# Системные импорты import asyncio, json, logging, os, re, random, sqlite3, threading from dataclasses import dataclass from datetime import datetime, time, timedelta from io import BytesIO from pathlib import Path from zoneinfo import ZoneInfo # Стороние импорты import requests from PIL import Image, ImageDraw, ImageFont from aiogram import Bot, Dispatcher, F, types from aiogram.filters import Command, CommandStart from aiogram.enums import ParseMode from aiogram.types import ( BufferedInputFile, Message, BotCommand, FSInputFile, BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats, WebAppInfo, InlineKeyboardMarkup, InlineKeyboardButton ) from aiogram.client.default import DefaultBotProperties # Локальные импорты from AI.talk_handler import handle_chat_message, handle_photo_message, handle_talk, push_message from games.casino import play_casino from games.fortune import generate_fortune from games.uwu import handle_uwu_cmd from games.nude import handle_nude_cmd from games.betting import ( clear_user_sport_context, debug_sports, describe_match_outcomes, get_formatted_matches, get_match_by_index, get_user_bet_history, get_user_bets, get_user_sport_context, place_bet, remember_user_sport_context, resolve_match_outcome, settle_bets, ) from games.economy import economy from zparser import get_military_data import config logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) _polychaetsi_lock = threading.Lock() _polychaetsi_word_re = re.compile(r"[а-яё]+", re.IGNORECASE) _no_lesson_re = re.compile(r"(нету\s+пары|нет\s+пары|пары\s+нету|пары\s+нет)", re.IGNORECASE) def _is_supported_image_message(message: Message) -> bool: return bool( message.photo or ( message.document and (message.document.mime_type or "").startswith("image/") ) ) def _is_watch_image_message(message: Message) -> bool: if not _is_supported_image_message(message): return False caption = (message.caption or "").strip() return bool(re.match(r"^/watch(?:@[A-Za-z0-9_]+)?(?:\s|$)", caption, flags=re.IGNORECASE)) async def handle_unmatched_message_debug(message: Message): logger.warning( "Unhandled message reached fallback. content_type=%s chat_id=%s has_text=%s has_photo=%s has_document=%s caption=%s", getattr(message, "content_type", None), message.chat.id if message.chat else None, bool(message.text), bool(message.photo), bool(message.document), bool(message.caption), ) _weekday_names = { 0: "Понедельник", 1: "Вторник", 2: "Среда", 3: "Четверг", 4: "Пятница", 5: "Суббота", 6: "Воскресенье", } _weekday_aliases = { "mon": 0, "monday": 0, "пн": 0, "пон": 0, "понедельник": 0, "tue": 1, "tues": 1, "tuesday": 1, "вт": 1, "втр": 1, "вторник": 1, "wed": 2, "wednesday": 2, "ср": 2, "срд": 2, "среда": 2, "thu": 3, "thur": 3, "thurs": 3, "thursday": 3, "чт": 3, "чет": 3, "четверг": 3, "fri": 4, "friday": 4, "пт": 4, "пят": 4, "пятница": 4, "sat": 5, "saturday": 5, "сб": 5, "суб": 5, "суббота": 5, "sun": 6, "sunday": 6, "вс": 6, "вск": 6, "воскресенье": 6, } @dataclass(frozen=True) class LessonSlot: pair_number: int | None title: str start: time | None end: time | None is_free: bool # --- Инициализация и вспомогательные функции --- def init_penis_db() -> None: """Инициализация БД для миниигры.""" Path(config.PENIS_DB_PATH).parent.mkdir(parents=True, exist_ok=True) with sqlite3.connect(config.PENIS_DB_PATH) as conn: conn.execute( """ CREATE TABLE IF NOT EXISTS penis_stats ( user_id INTEGER PRIMARY KEY, display_name TEXT NOT NULL, length REAL NOT NULL, last_used_ts INTEGER ) """ ) conn.execute("CREATE INDEX IF NOT EXISTS idx_penis_stats_length ON penis_stats(length DESC)") conn.commit() def init_polychaetsi_storage() -> None: """Инициализация json-хранилища статистики слова 'получается'.""" path = config.POLYCHAETSI_STATS_PATH path.parent.mkdir(parents=True, exist_ok=True) if not path.exists(): path.write_text('{"users": {}}', encoding="utf-8") def templates_list_text() -> str: """Список макетов для подсказки.""" parts = [] for tid in sorted(config.TEMPLATES.keys()): label = config.TEMPLATES[tid].get("label", "") parts.append(f"{tid} — {label}" if label else tid) return ", ".join(parts) if parts else "нет макетов" def _format_delta_ms(delta: timedelta) -> str: """HH:MM:SS.mmm, не допускаем отрицательных значений.""" total_ms = int(max(delta.total_seconds(), 0) * 1000) hours, rem = divmod(total_ms, 3_600_000) minutes, rem = divmod(rem, 60_000) seconds, milliseconds = divmod(rem, 1_000) return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{milliseconds:03d}" def _normalize_now(now: datetime | None = None) -> datetime: if now is None: return datetime.now(ZoneInfo(config.DEFAULT_TZ)) if now.tzinfo is None: return now.replace(tzinfo=ZoneInfo(config.DEFAULT_TZ)) return now def _current_week_key(now: datetime) -> str: week_number = now.isocalendar().week is_upper = (week_number % 2 == 1) == config.UPPER_WEEK_IS_ODD return "upper" if is_upper else "lower" def _resolve_schedule_date(now: datetime, requested_weekday: int | None = None) -> datetime: if requested_weekday is None: return now delta_days = (requested_weekday - now.weekday()) % 7 return now + timedelta(days=delta_days) def _parse_weekday_arg(value: str) -> int | None: normalized = re.sub(r"[^a-zа-яё]", "", value.lower()) if not normalized: return None return _weekday_aliases.get(normalized) def _build_lesson_slot(raw_entry: dict) -> LessonSlot: pair_number = raw_entry.get("pair") start_t, end_t = config.PAIR_TIMES.get(pair_number, (None, None)) title = str(raw_entry.get("title", "")).strip() or "Пара не указана" return LessonSlot( pair_number=pair_number, title=title, start=start_t, end=end_t, is_free=bool(_no_lesson_re.search(title)), ) def get_lessons_for_day(now: datetime | None = None) -> tuple[str, list[LessonSlot]]: now = _normalize_now(now) week_key = _current_week_key(now) raw_day = config.LESSON_SCHEDULE.get(week_key, {}).get(now.weekday(), []) lessons = [_build_lesson_slot(item) for item in raw_day] return week_key, lessons def _lesson_bounds(now: datetime, lesson: LessonSlot) -> tuple[datetime | None, datetime | None]: if lesson.start is None or lesson.end is None: return None, None start_dt = now.replace(hour=lesson.start.hour, minute=lesson.start.minute, second=0, microsecond=0) end_dt = now.replace(hour=lesson.end.hour, minute=lesson.end.minute, second=0, microsecond=0) return start_dt, end_dt def _find_current_lesson(now: datetime, lessons: list[LessonSlot]) -> LessonSlot | None: for lesson in lessons: start_dt, end_dt = _lesson_bounds(now, lesson) if start_dt and end_dt and start_dt <= now < end_dt: return lesson return None def _find_next_lesson(now: datetime, lessons: list[LessonSlot]) -> LessonSlot | None: for lesson in lessons: start_dt, _ = _lesson_bounds(now, lesson) if start_dt and start_dt > now and not lesson.is_free: return lesson return None def _format_lesson_brief(lesson: LessonSlot) -> str: prefix = f"{lesson.pair_number} пара" if lesson.pair_number is not None else "Пара" if lesson.start and lesson.end: return f"{prefix} ({lesson.start:%H:%M}-{lesson.end:%H:%M}) — {lesson.title}" return f"{prefix} — {lesson.title}" def _format_lesson_line(lesson: LessonSlot) -> str: if lesson.pair_number is None: return f"• {lesson.title}" if lesson.start and lesson.end: return f"{lesson.pair_number}. {lesson.start:%H:%M}-{lesson.end:%H:%M} — {lesson.title}" return f"{lesson.pair_number}. {lesson.title}" def next_lesson_text(now: datetime | None = None) -> str: now = _normalize_now(now) _, lessons = get_lessons_for_day(now) next_lesson = _find_next_lesson(now, lessons) if not next_lesson: return "Следующей пары сегодня нет." return f"Следующая пара: {_format_lesson_brief(next_lesson)}" def build_schedule_text(now: datetime | None = None, requested_weekday: int | None = None) -> str: now = _normalize_now(now) target_now = _resolve_schedule_date(now, requested_weekday) week_key, lessons = get_lessons_for_day(target_now) show_live_status = target_now.date() == now.date() current_lesson = _find_current_lesson(now, lessons) if show_live_status else None next_lesson = _find_next_lesson(now, lessons) if show_live_status else None week_label = "Верхняя" if week_key == "upper" else "Нижняя" day_label = _weekday_names.get(target_now.weekday(), "Сегодня") if not show_live_status: day_label = f"{day_label}, {target_now:%d.%m.%Y}" lines = [f"{week_label} неделя", day_label] if lessons: lines.append("") lines.extend(_format_lesson_line(lesson) for lesson in lessons) else: lines.append("") lines.append("На этот день расписание пустое.") if show_live_status: lines.append("") if current_lesson: if current_lesson.is_free: lines.append(f"Сейчас: {current_lesson.pair_number} пара свободна") else: lines.append(f"Сейчас: {_format_lesson_brief(current_lesson)}") else: lines.append("Сейчас: пары нет") if next_lesson: lines.append(f"Следующая: {_format_lesson_brief(next_lesson)}") else: lines.append("Следующая: сегодня нет") return "\n".join(lines) def minutes_until_break(now: datetime | None = None) -> str: """Логика расписания перекуров.""" now = _normalize_now(now) day = now.weekday() today_schedule = config.SCHEDULE.get(day, []) if not today_schedule: return "Сегодня кайфуем дома, перекур не нужен." def dt_for(t: time) -> datetime: return now.replace(hour=t.hour, minute=t.minute, second=0, microsecond=0) for start_t, end_t, br_minutes in today_schedule: start_dt = dt_for(start_t) end_dt = dt_for(end_t) if now < start_dt or (start_dt <= now < end_dt): return f"{_format_delta_ms(end_dt - now)} до перекура" if br_minutes: break_end = end_dt + timedelta(minutes=br_minutes) if end_dt <= now < break_end: return f"Идёт перекур, осталось {_format_delta_ms(break_end - now)}" return "Пары закончились, перекур только завтра." def next_break_window(now: datetime | None = None) -> tuple[datetime | None, datetime | None]: """Возвращает (начало, конец) ближайшего перекура или (None, None).""" now = _normalize_now(now) day = now.weekday() today_schedule = config.SCHEDULE.get(day, []) if not today_schedule: return None, None def dt_for(t: time) -> datetime: return now.replace(hour=t.hour, minute=t.minute, second=0, microsecond=0) for start_t, end_t, br_minutes in today_schedule: if not br_minutes: continue end_dt = dt_for(end_t) br_start = end_dt br_end = end_dt + timedelta(minutes=br_minutes) # Ближайший будущий или текущий перекур if now < br_end: return br_start, br_end return None, None # --- Авто-напоминание о перекуре --- _break_tasks: dict[int, tuple[datetime, asyncio.Task]] = {} _break_notified: dict[int, datetime] = {} async def _send_break_photo(chat_id: int, bot: Bot, start_dt: datetime) -> None: last = _break_notified.get(chat_id) if last and last == start_dt: return # Уже кидали уведомление для этого слота photo_path = config.BASE_DIR / "maket" / "vape.jpg" if not photo_path.exists(): logger.warning("vape.jpg not found, skip break photo") return try: await bot.send_photo(chat_id=chat_id, photo=FSInputFile(photo_path), caption="идем курить") _break_notified[chat_id] = start_dt except Exception: logger.exception("Failed to send break photo") async def _break_notification(chat_id: int, bot: Bot, start_dt: datetime) -> None: try: now = datetime.now(start_dt.tzinfo) delay = (start_dt - now).total_seconds() if delay > 0: await asyncio.sleep(delay) await _send_break_photo(chat_id, bot, start_dt) except asyncio.CancelledError: pass finally: stored = _break_tasks.get(chat_id) if stored and stored[0] == start_dt: _break_tasks.pop(chat_id, None) def schedule_break_notification(chat_id: int, bot: Bot, start_dt: datetime) -> None: existing = _break_tasks.get(chat_id) if existing: scheduled_start, task = existing if scheduled_start == start_dt and not task.done(): return # Уже ждём этот же слот if not task.done(): task.cancel() task = asyncio.create_task(_break_notification(chat_id, bot, start_dt)) _break_tasks[chat_id] = (start_dt, task) # --- Работа с изображениями (Макеты и Мемы) --- def load_font(size: int) -> ImageFont.FreeTypeFont: if not config.FONT_PATH.exists(): raise FileNotFoundError(f"Шрифт не найден: {config.FONT_PATH}") return ImageFont.truetype(str(config.FONT_PATH), size=size) def wrap_text_to_width(text: str, font: ImageFont.FreeTypeFont, max_width: int, draw: ImageDraw.ImageDraw, stroke_width: int) -> list[str]: words = text.split() lines, current = [], [] for word in words: tentative = " ".join(current + [word]) if current else word bbox = draw.textbbox((0, 0), tentative, font=font, stroke_width=stroke_width) if bbox[2] - bbox[0] <= max_width: current.append(word) else: if current: lines.append(" ".join(current)) current = [word] if current: lines.append(" ".join(current)) return lines def parse_up_down(text: str) -> tuple[str, str]: """Парсит формат /gen_mem Up:текст Down:текст""" up = "" down = "" # Очищаем текст от самой команды text_normalized = re.sub(r'^/\w+\s*', '', text, flags=re.IGNORECASE).replace("\n", " ") up_match = re.search(r"(?is)up:(.*?)(?=\s+down:|$)", text_normalized) down_match = re.search(r"(?is)down:(.*)$", text_normalized) if up_match: up = up_match.group(1).strip() if down_match: down = down_match.group(1).strip() # Если меток нет, по старинке: первое слово вверх, остальное вниз if not up and not down: parts = text_normalized.split(None, 1) if parts: up = parts[0].strip() if len(parts) > 1: down = parts[1].strip() return up, down def fit_text_in_box(text: str, max_width: int, max_height: int, start_size: int = 72, min_size: int = 14): draw_dummy = ImageDraw.Draw(Image.new("RGB", (1, 1))) for size in range(start_size, min_size - 1, -2): font = load_font(size) stroke_width = max(2, size // 12) lines = wrap_text_to_width(text, font, max_width, draw_dummy, stroke_width) if not lines: continue line_heights = [draw_dummy.textbbox((0, 0), ln, font=font, stroke_width=stroke_width)[3] - draw_dummy.textbbox((0, 0), ln, font=font, stroke_width=stroke_width)[1] for ln in lines] total_height = sum(line_heights) + max(0, len(lines) - 1) * max(6, size // 6) if total_height <= max_height: return font, lines, stroke_width, max(6, size // 6) font = load_font(min_size) return font, [text], max(1, min_size // 12), max(4, min_size // 6) def draw_multiline(draw: ImageDraw.ImageDraw, lines: list[str], position: tuple[int, int], font: ImageFont.FreeTypeFont, stroke_width: int, line_spacing: int, align_center: bool = False, max_width: int | None = None): x, y = position for line in lines: bbox = draw.textbbox((0, 0), line, font=font, stroke_width=stroke_width) line_width = bbox[2] - bbox[0] draw_x = x if align_center and max_width is not None: draw_x = x + (max_width - line_width) // 2 draw.text((draw_x, y), line, font=font, fill="white", stroke_width=stroke_width, stroke_fill="black") y += (bbox[3] - bbox[1]) + line_spacing def calculate_font_for_width(text: str, image_width: int, max_ratio: float = 0.09, min_size: int = 18): start_size = max(int(image_width * max_ratio), min_size) draw_dummy = ImageDraw.Draw(Image.new("RGB", (image_width, image_width))) for size in range(start_size, min_size - 1, -2): font = load_font(size) stroke_width = max(2, size // 12) wrapped = wrap_text_to_width(text, font, int(image_width * 0.95), draw_dummy, stroke_width) if wrapped: height = 0 for ln in wrapped: bbox = draw_dummy.textbbox((0, 0), ln, font=font, stroke_width=stroke_width) height += bbox[3] - bbox[1] height += max(0, len(wrapped) - 1) * max(6, size // 6) if height <= image_width * 0.35: return font, wrapped, stroke_width, max(6, size // 6) font = load_font(min_size) stroke_width = max(1, min_size // 12) wrapped = wrap_text_to_width(text, font, int(image_width * 0.95), draw_dummy, stroke_width) return font, wrapped or [text], stroke_width, max(4, min_size // 6) def generate_maket(text: str, template_id: str = "1") -> BytesIO: tpl = config.TEMPLATES.get(template_id) if not tpl or not tpl["path"].exists(): raise FileNotFoundError(f"Шаблон {template_id} не найден") with Image.open(tpl["path"]).convert("RGB") as img: anchor = tpl["anchor"] if "box_size" in tpl: max_w, max_h = tpl["box_size"] else: padding = tpl.get("padding", 0) max_w, max_h = img.width - anchor[0] - padding, img.height - anchor[1] - padding font, lines, stroke, spacing = fit_text_in_box(text, max_w, max_h) draw = ImageDraw.Draw(img) y_offset = anchor[1] for line in lines: draw.text((anchor[0], y_offset), line, font=font, fill="white", stroke_width=stroke, stroke_fill="black") bbox = draw.textbbox((0, 0), line, font=font, stroke_width=stroke) y_offset += (bbox[3] - bbox[1]) + spacing output = BytesIO() img.save(output, format="PNG") output.seek(0) return output def generate_meme(img: Image.Image, top_text: str, bottom_text: str) -> BytesIO: img = img.convert("RGB") draw = ImageDraw.Draw(img) width, height = img.size margin_y = int(height * 0.04) if top_text: font, lines, stroke_width, spacing = calculate_font_for_width(top_text, width) draw_multiline( draw, lines, (int(width * 0.025), margin_y), font, stroke_width, spacing, align_center=True, max_width=int(width * 0.95), ) if bottom_text: font, lines, stroke_width, spacing = calculate_font_for_width(bottom_text, width) block_height = 0 for ln in lines: bbox = draw.textbbox((0, 0), ln, font=font, stroke_width=stroke_width) block_height += bbox[3] - bbox[1] block_height += max(0, len(lines) - 1) * spacing start_y = height - margin_y - block_height draw_multiline( draw, lines, (int(width * 0.025), start_y), font, stroke_width, spacing, align_center=True, max_width=int(width * 0.95), ) output = BytesIO() img.save(output, format="JPEG", quality=95) output.seek(0) return output def minutes_until_next_lesson(now: datetime | None = None) -> str: if now is None: now = datetime.now(ZoneInfo(config.DEFAULT_TZ)) elif now.tzinfo is None: now = now.replace(tzinfo=ZoneInfo(config.DEFAULT_TZ)) lesson_days = [1, 3] # вторник и четверг lesson_start = time(9, 50) lesson_end = time(11, 25) day = now.weekday() def lesson_start_for(day_offset: int) -> datetime: target = now + timedelta(days=day_offset) return target.replace(hour=lesson_start.hour, minute=lesson_start.minute, second=0, microsecond=0) def lesson_end_today() -> datetime: return now.replace(hour=lesson_end.hour, minute=lesson_end.minute, second=0, microsecond=0) if day in lesson_days: start_today = lesson_start_for(0) end_today = lesson_end_today() if start_today <= now < end_today: next_dt = None for i in range(1, 8): next_day = (day + i) % 7 if next_day in lesson_days: next_dt = lesson_start_for(i) break if next_dt is None: return "Следующий урок не найден." delta = next_dt - now total_seconds = int(delta.total_seconds()) days = total_seconds // 86400 hours = (total_seconds % 86400) // 3600 minutes = (total_seconds % 3600) // 60 percent = random.randint(0, 100) return ( f"Сегодня твой мозг сгнил на {percent}%\n" f"Мозг начнет догнивать через {days} дн {hours} ч {minutes} мин" ) delta = None for i in range(0, 8): next_day = (day + i) % 7 if next_day in lesson_days: candidate = lesson_start_for(i) if candidate > now: delta = candidate - now break if delta is None: return "Ближайший урок не найден, кайфуй." total_seconds = int(delta.total_seconds()) days = total_seconds // 86400 hours = (total_seconds % 86400) // 3600 minutes = (total_seconds % 3600) // 60 return f"Твой мозг окончательно сгниет, а очко порвется через: {days} дн {hours} ч {minutes} мин" def _prepare_magnit_query(value: str) -> str: value = re.sub(r"(?<=[a-zа-яё])(?=[A-ZА-ЯЁ])", " ", value) value = re.sub(r"[_-]+", " ", value) return re.sub(r"\s+", " ", value).strip() def _normalize_magnit_product(entry) -> dict[str, str]: if isinstance(entry, str): raw_query = entry.strip() if not raw_query: raise ValueError("Пустой query в MAGNIT_PRODUCTS.") return { "query": _prepare_magnit_query(raw_query), "label": raw_query, "store_code": config.MAGNIT_STORE_CODE, } if isinstance(entry, dict): raw_query = str(entry.get("query", "")).strip() if not raw_query: raise ValueError("У товара в MAGNIT_PRODUCTS отсутствует query.") return { "query": _prepare_magnit_query(raw_query), "label": str(entry.get("label") or raw_query).strip() or raw_query, "store_code": str(entry.get("store_code") or config.MAGNIT_STORE_CODE).strip(), } raise ValueError("MAGNIT_PRODUCTS должен содержать строки или словари.") def _pick_magnit_items(data: dict) -> list[dict]: items = data.get("items") if isinstance(items, list): return [item for item in items if isinstance(item, dict)] best_match = data.get("best_match") if isinstance(best_match, dict): return [best_match] return [] def _format_money(value) -> str | None: if value is None: return None amount = f"{float(value):.2f}".rstrip("0").rstrip(".") return f"{amount} ₽" def _format_magnit_price(item: dict) -> str: new_price = _format_money(item.get("price")) old_price = _format_money(item.get("old_price")) if old_price and new_price and item.get("old_price") and item.get("old_price") != item.get("price"): return f"{old_price} -> {new_price}" return new_price or "нет цены" def _format_magnit_quantity(value) -> str: if value is None: return "нет данных" return f"{value} шт." async def _send_lines_in_chunks(message: Message, lines: list[str], limit: int = 4000) -> None: if not lines: await message.answer("Товары не найдены.") return chunk: list[str] = [] chunk_len = 0 for line in lines: extra_len = len(line) + (1 if chunk else 0) if chunk and chunk_len + extra_len > limit: await message.answer("\n".join(chunk)) chunk = [line] chunk_len = len(line) continue chunk.append(line) chunk_len += extra_len if chunk: await message.answer("\n".join(chunk)) # --- Игровая логика (/penis) --- def play_penis(user_id: int, user_name: str | None = None, now: datetime | None = None) -> tuple[bool, str]: if now is None: now = datetime.now(ZoneInfo(config.DEFAULT_TZ)) elif now.tzinfo is None: now = now.replace(tzinfo=ZoneInfo(config.DEFAULT_TZ)) now_ts = int(now.timestamp()) display_name = user_name.strip() if user_name and user_name.strip() else f"user_{user_id}" try: with sqlite3.connect(config.PENIS_DB_PATH) as conn: conn.row_factory = sqlite3.Row row = conn.execute( "SELECT length, last_used_ts, display_name FROM penis_stats WHERE user_id = ?", (user_id,), ).fetchone() if row is None: current_length = config.PENIS_START_LENGTH last_used_ts = None else: current_length = float(row["length"]) last_used_ts = row["last_used_ts"] stored_name = row["display_name"] if not user_name and isinstance(stored_name, str) and stored_name.strip(): display_name = stored_name.strip() if last_used_ts is not None: next_ts = int(last_used_ts) + config.PENIS_COOLDOWN_SECONDS if now_ts < next_ts: if row is not None and row["display_name"] != display_name: conn.execute( "UPDATE penis_stats SET display_name = ? WHERE user_id = ?", (display_name, user_id), ) left_seconds = next_ts - now_ts hours = left_seconds // 3600 minutes = (left_seconds % 3600) // 60 conn.commit() return False, f"Сегодня уже кидал. Попробуй через {hours} ч {minutes} мин." delta = round(random.uniform(config.PENIS_MIN_DELTA, config.PENIS_MAX_DELTA), 1) sign = random.choice([-1, 1]) new_length = round(max(0.1, current_length + sign * delta), 1) if row is None: conn.execute( "INSERT INTO penis_stats(user_id, display_name, length, last_used_ts) VALUES(?, ?, ?, ?)", (user_id, display_name, new_length, now_ts), ) else: conn.execute( "UPDATE penis_stats SET display_name = ?, length = ?, last_used_ts = ? WHERE user_id = ?", (display_name, new_length, now_ts, user_id), ) conn.commit() # Обновляем баланс в экономической системе if sign > 0: economy.update_balance(user_id, delta, 'penis_game', f'Выигрыш в игре +{delta} см') else: economy.update_balance(user_id, delta, 'penis_game', f'Проигрыш в игре {delta} см') except sqlite3.Error: logger.exception("Failed to process /penis") return False, "Ошибка БД. Попробуй позже." sign_text = "+" if sign > 0 else "-" return ( True, f"📏 Изменение: {sign_text}{delta:.1f} см\n" f"Текущая длина: {new_length:.1f} см\n" "Следующая попытка через 24 часа.", ) def format_penis_user_name(user: types.User) -> str: if user.username: return f"@{user.username}" if user.full_name: return user.full_name return f"user_{user.id}" def build_penis_top(limit: int = config.PENIS_TOP_LIMIT) -> str: try: with sqlite3.connect(config.PENIS_DB_PATH) as conn: conn.row_factory = sqlite3.Row rows = conn.execute( """ SELECT user_id, display_name, length FROM penis_stats ORDER BY length DESC, user_id ASC LIMIT ? """, (limit,), ).fetchall() except sqlite3.Error: logger.exception("Failed to build /top_penis") return "Не удалось загрузить топ. Ошибка БД." if not rows: return "🏆 Топ пуст. Сначала пусть кто-то крутнет /penis." medals = {1: "🥇", 2: "🥈", 3: "🥉"} lines = ["🏆 Топ по длине:"] for index, row in enumerate(rows, start=1): user_id = int(row["user_id"]) length = float(row["length"]) name = row["display_name"] if isinstance(row["display_name"], str) else f"user_{user_id}" name = name.strip() if name.strip() else f"user_{user_id}" prefix = medals.get(index, f"{index}.") lines.append(f"{prefix} {name} — {length:.1f} см") return "\n".join(lines) def _load_polychaetsi_stats() -> dict: path = config.POLYCHAETSI_STATS_PATH if not path.exists(): return {"users": {}} try: with path.open("r", encoding="utf-8") as fh: data = json.load(fh) except (OSError, json.JSONDecodeError): logger.exception("Failed to load polychaetsi stats") return {"users": {}} if not isinstance(data, dict): return {"users": {}} users = data.get("users") if not isinstance(users, dict): data["users"] = {} return data def _save_polychaetsi_stats(data: dict) -> None: path = config.POLYCHAETSI_STATS_PATH path.parent.mkdir(parents=True, exist_ok=True) tmp_path = path.with_name(f"{path.name}.tmp") with tmp_path.open("w", encoding="utf-8") as fh: json.dump(data, fh, ensure_ascii=False, indent=2) tmp_path.replace(path) def _damerau_levenshtein_distance(source: str, target: str, max_distance: int = 2) -> int: if source == target: return 0 if abs(len(source) - len(target)) > max_distance: return max_distance + 1 previous_previous: list[int] | None = None previous = list(range(len(target) + 1)) for i, source_char in enumerate(source, start=1): current = [i] + [0] * len(target) row_min = current[0] for j, target_char in enumerate(target, start=1): cost = 0 if source_char == target_char else 1 current[j] = min( previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost, ) if ( previous_previous is not None and i > 1 and j > 1 and source_char == target[j - 2] and source[i - 2] == target_char ): current[j] = min(current[j], previous_previous[j - 2] + 1) row_min = min(row_min, current[j]) if row_min > max_distance: return max_distance + 1 previous_previous, previous = previous, current return previous[-1] def _normalize_polychaetsi_word(word: str) -> str: normalized = word.lower().replace("ё", "е") # Подтягиваем самые частые ошибки в возвратном окончании. return re.sub(r"(ть?ся|ть?са|цца|ца)$", "тся", normalized) def is_polychaetsi_variant(word: str) -> bool: normalized = _normalize_polychaetsi_word(word) target = config.POLYCHAETSI_TARGET if normalized == target: return True if not normalized or normalized[0] != "п": return False if len(normalized) < len(target) - 1 or len(normalized) > len(target) + 1: return False if "ч" not in normalized or "а" not in normalized or "е" not in normalized: return False if not normalized.endswith(("я", "а")): return False return _damerau_levenshtein_distance(normalized, target, max_distance=2) <= 2 def count_polychaetsi_mentions(text: str) -> int: return sum(1 for word in _polychaetsi_word_re.findall(text) if is_polychaetsi_variant(word)) def register_polychaetsi_hits(user: types.User, hits: int) -> int: display_name = format_penis_user_name(user) with _polychaetsi_lock: data = _load_polychaetsi_stats() users = data.setdefault("users", {}) user_key = str(user.id) entry = users.get(user_key) if not isinstance(entry, dict): entry = {"display_name": display_name, "count": 0} stored_count = entry.get("count", 0) try: count = int(stored_count) except (TypeError, ValueError): count = 0 count += hits entry["display_name"] = display_name entry["count"] = count users[user_key] = entry _save_polychaetsi_stats(data) return count def build_polychaetsi_top(limit: int = config.POLYCHAETSI_TOP_LIMIT) -> str: with _polychaetsi_lock: data = _load_polychaetsi_stats() users = data.get("users", {}) if not isinstance(users, dict): return "ТОП получается:\nпока пусто." entries: list[tuple[int, str, int]] = [] for user_key, raw_entry in users.items(): if not isinstance(raw_entry, dict): continue try: count = int(raw_entry.get("count", 0)) except (TypeError, ValueError): continue if count <= 0: continue try: numeric_user_id = int(user_key) except (TypeError, ValueError): numeric_user_id = 0 name = str(raw_entry.get("display_name") or f"user_{user_key}").strip() or f"user_{user_key}" entries.append((count, name, numeric_user_id)) if not entries: return "ТОП получается:\nпока пусто." entries.sort(key=lambda item: (-item[0], item[1].lower(), item[2])) medals = {1: "🥇", 2: "🥈", 3: "🥉"} lines = ["ТОП получается:"] for index, (count, name, _) in enumerate(entries[:limit], start=1): prefix = medals.get(index, f"{index}.") lines.append(f"{prefix} {name} — {count}") return "\n".join(lines) # --- Хендлеры aiogram --- async def handle_help(message: Message): await message.answer(config.get_hint_text(templates_list_text()), parse_mode=None) async def handle_start(message: Message): webapp_url = os.getenv("WEBAPP_URL", "") if webapp_url: if message.chat.type == "private": kb = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="🎮 Открыть Mini App", web_app=WebAppInfo(url=webapp_url))] ]) await message.answer("Йо, кент! Жми кнопку 👇", reply_markup=kb) else: await message.answer("Йо, кент! Mini App доступно только в личных сообщениях. Пиши мне в личку!") else: await message.answer("Соси") async def handle_app_cmd(message: Message): """Открыть Mini App.""" webapp_url = os.getenv("WEBAPP_URL", "") if not webapp_url: await message.answer("Mini App не настроен.") return if message.chat.type == "private": kb = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="🎮 Открыть Mini App", web_app=WebAppInfo(url=webapp_url))] ]) await message.answer("⬇️ Жми кнопку чтобы открыть приложение", reply_markup=kb) else: await message.answer("Братуха, встроенная кнопка Mini App пока работает только в личке (ограничение Telegram). Перейди в личные сообщения с ботом или используй прямую ссылку: https://t.me/FenyaBotTest_bot/app (если она настроена).") async def handle_schedule_cmd(message: Message): now = datetime.now(ZoneInfo(config.DEFAULT_TZ)) raw_text = message.text or "" parts = raw_text.split(maxsplit=1) requested_weekday = None if len(parts) > 1: requested_weekday = _parse_weekday_arg(parts[1].split(maxsplit=1)[0]) if requested_weekday is None: await message.answer( "Не понял день недели. Примеры: /schedule mon, /schedule fri, /schedule пн, /schedule чт." ) return await message.answer(build_schedule_text(now, requested_weekday)) async def handle_break_cmd(message: Message): now = datetime.now(ZoneInfo(config.DEFAULT_TZ)) await message.answer(f"{minutes_until_break(now)}\n{next_lesson_text(now)}") # Ставим будильник на ближайший перекур для этого чата start_dt, _ = next_break_window(now) if start_dt: schedule_break_notification(message.chat.id, message.bot, start_dt) async def handle_penis_cmd(message: Message): if not message.from_user: await message.answer("Команда доступна только пользователям.") return display_name = format_penis_user_name(message.from_user) await message.answer(play_penis(message.from_user.id, display_name)[1]) async def handle_top_penis_cmd(message: Message): await message.answer(build_penis_top()) async def handle_polychaetsi_cmd(message: Message): await message.answer(build_polychaetsi_top()) async def handle_size_cmd(message: Message): size = round(random.uniform(0.1, 35.0), 1) if size < 5: status = "СВОи" elif size < 10: status = "Неплохой" elif size < 18: status = "Шлюхи ахуевают при виде тебя" elif size < 25: status = "Хуек с коробок" elif size < 32: status = "Пиздабол" else: status = "Говноед" await message.answer(f"📏 Твой хуек: {size} см\nТы: {status}") async def handle_zvetok_cmd(message: Message): await message.answer(minutes_until_next_lesson()) async def handle_prices_cmd(message: Message): try: products = [_normalize_magnit_product(entry) for entry in config.MAGNIT_PRODUCTS] except ValueError as exc: await message.answer(f"Проверь MAGNIT_PRODUCTS в config.py: {exc}") return if not products: await message.answer("Список MAGNIT_PRODUCTS пуст. Добавь товары в config.py.") return async def fetch_product(product_cfg: dict[str, str]) -> list[str]: def fetch(): return requests.get( f"{config.MAGNIT_API_BASE_URL.rstrip('/')}/price", params={ "store_code": product_cfg["store_code"], "query": product_cfg["query"], }, timeout=config.MAGNIT_REQUEST_TIMEOUT, ) label = product_cfg["label"] or product_cfg["query"] or "товар" try: resp = await asyncio.to_thread(fetch) if resp.status_code != 200: logger.warning("Magnit API returned %s for %s", resp.status_code, product_cfg["query"]) return [f"{label}, ошибка API ({resp.status_code}), -"] data = resp.json() if not isinstance(data, dict): return [f"{label}, битый ответ API, -"] items = _pick_magnit_items(data) if not items: return [f"{label}, товар не найден, -"] lines = [] for item in items: name = str(item.get("name") or label).strip() or label price = _format_magnit_price(item) quantity = _format_magnit_quantity(item.get("quantity")) lines.append(f"{name}, {price}, {quantity}") return lines except requests.RequestException: logger.exception("Failed to fetch Magnit product: %s", product_cfg["query"]) return [f"{label}, ошибка запроса, -"] except ValueError: logger.exception("Magnit API returned invalid JSON for %s", product_cfg["query"]) return [f"{label}, битый JSON, -"] result_groups = await asyncio.gather(*(fetch_product(product) for product in products)) lines = [line for group in result_groups for line in group] await _send_lines_in_chunks(message, lines) async def handle_fetch_cmd(message: Message): await handle_prices_cmd(message) async def handle_svodka_cmd(message: Message): data = await asyncio.to_thread(get_military_data, "creamy_caprice", 5) if data["status"] == "success": response = ( f"в период с {data['dates']} хохлы в среднем проебывали по {data['pace']} км² в сутки, " f"а за день ВС РФ лишала чубатых в среднем по {data['total']} км²" ) await message.answer(response) else: await message.answer(f"Хохлы ответят за это...: {data['message']}") async def handle_ebalnik_cmd(message: Message): chat_id = message.chat.id if chat_id in _autoreply_disabled_chats: _autoreply_disabled_chats.remove(chat_id) await message.reply("Автоответы вернул, бот снова влезает в чат.") else: _autoreply_disabled_chats.add(chat_id) await message.reply("Автоответы срезал, бот теперь молчит в этом чате.") async def handle_maket_cmd(message: Message): parts = message.text.split(maxsplit=2) if len(parts) == 1: await message.reply(f"Доступные макеты: {templates_list_text()}\nФормат: /maket «id» «текст»") return if len(parts) < 3: await message.reply( f"Формат: /maket «id» «текст»\nДоступные макеты: {templates_list_text()}\n\n" + config.get_hint_text(templates_list_text()) ) return template_id = parts[1] if template_id not in config.TEMPLATES: await message.reply(f"Такого макета нет. Доступные: {templates_list_text()}") return text = parts[2].strip() if not text: await message.reply("Нужен текст после номера макета.") return try: output = await asyncio.to_thread(generate_maket, text, template_id) await message.reply_photo(BufferedInputFile(output.getvalue(), filename="maket.png")) except Exception as exc: logger.exception("Failed to generate maket") await message.reply(f"Не получилось: {exc}") async def handle_gen_mem(message: Message, bot: Bot): photo_msg = message if message.photo else (message.reply_to_message if message.reply_to_message and message.reply_to_message.photo else None) if not photo_msg: await message.reply("Еблан, /gen_mem Up: Down:") return full_text = message.text or "" top_text, bottom_text = parse_up_down(full_text) if not top_text and not bottom_text: await message.reply("Еблан, /gen_mem Up: Down:") return try: file = await bot.get_file(photo_msg.photo[-1].file_id) buf = BytesIO() await bot.download_file(file.file_path, destination=buf) buf.seek(0) img = Image.open(buf) output = await asyncio.to_thread(generate_meme, img, top_text, bottom_text) except Exception as exc: logger.exception("Failed to render meme") await message.reply(f"Не получилось сделать мем: {exc}") return await message.reply_photo(BufferedInputFile(output.getvalue(), filename="meme.jpg")) _autoreply_disabled_chats: set[int] = set() async def handle_keywords(message: Message): if not message.text or message.text.startswith('/'): return # Считаем сообщение для экономической системы if message.from_user: await asyncio.to_thread(economy.count_message, message.from_user.id) text_lower = message.text.lower() chat_id = message.chat.id user_name = (message.from_user.first_name or "кент") if message.from_user else "кент" # /ebalnik должен глушить все авто-реакции в чате, включая медиа/фото-ветки. if chat_id in _autoreply_disabled_chats: return if any(re.search(rf'\b{word}\b', text_lower) for word in config.KEYWORDS): audio_path = config.BASE_DIR / "maket" / "Radiohead - Creep.mp3" if audio_path.exists(): await message.answer_audio( audio=FSInputFile(audio_path), caption="I'm a creep, I'm a weirdo...", ) return polychaetsi_hits = count_polychaetsi_mentions(text_lower) if polychaetsi_hits > 0 and message.from_user: total_hits = await asyncio.to_thread(register_polychaetsi_hits, message.from_user, polychaetsi_hits) photo_path = config.POLYCHAETSI_IMAGE_PATH if photo_path.exists(): await message.answer_photo( photo=FSInputFile(photo_path), caption=f"x{total_hits}", ) else: logger.warning("cvetok.jpg not found, fallback to text response") await message.answer(f"x{total_hits}") return # Записываем каждое обычное сообщение в историю чата await asyncio.to_thread(push_message, chat_id, "user", user_name, message.text) if chat_id in _autoreply_disabled_chats: return await handle_chat_message(message, store_message=False, allow_autonomous=True) async def handle_penis_casino_cmd(message: Message): if not message.from_user: await message.answer("Команда доступна только пользователям.") return parts = message.text.split(maxsplit=1) if len(parts) < 2: await message.reply("Формат: /penis_casino [ставка]\nПример: /penis_casino 0.5", parse_mode=None) return try: bet = round(float(parts[1].replace(",", ".")), 1) except ValueError: await message.reply("Ставка должна быть числом, фраер. Пример: /penis_casino 0.5") return result = await asyncio.to_thread(play_casino, message.from_user.id, bet) await message.reply(result, parse_mode=None) # --- Экономические команды --- async def handle_balance_cmd(message: Message): """Показать баланс пользователя""" if not message.from_user: await message.answer("Команда доступна только пользователям.") return balance = economy.get_user_balance(message.from_user.id) stats = await asyncio.to_thread(economy.get_user_stats, message.from_user.id) cb_stats = await asyncio.to_thread(economy.get_central_bank_stats) text = f"""💰 **Твой баланс:** {balance:.1f} см 📊 **Статистика:** • Активные вклады: {stats['active_deposits_count']} шт. ({stats['active_deposits_sum']:.1f} см) • Активные кредиты: {stats['active_loans_count']} шт. ({stats['active_loans_sum']:.1f} см) • Сообщений сегодня: {stats['today_messages']} шт. • Сообщений вчера: {stats['yesterday_messages']} шт. • Ожидаемый доход: {stats['yesterday_messages'] * 0.01:.2f} см 🏛️ **Центральный Банк:** • Капитал ЦБ: {cb_stats['capital']:.1f} см • Собрано налогов: {cb_stats['total_taxes_collected']:.1f} см 💡 **Команды экономики:** /balance - показать баланс /deposit [сумма] [дни] - открыть вклад /loans - показать кредиты /loan [сумма] [дни] - взять кредит /repay [ID] - погасить кредит /transfer @username [сумма] - перевести деньги /cb_stats - статистика ЦБ /vanomasa - полное обнуление (для админов) 💬 **Заработок на сообщениях:** 0.01 см за каждое сообщение Выплата в 00:00 по МСК """ await message.answer(text, parse_mode=None) async def handle_deposit_cmd(message: Message): """Открыть вклад""" if not message.from_user: await message.answer("Команда доступна только пользователям.") return parts = message.text.split(maxsplit=2) if len(parts) < 3: await message.reply("Формат: /deposit [сумма] [дни]\nПример: /deposit 50 30", parse_mode=None) return try: amount = float(parts[1].replace(",", ".")) days = int(parts[2]) except ValueError: await message.reply("Сумма должна быть числом, дни - целым числом.", parse_mode=None) return if amount <= 0 or days <= 0: await message.reply("Сумма и дни должны быть положительными.", parse_mode=None) return success = await asyncio.to_thread(economy.create_deposit, message.from_user.id, amount, days) if success: rate = economy.get_setting('central_bank_rate') await message.reply(f"✅ Вклад открыт!\n💰 Сумма: {amount:.1f} см\n📅 Срок: {days} дней\n📈 Ставка: {rate}% годовых", parse_mode=None) else: await message.reply("❌ Не удалось открыть вклад. Проверь баланс и минимальную сумму.", parse_mode=None) async def handle_loans_cmd(message: Message): """Показать кредиты""" if not message.from_user: await message.answer("Команда доступна только пользователям.") return # Здесь можно добавить логику показа кредитов пользователя await message.reply("📋 Твои кредиты:\n\n(функция в разработке)", parse_mode=None) async def handle_loan_cmd(message: Message): """Взять кредит""" if not message.from_user: await message.answer("Команда доступна только пользователям.") return parts = message.text.split(maxsplit=2) if len(parts) < 3: await message.reply("Формат: /loan [сумма] [дни]\nПример: /loan 50 30", parse_mode=None) return try: amount = float(parts[1].replace(",", ".")) days = int(parts[2]) except ValueError: await message.reply("Сумма должна быть числом, дни - целым числом.", parse_mode=None) return if amount <= 0 or days <= 0: await message.reply("Сумма и дни должны быть положительными.", parse_mode=None) return success = await asyncio.to_thread(economy.create_loan, message.from_user.id, amount, days) if success: rate = economy.get_setting('loan_interest_rate') await message.reply(f"✅ Кредит выдан!\n💰 Сумма: {amount:.1f} см\n📅 Срок: {days} дней\n📈 Ставка: {rate}% годовых", parse_mode=None) else: await message.reply("❌ Не удалось взять кредит. Проверь лимиты.", parse_mode=None) async def handle_transfer_cmd(message: Message): """Перевод денег другому пользователю""" if not message.from_user: await message.answer("Команда доступна только пользователям.") return parts = message.text.split(maxsplit=2) if len(parts) < 3: await message.reply("Формат: /transfer @username [сумма]\nПример: /transfer @user123 10.5", parse_mode=None) return username = parts[1].lstrip('@') try: amount = float(parts[2].replace(",", ".")) except ValueError: await message.reply("Сумма должна быть числом.", parse_mode=None) return if amount <= 0: await message.reply("Сумма должна быть положительной.", parse_mode=None) return # Здесь нужно найти user_id по username # Для простоты примера, пока покажем сообщение об ошибке await message.reply("🔍 Поиск пользователя...\n(функция поиска пользователей будет добавлена)", parse_mode=None) async def handle_vanomasa_cmd(message: Message): """Полное обнуление (только для админов)""" if not message.from_user: await message.answer("Команда доступна только пользователям.") return # Проверка на админа (простая реализация) if message.from_user.id not in [123456789]: # Заменить на реальные ID админов await message.reply("❌ Команда доступна только администраторам!", parse_mode=None) return success = await asyncio.to_thread(economy.vanomasa) if success: await message.reply("💥 **ВАНОМАСА!** 💥\n\nВсе балансы обнулены!", parse_mode=None) else: await message.reply("❌ Ваномаса еще не доступна. Подождите.", parse_mode=None) async def handle_central_bank_cmd(message: Message): """Статистика Центрального Банка""" if not message.from_user: await message.answer("Команда доступна только пользователям.") return cb_stats = await asyncio.to_thread(economy.get_central_bank_stats) text = f"""🏛️ **Центральный Банк ZOV OS** (децентрализованный) 💰 **Капитал ЦБ:** {cb_stats['capital']:.2f} см 📊 **Статистика операций:** • Всего собрано налогов: {cb_stats['total_taxes_collected']:.2f} см • Регулирование инфляции: {cb_stats['total_inflation_adjustments']:.2f} см • Эмиссия денег: {cb_stats['total_emissions']:.2f} см 🤖 **Автоматическое регулирование:** • ЦБ работает по алгоритмам без вмешательства • Налоги автоматически поступают в ЦБ • Инфляция регулируется автоматически • Эмиссия при дефляции, скупка при инфляции 💡 **Команды ЦБ:** /central_bank - детальная статистика /cb_stats - краткая статистика /cb_rules - правила работы ЦБ """ await message.answer(text, parse_mode=None) async def handle_cb_stats_cmd(message: Message): """Статистика ЦБ для всех пользователей""" if not message.from_user: await message.answer("Команда доступна только пользователям.") return cb_stats = await asyncio.to_thread(economy.get_central_bank_stats) text = f"""🏛️ **Центральный Банк ZOV OS** (децентрализованный) 💰 **Капитал ЦБ:** {cb_stats['capital']:.2f} см 📊 **Операции ЦБ:** • Собрано налогов: {cb_stats['total_taxes_collected']:.2f} см • Антиинфляция: {cb_stats['total_inflation_adjustments']:.2f} см • Эмиссия: {cb_stats['total_emissions']:.2f} см 🔹 ЦБ работает автоматически по алгоритмам 🔹 Учредительный капитал: 1000 см 🔹 Все налоги поступают в ЦБ 🔹 Никакого ручного управления """ await message.answer(text, parse_mode=None) async def handle_cb_rules_cmd(message: Message): """Правила работы Центрального Банка""" if not message.from_user: await message.answer("Команда доступна только пользователям.") return text = f"""📖 **Правила Центрального Банка ZOV OS** 🤖 **Децентрализованная система:** • ЦБ работает полностью автоматически • Никакого ручного вмешательства • Все решения принимаются по алгоритмам � **Сбор налогов:** • 13% налог автоматически поступает в ЦБ • Налоги собираются каждый час • Капитал ЦБ растет за счет налогов 📈 **Борьба с инфляцией:** • Если инфляция > 10%: ЦБ скупает деньги • Увеличивает налоги до 20% максимум • Скупает до 5% от массы денег � **Стимуляция экономики:** • Если инфляция < 2%: ЦБ проводит эмиссию • Уменьшает налоги до 10% минимум • Эмиссия до 5% от капитала ЦБ ⚖️ **Баланс системы:** • ЦБ не может обанкротиться (учредительный капитал) • Автоматическая стабилизация экономики • Прозрачная статистика для всех """ await message.answer(text, parse_mode=None) async def handle_gadanie_cmd(message: Message): parts = message.text.split(maxsplit=1) if len(parts) < 2 or not parts[1].strip(): await message.reply("Напиши на что гадать, кент. Пример: /gadanie любовь") return topic = parts[1].strip() await message.bot.send_chat_action(chat_id=message.chat.id, action="typing") try: fortune_text, up_text, down_text = await generate_fortune(topic) await message.reply(f"🔮 Гадание на «{topic}»:\n\n{fortune_text}", parse_mode=None) photo_path = config.BASE_DIR / "maket" / "vape.jpg" if photo_path.exists(): img = Image.open(photo_path).convert("RGB") meme_output = await asyncio.to_thread(generate_meme, img, up_text, down_text) await message.reply_photo( BufferedInputFile(meme_output.getvalue(), filename="gadanie.jpg") ) except Exception: logger.exception("Gadanie failed") await message.reply("Бля, карты рассыпались, попробуй позже.") async def handle_matches_cmd(message: Message): raw_text = message.text or "" parts = raw_text.split(maxsplit=1) sport_alias = parts[1].strip().lower() if len(parts) > 1 else None # Если лига не указана — показать список if not sport_alias: labels = { "epl": "⚽ EPL (Англия)", "ucl": "⚽ Champions League", "laliga": "⚽ La Liga (Испания)", "bundesliga": "⚽ Bundesliga (Германия)", "seriea": "⚽ Serie A (Италия)", "ligue1": "⚽ Ligue 1 (Франция)", "europa": "⚽ Europa League", } lines = ["🏆 Доступные лиги для ставок:\n"] for alias, name in labels.items(): lines.append(f" {name} → /matches {alias}") lines.append("\nПример: /matches epl") await message.reply("\n".join(lines), parse_mode=None) return if sport_alias not in config.ODDS_SPORTS: if message.from_user: clear_user_sport_context(message.from_user.id) available = " / ".join(config.ODDS_SPORTS.keys()) await message.reply(f"Неизвестная лига. Доступно: {available}", parse_mode=None) return if message.from_user: if sport_alias in config.ODDS_SPORTS: remember_user_sport_context(message.from_user.id, sport_alias) else: clear_user_sport_context(message.from_user.id) await message.bot.send_chat_action(chat_id=message.chat.id, action="typing") result = await get_formatted_matches(sport_alias) await message.reply(result, parse_mode=None) async def handle_bet_cmd(message: Message): if not message.from_user: await message.answer("Команда доступна только пользователям.") return raw_text = message.text or "" parts = raw_text.split() if len(parts) < 4: await message.reply( "Формат: /bet [спорт] [номер] [исход] [ставка]\n" "Коротко после /matches cs|dota|football: /bet [номер] [исход] [ставка]\n" "Исход: 1/2, для ничьи X, можно ещё название команды или коэффициент.\n" "Пример: /bet cs 1 1 0.5", parse_mode=None, ) return sport_alias: str | None if parts[1].lower() in config.ODDS_SPORTS: if len(parts) < 5: await message.reply( "Формат: /bet [спорт] [номер] [исход] [ставка]\nПример: /bet cs 1 1 0.5", parse_mode=None, ) return sport_alias = parts[1].lower() index_token = parts[2] selection = " ".join(parts[3:-1]).strip() amount_token = parts[-1] else: sport_alias = get_user_sport_context(message.from_user.id) if not sport_alias: await message.reply( "Сначала открой /matches с нужной лигой (epl, ucl, laliga...).\n" "Либо пиши полный формат: /bet [спорт] [номер] [исход] [ставка]", parse_mode=None, ) return index_token = parts[1] selection = " ".join(parts[2:-1]).strip() amount_token = parts[-1] if not selection: await message.reply("Нужен исход: 1, 2, X, название команды или коэффициент.", parse_mode=None) return try: index = int(index_token) except ValueError: await message.reply("Номер матча должен быть числом.", parse_mode=None) return try: amount = round(float(amount_token.replace(",", ".")), 1) except ValueError: await message.reply("Ставка должна быть числом.", parse_mode=None) return match = await get_match_by_index(sport_alias, index) if not match: await message.reply(f"Матч не найден. Посмотри /matches {sport_alias}", parse_mode=None) return team = resolve_match_outcome(match, selection) if not team: await message.reply( f"Исход не найден. Доступные: {describe_match_outcomes(match)}", parse_mode=None, ) return result = await asyncio.to_thread(place_bet, message.from_user.id, match, sport_alias, team, amount) await message.reply(result, parse_mode=None) async def handle_mybets_cmd(message: Message): if not message.from_user: return result = await asyncio.to_thread(get_user_bets, message.from_user.id) await message.reply(result, parse_mode=None) async def handle_bet_history_cmd(message: Message): if not message.from_user: return result = await asyncio.to_thread(get_user_bet_history, message.from_user.id) # Разделяем на части если слишком длинно if len(result) > 4000: parts = result.split("\n\n") chunk = "" for part in parts: if len(chunk) + len(part) + 2 > 4000: await message.reply(chunk, parse_mode=None) chunk = part else: chunk = chunk + "\n\n" + part if chunk else part if chunk: await message.reply(chunk, parse_mode=None) else: await message.reply(result, parse_mode=None) async def handle_sports_debug_cmd(message: Message): await message.bot.send_chat_action(chat_id=message.chat.id, action="typing") result = await debug_sports() await message.reply(result, parse_mode=None) async def _settle_loop(bot: Bot): while True: await asyncio.sleep(1800) try: notifications = await settle_bets() for note in notifications: logger.info(note) except Exception: logger.exception("settle_bets failed") # --- Запуск --- def main(): token = os.getenv("BOT_TOKEN") if not token: raise RuntimeError("BOT_TOKEN не задан!") init_penis_db() init_polychaetsi_storage() bot = Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) dp = Dispatcher() # Регистрация хендлеров dp.message.register(handle_start, CommandStart()) dp.message.register(handle_app_cmd, Command("app")) dp.message.register(handle_help, Command("help")) dp.message.register(handle_schedule_cmd, Command("schedule")) dp.message.register(handle_break_cmd, Command("break")) dp.message.register(handle_maket_cmd, Command("maket")) dp.message.register(handle_size_cmd, Command("size")) dp.message.register(handle_penis_cmd, Command("penis")) dp.message.register(handle_top_penis_cmd, Command("top_penis")) dp.message.register(handle_polychaetsi_cmd, Command("polychaetsi")) dp.message.register(handle_prices_cmd, Command("prices")) dp.message.register(handle_fetch_cmd, Command("fetch")) dp.message.register(handle_zvetok_cmd, Command("zvetok")) dp.message.register(handle_talk, Command("talk")) dp.message.register(handle_svodka_cmd, Command("svodka")) dp.message.register(handle_ebalnik_cmd, Command("ebalnik")) dp.message.register(handle_penis_casino_cmd, Command("penis_casino")) dp.message.register(handle_gadanie_cmd, Command("gadanie")) dp.message.register(handle_matches_cmd, Command("matches")) dp.message.register(handle_bet_cmd, Command("bet")) dp.message.register(handle_mybets_cmd, Command("mybets")) dp.message.register(handle_bet_history_cmd, Command("bet_history")) dp.message.register(handle_sports_debug_cmd, Command("sports_debug")) dp.message.register(handle_gen_mem, Command("gen_mem")) dp.message.register(handle_uwu_cmd, Command("uwu")) dp.message.register(handle_nude_cmd, Command("nude")) dp.message.register(handle_photo_message, _is_watch_image_message) # Экономические команды dp.message.register(handle_balance_cmd, Command("balance")) dp.message.register(handle_deposit_cmd, Command("deposit")) dp.message.register(handle_loans_cmd, Command("loans")) dp.message.register(handle_loan_cmd, Command("loan")) dp.message.register(handle_transfer_cmd, Command("transfer")) dp.message.register(handle_vanomasa_cmd, Command("vanomasa")) # Команды Центрального Банка (децентрализованные) dp.message.register(handle_central_bank_cmd, Command("central_bank")) dp.message.register(handle_cb_stats_cmd, Command("cb_stats")) dp.message.register(handle_cb_rules_cmd, Command("cb_rules")) dp.message.register(handle_keywords, F.text) dp.message.register(handle_unmatched_message_debug) async def on_startup(bot: Bot): try: commands = [ BotCommand(command="start", description="Приветствие"), BotCommand(command="help", description="Подсказки по командам"), BotCommand(command="schedule", description="расписание пар"), BotCommand(command="maket", description="Вписать текст в макет 1"), BotCommand(command="gen_mem", description="Мем из фото (ответом)"), BotCommand(command="break", description="Сколько минут до перекура"), BotCommand(command="size", description="хуй"), BotCommand(command="penis", description="миниигра 1 раз в 24ч"), BotCommand(command="top_penis", description="топ по длине"), BotCommand(command="polychaetsi", description="топ по получается"), BotCommand(command="prices", description="цены и остатки Магнита"), BotCommand(command="fetch", description="alias для /prices"), BotCommand(command="zvetok", description="Цветянский бля"), BotCommand(command="talk", description="Побазарить"), BotCommand(command="watch", description="Коммент фото по подписи /watch"), BotCommand(command="ebalnik", description="вкл/выкл автответов"), BotCommand(command="penis_casino", description="казино на размер"), BotCommand(command="gadanie", description="гадание на фене"), BotCommand(command="matches", description="матчи для ставок"), BotCommand(command="bet", description="поставить см на матч"), BotCommand(command="mybets", description="мои активные ставки"), BotCommand(command="bet_history", description="история ставок за месяц"), BotCommand(command="sports_debug", description="диагностика API матчей"), BotCommand(command="svodka", description="СВО: итоги"), BotCommand(command="uwu", description="Случайная картинка с e621"), BotCommand(command="nude", description="Голые женщины из открытых источников"), BotCommand(command="balance", description="💰 баланс и статистика"), BotCommand(command="deposit", description="💎 открыть вклад"), BotCommand(command="loan", description="💵 взять кредит"), BotCommand(command="transfer", description="💸 перевести деньги"), BotCommand(command="cb_stats", description="🏛️ статистика ЦБ"), BotCommand(command="central_bank", description="🏛️ детальная статистика ЦБ"), BotCommand(command="cb_rules", description="📖 правила ЦБ"), ] scopes = ( BotCommandScopeDefault(), BotCommandScopeAllPrivateChats(), BotCommandScopeAllGroupChats(), ) for scope in scopes: await bot.set_my_commands(commands, scope=scope) except Exception: logger.warning("Не удалось выставить команды, но бот продолжит работу.") # Запуск фоновых задач экономики asyncio.create_task(economy_background_tasks()) dp.startup.register(on_startup) async def start_settle(bot: Bot): asyncio.create_task(_settle_loop(bot)) dp.startup.register(start_settle) logger.info("Бот запущен...") asyncio.run(dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())) async def economy_background_tasks(): """Фоновые задачи для экономики""" while True: try: moscow_time = datetime.now(ZoneInfo("Europe/Moscow")) current_hour = moscow_time.hour current_minute = moscow_time.minute await asyncio.to_thread(economy.collect_daily_taxes) await asyncio.to_thread(economy.regulate_inflation) if current_hour == 0 and current_minute == 0: users_paid = await asyncio.to_thread(economy.pay_daily_activity_rewards) if users_paid > 0: logger.info(f"Activity rewards paid to {users_paid} users at 00:00 MSK") logger.info(f"Economy background tasks completed at {moscow_time.strftime('%H:%M:%S MSK')}") except Exception as e: logger.exception(f"Economy background task failed: {e}") await asyncio.sleep(60) if __name__ == "__main__": main()