bot_tg/main.py

1835 lines
74 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Системные импорты
import asyncio, json, logging, os, re, random, shutil, 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
from downloader import download_video, COOKIES_PATH
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_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:
result = economy.play_penis(
user_id, display_name, now_ts,
config.PENIS_START_LENGTH, config.PENIS_COOLDOWN_SECONDS,
config.PENIS_MIN_DELTA, config.PENIS_MAX_DELTA,
)
except Exception:
logger.exception("Failed to process /penis")
return False, "Ошибка БД. Попробуй позже."
if not result['allowed']:
left = result['left_seconds']
hours = left // 3600
minutes = (left % 3600) // 60
return False, f"Сегодня уже кидал. Попробуй через {hours} ч {minutes} мин."
sign = result['sign']
delta = result['delta']
new_balance = result['new_balance']
sign_text = "+" if sign > 0 else "-"
return (
True,
f"📏 Изменение: {sign_text}{delta:.1f} см\n"
f"Текущая длина: {new_balance:.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:
rows = economy.get_penis_top(limit)
except Exception:
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"])
balance = float(row["balance"])
name = str(row["display_name"]).strip() or f"user_{user_id}"
prefix = medals.get(index, f"{index}.")
lines.append(f"{prefix} {name}{balance:.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)
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, error_msg = 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(f"Не удалось открыть вклад. {error_msg}", 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 = round(float(parts[2].replace(",", ".")), 1)
except ValueError:
await message.reply("Сумма должна быть числом.", parse_mode=None)
return
if amount <= 0:
await message.reply("Сумма должна быть положительной.", parse_mode=None)
return
to_user_id = await asyncio.to_thread(economy.find_user_id_by_username, username)
if not to_user_id:
await message.reply(f"Пользователь @{username} не найден. Он должен хотя бы раз сыграть в /penis.", parse_mode=None)
return
if to_user_id == message.from_user.id:
await message.reply("Себе переводить нельзя.", parse_mode=None)
return
success = await asyncio.to_thread(
economy.transfer_money, message.from_user.id, to_user_id, amount, f"transfer to @{username}"
)
if success:
my_balance = economy.get_user_balance(message.from_user.id)
await message.reply(
f"✅ Переведено {amount:.1f} см → @{username}\nТвой баланс: {my_balance:.1f} см",
parse_mode=None,
)
else:
my_balance = economy.get_user_balance(message.from_user.id)
limit = round(my_balance * 0.2, 1)
await message.reply(
f"Не удалось перевести. Баланс: {my_balance:.1f} см, лимит одного перевода: {limit:.1f} см (20%).",
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**
🤖 **Децентрализованная система:**
• ЦБ работает полностью автоматически
• Никакого ручного вмешательства
Все решения принимаются по алгоритмам
<EFBFBD> **Сбор налогов:**
• 13% налог автоматически поступает в ЦБ
• Налоги собираются каждый час
• Капитал ЦБ растет за счет налогов
📈 **Борьба с инфляцией:**
• Если инфляция > 10%: ЦБ скупает деньги
• Увеличивает налоги до 20% максимум
• Скупает до 5% от массы денег
<EFBFBD> **Стимуляция экономики:**
• Если инфляция < 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 "dota2"
if sport_alias not in config.ODDS_SPORTS:
if message.from_user:
clear_user_sport_context(message.from_user.id)
await message.reply("Неизвестная лига. Доступно: dota2", parse_mode=None)
return
if message.from_user:
remember_user_sport_context(message.from_user.id, sport_alias)
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"
"Или: /bet dota2 [номер] [исход] [ставка]\n"
"Исход: 1/2. Можно писать ещё название команды или коэффициент.\n"
"Пример: /bet 1 1 0.5",
parse_mode=None,
)
return
sport_alias: str
if parts[1].lower() == "dota2":
if len(parts) < 5:
await message.reply(
"Формат: /bet dota2 [номер] [исход] [ставка]\nПример: /bet dota2 1 1 0.5",
parse_mode=None,
)
return
sport_alias = "dota2"
index_token = parts[2]
selection = " ".join(parts[3:-1]).strip()
amount_token = parts[-1]
else:
sport_alias = "dota2"
index_token = parts[1]
selection = " ".join(parts[2:-1]).strip()
amount_token = parts[-1]
if not selection:
await message.reply("Нужен исход: 1, 2, название команды или коэффициент.", 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
success, 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)
_YT_URL_RE = re.compile(
r"https?://(?:www\.)?(?:youtube\.com/(?:watch|shorts|live)|youtu\.be)/\S+",
re.IGNORECASE,
)
async def handle_dow_cmd(message: Message, bot: Bot):
parts = (message.text or "").split(maxsplit=1)
# Если /dow без URL — пробуем взять ссылку из сообщения-источника (reply)
url = ""
if len(parts) >= 2:
url = parts[1].strip()
elif message.reply_to_message:
src_text = message.reply_to_message.text or message.reply_to_message.caption or ""
m = _YT_URL_RE.search(src_text)
if m:
url = m.group(0)
if not url:
await message.reply("Формат: /dow <ссылка>\nИли ответь на сообщение с YouTube-ссылкой командой /dow")
return
status = await message.reply("⏳ Скачиваю...")
file_path, title, error = await download_video(url)
if error:
await status.edit_text(f"❌ Ошибка скачивания:\n{error[:500]}")
return
try:
size_mb = file_path.stat().st_size / (1024 * 1024)
if size_mb > 50:
await status.edit_text(f"❌ Видео весит {size_mb:.1f} МБ — Telegram не позволяет отправить >50 МБ")
return
await status.edit_text("📤 Отправляю...")
target = message.reply_to_message or message
await target.reply_video(
FSInputFile(str(file_path)),
caption=title[:1024],
supports_streaming=True,
)
await status.delete()
except Exception as exc:
logger.exception("Failed to send downloaded video")
await status.edit_text(f"Не смог отправить: {exc}")
finally:
shutil.rmtree(str(file_path.parent), ignore_errors=True)
async def handle_setcookies_cmd(message: Message, bot: Bot):
"""Загрузить файл кук для YouTube (только в личке, ответом на документ)."""
if message.chat.type != "private":
await message.reply("Куки можно загрузить только в личных сообщениях с ботом.")
return
doc = None
if message.document:
doc = message.document
elif message.reply_to_message and message.reply_to_message.document:
doc = message.reply_to_message.document
if not doc:
await message.reply(
"Пришли файл cookies (Netscape/txt) в этот чат с подписью /setcookies "
"или ответь на сообщение с файлом командой /setcookies"
)
return
try:
COOKIES_PATH.parent.mkdir(parents=True, exist_ok=True)
file_info = await bot.get_file(doc.file_id)
buf = BytesIO()
await bot.download_file(file_info.file_path, destination=buf)
buf.seek(0)
COOKIES_PATH.write_bytes(buf.read())
await message.reply(f"✅ Куки сохранены ({doc.file_size or '?'} байт). yt-dlp будет их использовать.")
except Exception as exc:
logger.exception("Failed to save cookies")
await message.reply(f"Не удалось сохранить: {exc}")
async def _settle_loop(bot: Bot):
while True:
await asyncio.sleep(1800)
try:
notifications = await settle_bets()
for user_id, text in notifications:
try:
await bot.send_message(user_id, text)
except Exception:
logger.warning("Could not notify user %s about bet result", user_id)
except Exception:
logger.exception("settle_bets failed")
# --- Запуск ---
def main():
token = os.getenv("BOT_TOKEN")
if not token: raise RuntimeError("BOT_TOKEN не задан!")
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_dow_cmd, Command("dow"))
dp.message.register(handle_setcookies_cmd, Command("setcookies"))
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="dow", description="Скачать видео по ссылке"),
BotCommand(command="setcookies", description="Загрузить куки YouTube (личка)"),
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)
await asyncio.to_thread(economy.process_matured_deposits)
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()