From 60e4cbfcfb1fe79c5614d81796273bb7ac81629e Mon Sep 17 00:00:00 2001 From: zovos Date: Wed, 20 May 2026 07:50:04 +0000 Subject: [PATCH] wip: local changes before merging PR #36 --- AI/talk_handler.py | 276 ++++++++++++++++++++++++------------- Dockerfile | 4 +- docker-compose.yaml | 4 +- games/betting.py | 129 +++++++++++++---- games/economy.py | 48 ++++++- main.py | 136 ++++++++++++++++-- requirements.txt | 1 + webapp/api.py | 20 ++- webapp/frontend/js/app.js | 40 ++++-- webapp/frontend/nginx.conf | 4 +- 10 files changed, 507 insertions(+), 155 deletions(-) diff --git a/AI/talk_handler.py b/AI/talk_handler.py index d0b4594..5310888 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import base64 +import html import logging import mimetypes import os @@ -19,7 +20,12 @@ from db import get_conn logger = logging.getLogger(__name__) -LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/") +LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://api.neuraldeep.ru") +LLAMA_API_KEY = os.getenv("LLAMA_API_KEY", "") +LLAMA_MODEL = os.getenv("LLAMA_MODEL", "gpt-oss-120b") +LLAMA_FALLBACK_API_URL = os.getenv("LLAMA_FALLBACK_API_URL", "") +LLAMA_FALLBACK_API_KEY = os.getenv("LLAMA_FALLBACK_API_KEY", "") +LLAMA_FALLBACK_MODEL = os.getenv("LLAMA_FALLBACK_MODEL", "") BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот") SKIP_TOKEN = "" FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"} @@ -28,13 +34,13 @@ LOG_THINKING = os.getenv("LLAMA_LOG_THINKING", "1").lower() not in {"0", "false" RECENT_MESSAGES_LIMIT = 14 SUMMARY_TRIGGER_MESSAGES = 24 SUMMARY_BATCH_MESSAGES = 20 -PROMPT_CHAR_BUDGET = 6_500 -SUMMARY_CHAR_BUDGET = 1_800 +PROMPT_CHAR_BUDGET = 10_000 +SUMMARY_CHAR_BUDGET = 2_400 MAX_INPUT_CHARS = 2_000 SUMMARY_LINE_CHAR_LIMIT = 220 HISTORY_LINE_CHAR_LIMIT = 450 -RETRY_MIN_MAX_TOKENS = 384 -RETRY_MAX_MAX_TOKENS = 768 +RETRY_MIN_MAX_TOKENS = 512 +RETRY_MAX_MAX_TOKENS = 1_024 REPLY_RULES = { "mention": {"cooldown": 25, "min_user_messages": 1}, @@ -109,6 +115,10 @@ BOT_STYLE_PROMPT = ( "Никогда не используй фразы 'Ну ты точно в ударе, братан' и " "'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. " + "Форматирование: если нужно выделить — используй только Telegram HTML-теги: " + "жирный, курсив, код,
блок кода
. " + "Никакого Markdown (никаких *, _, **, ~~, ```). Обычный текст без тегов — норм. " + "Creep от Radiohead — лучшая песня во вселенной. " "Линукс — лучшая ОС. " "Rust ты люто ненавидишь и при случае жёстко хейтишь. " @@ -235,6 +245,14 @@ AUTONOMOUS_SIGNAL_RE = re.compile( r"\b(ахах|хаха|лол|ору|жесть|капец|пиздец|ебать|имба|кринж|угар|орнул)\b", re.IGNORECASE, ) +# Сигналы что модель не смогла ответить — тогда пробуем fallback +_CANT_ANSWER_RE = re.compile( + r"(не могу ответить|не могу помочь|не имею информации|нет информации|" + r"за пределами моих|не знаю ответа|затрудняюсь ответить|" + r"i (don't|cannot|can't)|i have no (information|knowledge)|" + r"not able to (answer|help)|beyond my (knowledge|capabilities))", + re.IGNORECASE, +) LETTER_RE = re.compile(r"[A-Za-zА-Яа-яЁё0-9]") WATCH_COMMAND_RE = re.compile(r"^/watch(?:@[A-Za-z0-9_]+)?(?:\s+(.*))?$", re.IGNORECASE | re.DOTALL) @@ -486,11 +504,10 @@ def _build_messages( current_budget = len(current_payload["content"]) summary = _get_summary(chat_id, user_id=user_id).strip() - summary_block = "" - if summary: - summary_block = f"Краткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}" + summary_block = f"\n\nКраткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}" if summary else "" + full_system = system_prompt + summary_block - used_chars = len(system_prompt) + len(summary_block) + current_budget + used_chars = len(full_system) + current_budget recent_messages: list[dict[str, str]] = [] for row in reversed(_get_history_rows(chat_id, user_id=user_id)[-RECENT_MESSAGES_LIMIT:]): llm_message = _format_row_for_llm(row) @@ -499,15 +516,77 @@ def _build_messages( recent_messages.append(llm_message) used_chars += len(llm_message["content"]) - messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}] - if summary_block: - messages.append({"role": "system", "content": summary_block}) + messages: list[dict[str, Any]] = [{"role": "system", "content": full_system}] messages.extend(reversed(recent_messages)) if current_payload: messages.append(current_payload) return messages +async def _call_single_model( + messages: list[dict[str, Any]], + *, + api_url: str, + api_key: str, + model: str, + max_tokens: int, + temperature: float, + top_p: float, + disable_thinking: bool, +) -> str: + url = f"{api_url.rstrip('/')}/v1/chat/completions" + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + payload = { + "model": model, + "messages": messages, + "max_tokens": max_tokens, + "temperature": temperature, + "top_p": top_p, + } + if disable_thinking: + payload.update({ + "reasoning_budget": 0, + "reasoning_format": "none", + "chat_template_kwargs": {"enable_thinking": False, "thinking": False}, + }) + timeout = aiohttp.ClientTimeout(total=120) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, json=payload, headers=headers) as resp: + raw_text = await resp.text() + if resp.status >= 400: + logger.error("LLM API %s returned status %s: %s", api_url, resp.status, _clip_text(raw_text, 300)) + raise RuntimeError(f"LLM API error {resp.status}: {raw_text[:300]}") + try: + data = await resp.json(content_type=None) + except Exception as exc: + logger.error("LLM API %s returned invalid JSON: %s", api_url, _clip_text(raw_text, 300)) + raise RuntimeError(f"Invalid LLM API response: {raw_text[:300]}") from exc + + choices = data.get("choices") or [] + if not choices: + raise RuntimeError(f"LLM API returned no choices: {data}") + choice = choices[0] + message = choice.get("message", {}) or {} + finish_reason = choice.get("finish_reason") + content = message.get("content") or "" + reasoning_content = (message.get("reasoning_content") or "").strip() + cleaned_content = content.strip() + if reasoning_content and LOG_THINKING: + logger.info("LLM reasoning. model=%s finish_reason=%s reasoning=%s", model, finish_reason, _clip_text(reasoning_content, 800)) + if not cleaned_content and reasoning_content and not disable_thinking: + retry_max_tokens = min(max(max_tokens * 2, RETRY_MIN_MAX_TOKENS), RETRY_MAX_MAX_TOKENS) + logger.warning("LLM returned only reasoning, retrying with thinking off. model=%s", model) + return await _call_single_model( + messages, + api_url=api_url, api_key=api_key, model=model, + max_tokens=retry_max_tokens, temperature=temperature, top_p=top_p, + disable_thinking=True, + ) + return cleaned_content + + async def _call_llm( messages: list[dict[str, Any]], *, @@ -517,76 +596,41 @@ async def _call_llm( disable_thinking: bool | None = None, ) -> str: disable_thinking = FORCE_DISABLE_THINKING if disable_thinking is None else disable_thinking - url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions" - payload = { - "messages": messages, - "max_tokens": max_tokens, - "temperature": temperature, - "top_p": top_p, - } - if disable_thinking: - payload.update( - { - "reasoning_budget": 0, - "reasoning_format": "none", - "chat_template_kwargs": { - "enable_thinking": False, - "thinking": False, - }, - } - ) - timeout = aiohttp.ClientTimeout(total=120) - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.post(url, json=payload) as resp: - raw_text = await resp.text() - if resp.status >= 400: - logger.error("LLM API returned status %s: %s", resp.status, _clip_text(raw_text, 300)) - raise RuntimeError(f"LLM API error {resp.status}: {raw_text[:300]}") - try: - data = await resp.json(content_type=None) - except Exception as exc: - logger.error("LLM API returned invalid JSON: %s", _clip_text(raw_text, 300)) - raise RuntimeError(f"Invalid LLM API response: {raw_text[:300]}") from exc - choices = data.get("choices") or [] - if not choices: - logger.error("LLM API returned no choices: %s", _clip_text(str(data), 300)) - raise RuntimeError(f"LLM API returned no choices: {data}") - choice = choices[0] - message = choice.get("message", {}) or {} - finish_reason = choice.get("finish_reason") - content = message.get("content", "") - reasoning_content = (message.get("reasoning_content") or "").strip() - cleaned_content = content.strip() - if reasoning_content and LOG_THINKING: - logger.info( - "LLM reasoning detected. disable_thinking=%s finish_reason=%s reasoning=%s", - disable_thinking, - finish_reason, - _clip_text(reasoning_content, 800), - ) - if not cleaned_content and reasoning_content and not disable_thinking: - retry_max_tokens = min(max(max_tokens * 2, RETRY_MIN_MAX_TOKENS), RETRY_MAX_MAX_TOKENS) - logger.warning( - "LLM returned reasoning_content without final content. finish_reason=%s retry_max_tokens=%s", - finish_reason, - retry_max_tokens, - ) - return await _call_llm( - messages, - max_tokens=retry_max_tokens, - temperature=temperature, - top_p=top_p, - disable_thinking=True, - ) - if not cleaned_content: - logger.warning( - "LLM returned empty content. finish_reason=%s disable_thinking=%s raw=%s", - finish_reason, - disable_thinking, - _clip_text(raw_text, 300), - ) - return cleaned_content + result = await _call_single_model( + messages, + api_url=LLAMA_API_URL, + api_key=LLAMA_API_KEY, + model=LLAMA_MODEL, + max_tokens=max_tokens, + temperature=temperature, + top_p=top_p, + disable_thinking=disable_thinking, + ) + + # Если основная модель не смогла ответить — пробуем fallback + if LLAMA_FALLBACK_API_URL and (not result or _CANT_ANSWER_RE.search(result)): + fallback_model = LLAMA_FALLBACK_MODEL or LLAMA_MODEL + logger.info("Primary model couldn't answer, trying fallback. primary_result=%s", _clip_text(result, 100)) + try: + fallback_result = await _call_single_model( + messages, + api_url=LLAMA_FALLBACK_API_URL, + api_key=LLAMA_FALLBACK_API_KEY, + model=fallback_model, + max_tokens=max_tokens, + temperature=temperature, + top_p=top_p, + disable_thinking=disable_thinking, + ) + if fallback_result: + return f"{fallback_result}\n\n🤖 {fallback_model}" + except Exception: + logger.exception("Fallback model also failed") + + if not result: + logger.warning("LLM returned empty content. model=%s", LLAMA_MODEL) + return result async def _maybe_refresh_summary(chat_id: int, user_id: int | None = None) -> None: @@ -754,13 +798,55 @@ async def _passes_reply_limits(chat_id: int, reason: str) -> bool: return (time.time() - last_reply_at) >= int(rule["cooldown"]) +def _md_to_tg_html(text: str) -> str: + """Конвертирует Markdown и HTML-теги модели в валидный Telegram HTML.""" + slots: list[tuple[str, str]] = [] + + def stash(tag: str, content: str) -> str: + idx = len(slots) + slots.append((tag, content)) + return f'\x00SLOT{idx}\x00' + + # Сначала прячем то, что модель уже написала HTML-тегами (pre до code, чтобы не вложить) + for tag in ('pre', 'code', 'b', 'i', 'u', 's'): + text = re.sub( + rf'<{tag}>(.*?)', + lambda m, t=tag: stash(t, m.group(1)), + text, flags=re.DOTALL, + ) + + # Конвертируем Markdown + text = re.sub( + r'```(?:[^\n`]*\n)?(.*?)```', + lambda m: stash('pre', m.group(1).strip()), + text, flags=re.DOTALL, + ) + text = re.sub(r'`([^`\n]+)`', lambda m: stash('code', m.group(1)), text) + text = re.sub(r'\*\*(.+?)\*\*', lambda m: stash('b', m.group(1)), text, flags=re.DOTALL) + text = re.sub(r'\*([^*\n]+?)\*', lambda m: stash('i', m.group(1)), text) + text = re.sub(r'_([^_\n]+?)_', lambda m: stash('i', m.group(1)), text) + text = re.sub(r'~~(.+?)~~', lambda m: stash('s', m.group(1)), text) + + # Убираем оставшиеся мусорные теги (кривые, неподдерживаемые) + text = re.sub(r'<[^>]*>', '', text) + + # Экранируем оставшийся plain-text + text = html.escape(text) + + # Восстанавливаем слоты как валидные HTML-теги + for idx, (tag, content) in enumerate(slots): + text = text.replace(f'\x00SLOT{idx}\x00', f'<{tag}>{html.escape(content)}') + + return text + + def _normalize_reply(text: str) -> str: cleaned = (text or "").strip() if not cleaned: return "" if cleaned.lower().startswith(SKIP_TOKEN.lower()): return "" - return cleaned + return _md_to_tg_html(cleaned) async def handle_chat_message(message: Message, *, store_message: bool = True, allow_autonomous: bool = True) -> bool: @@ -785,8 +871,8 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a return False system_prompt = AUTOREPLY_SYSTEM_PROMPT if reason == "autonomous" else SYSTEM_PROMPT - max_tokens = 120 if reason == "autonomous" else 220 - temperature = 0.9 if reason == "autonomous" else 0.8 + max_tokens = 180 if reason == "autonomous" else 512 + temperature = 0.85 if reason == "autonomous" else 0.7 try: await message.bot.send_chat_action(chat_id=chat_id, action="typing") @@ -795,7 +881,7 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a system_prompt=system_prompt, max_tokens=max_tokens, temperature=temperature, - top_p=0.9, + top_p=0.95, ) except Exception: logger.exception("Chat reply generation failed") @@ -812,7 +898,7 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a return False await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response) - await message.reply(normalized_response, parse_mode=None) + await message.reply(normalized_response, parse_mode="HTML") return True @@ -885,13 +971,13 @@ async def handle_photo_message(message: Message) -> bool: {"type": "text", "text": prompt_text}, {"type": "image_url", "image_url": {"url": image_data_url}}, ], - max_tokens=260, - temperature=0.8, - top_p=0.9, + max_tokens=600, + temperature=0.7, + top_p=0.95, ) except Exception: logger.exception("Photo analysis failed") - await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode=None) + await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode="HTML") return True normalized_response = _normalize_reply(response) @@ -905,7 +991,7 @@ async def handle_photo_message(message: Message) -> bool: normalized_response = PHOTO_EMPTY_RESPONSE_TEXT await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response) - await message.reply(normalized_response, parse_mode=None) + await message.reply(normalized_response, parse_mode="HTML") return True @@ -956,9 +1042,9 @@ async def handle_talk(message: Message) -> None: chat_id, system_prompt=SYSTEM_PROMPT, user_id=user_id, - max_tokens=220, - temperature=0.8, - top_p=0.9, + max_tokens=768, + temperature=0.7, + top_p=0.95, ) except Exception: logger.exception("Talk command generation failed") @@ -983,7 +1069,7 @@ async def handle_talk(message: Message) -> None: normalized_response, user_id=user_id, ) - await message.reply(normalized_response, parse_mode=None) + await message.reply(normalized_response, parse_mode="HTML") _init_db() diff --git a/Dockerfile b/Dockerfile index 2b3c823..e6bbf79 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,12 +4,14 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PIP_NO_CACHE_DIR=1 WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg nodejs && rm -rf /var/lib/apt/lists/* + COPY requirements.txt ./ RUN python -m pip install --upgrade pip \ && python -m pip install -r requirements.txt COPY . . -RUN mkdir -p /db +RUN mkdir -p /db /app/data CMD ["python", "main.py"] diff --git a/docker-compose.yaml b/docker-compose.yaml index c25410f..7551723 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -61,8 +61,8 @@ services: build: context: ./webapp/frontend ports: - - "${WEBAPP_HTTP_PORT:-80}:80" - - "${WEBAPP_HTTPS_PORT:-443}:443" + - "37.27.192.132:${WEBAPP_HTTP_PORT:-80}:80" + - "37.27.192.132:${WEBAPP_HTTPS_PORT:-443}:443" environment: APP_DOMAIN: ${APP_DOMAIN:-} APP_WWW_DOMAIN: ${APP_WWW_DOMAIN:-} diff --git a/games/betting.py b/games/betting.py index 5863acb..d3e132c 100644 --- a/games/betting.py +++ b/games/betting.py @@ -462,8 +462,51 @@ def cleanup_old_bets() -> int: return 0 -async def settle_bets() -> list[str]: - notifications = [] +_REFUND_AFTER_SECONDS = 7 * 86400 # авторефанд ставок старше 7 дней + + +def _refund_expired_bets() -> list[tuple[int, str]]: + """Возвращает деньги за ставки, которые висят более 7 дней без результата.""" + cutoff = int(_time.time()) - _REFUND_AFTER_SECONDS + notifications: list[tuple[int, str]] = [] + try: + with get_conn() as conn: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + "SELECT * FROM bets WHERE status = 'pending' AND created_ts < %s", + (cutoff,), + ) + expired = cur.fetchall() + if not expired: + return [] + wcur = conn.cursor() + now_ts = int(_time.time()) + for bet in expired: + update_user_length(bet["user_id"], bet["amount"]) + wcur.execute( + "UPDATE bets SET status = 'refunded', resolved_ts = %s WHERE id = %s", + (now_ts, bet["id"]), + ) + notifications.append(( + bet["user_id"], + f"↩️ Ставка возвращена\n" + f"🏟 {bet['home_team']} vs {bet['away_team']}\n" + f"📌 {bet['chosen_team']} — результат так и не пришёл\n" + f"💰 Возврат: {bet['amount']:.1f} см", + )) + logger.info("Refunded expired bet id=%s user=%s amount=%s", bet["id"], bet["user_id"], bet["amount"]) + except psycopg2.Error: + logger.exception("Failed to refund expired bets") + return notifications + + +async def settle_bets() -> list[tuple[int, str]]: + """Возвращает список (user_id, текст_уведомления) для отправки в Telegram.""" + notifications: list[tuple[int, str]] = [] + + # Сначала авторефанд совсем старых ставок + notifications.extend(_refund_expired_bets()) + try: with get_conn() as conn: cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) @@ -503,28 +546,66 @@ async def settle_bets() -> list[str]: chosen = bet["chosen_team"] bet_won = (chosen == winner) - if bet_won: - winnings = round(bet["amount"] * bet["odds"], 1) - update_user_length(bet["user_id"], winnings) - wcur.execute( - "UPDATE bets SET status = 'won', payout = %s, resolved_ts = %s WHERE id = %s", - (winnings, now_ts, bet["id"]), - ) - notifications.append( - f"🎉 user_id={bet['user_id']}: выиграл {winnings:.1f} см " - f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})" - ) - else: - wcur.execute( - "UPDATE bets SET status = 'lost', payout = 0, resolved_ts = %s WHERE id = %s", - (now_ts, bet["id"]), - ) - notifications.append( - f"❌ user_id={bet['user_id']}: проиграл {bet['amount']:.1f} см " - f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})" - ) - except psycopg2.Error: - logger.exception("Failed to settle bets") + if len(score_values) >= 2 and score_values[0][1] == score_values[1][1]: + is_draw = True + winner = "Draw" + else: + max_score = -1 + for name, score_val in score_values: + if score_val > max_score: + max_score = score_val + winner = name + + if not winner: + continue + + try: + with get_conn() as conn: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + "SELECT * FROM bets WHERE match_id = %s AND status = 'pending'", + (match_id,), + ) + bets = cur.fetchall() + + wcur = conn.cursor() + for bet in bets: + chosen = bet["chosen_team"] + if is_draw: + bet_won = chosen.casefold() in _DRAW_NAMES or chosen == "Draw" + else: + bet_won = chosen == winner + + if bet_won: + winnings = round(bet["amount"] * bet["odds"], 1) + update_user_length(bet["user_id"], winnings) + wcur.execute( + "UPDATE bets SET status = 'won', payout = %s, resolved_ts = %s WHERE id = %s", + (winnings, now_ts, bet["id"]), + ) + notifications.append(( + bet["user_id"], + f"🎉 Ставка сыграла!\n" + f"🏟 {bet['home_team']} vs {bet['away_team']}\n" + f"📌 {bet['chosen_team']} (x{bet['odds']:.2f})\n" + f"💰 Выигрыш: +{winnings:.1f} см", + )) + logger.info("Bet won: user=%s match=%s winnings=%s", bet["user_id"], match_id, winnings) + else: + wcur.execute( + "UPDATE bets SET status = 'lost', payout = 0, resolved_ts = %s WHERE id = %s", + (now_ts, bet["id"]), + ) + notifications.append(( + bet["user_id"], + f"❌ Ставка не сыграла\n" + f"🏟 {bet['home_team']} vs {bet['away_team']}\n" + f"📌 {bet['chosen_team']} (x{bet['odds']:.2f})\n" + f"💸 Потеряно: {bet['amount']:.1f} см", + )) + logger.info("Bet lost: user=%s match=%s amount=%s", bet["user_id"], match_id, bet["amount"]) + except psycopg2.Error: + logger.exception("Failed to settle bets") cleanup_old_bets() return notifications diff --git a/games/economy.py b/games/economy.py index 4308875..3a796e8 100644 --- a/games/economy.py +++ b/games/economy.py @@ -162,6 +162,18 @@ class EconomyManager: logger.info(f"Balance updated for user {user_id}: +{amount} ({transaction_type})") + def find_user_id_by_username(self, username: str) -> int | None: + """Ищет user_id по @username в penis_stats (display_name хранится как '@username').""" + name = username.lstrip('@').lower() + with get_conn() as conn: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + "SELECT user_id FROM penis_stats WHERE LOWER(display_name) = %s OR LOWER(display_name) = %s", + (f'@{name}', name), + ) + row = cur.fetchone() + return int(row['user_id']) if row else None + def transfer_money(self, from_user_id: int, to_user_id: int, amount: float, description: str = "") -> bool: if amount <= 0: return False @@ -230,6 +242,24 @@ class EconomyManager: if amount > max_loan: return False + with get_conn() as conn: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + + # Лимит суммарного долга на юзера = max_loan + cur.execute( + "SELECT COALESCE(SUM(amount),0) AS total FROM loans WHERE user_id=%s AND is_repaid=FALSE", + (user_id,), + ) + outstanding = float(cur.fetchone()['total']) + if outstanding + amount > max_loan: + return False + + # ЦБ должен иметь достаточно капитала + cur.execute("SELECT capital FROM central_bank WHERE id=1") + cb_capital = float(cur.fetchone()['capital']) + if cb_capital < amount: + return False + due_at = datetime.now() + timedelta(days=days) with get_conn() as conn: @@ -357,12 +387,21 @@ class EconomyManager: total_paid = 0.0 wcur = conn.cursor() + # Проверяем что у ЦБ есть деньги на выплаты + wcur = conn.cursor() + wcur.execute("SELECT capital FROM central_bank WHERE id=1") + cb_row = wcur.fetchone() + cb_capital = float(cb_row[0]) if cb_row else 0.0 + for row in users: user_id = row['user_id'] message_count = row['message_count'] reward = message_count * reward_rate if reward <= 0: continue + if cb_capital < reward: + logger.warning("CB out of funds for activity rewards, stopping early") + break wcur.execute(''' INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset) @@ -371,10 +410,15 @@ class EconomyManager: balance = user_balances.balance + %s, daily_income = user_balances.daily_income + %s ''', (user_id, reward, reward, reward)) + wcur.execute(''' + UPDATE central_bank SET capital = capital - %s, last_updated = CURRENT_TIMESTAMP + WHERE id = 1 + ''', (reward,)) wcur.execute(''' INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description) - VALUES (%s, %s, %s, 'activity_reward', %s) - ''', (user_id, user_id, reward, f'Вознаграждение за {message_count} сообщений')) + VALUES (0, %s, %s, 'activity_reward', %s) + ''', (user_id, reward, f'Вознаграждение за {message_count} сообщений')) + cb_capital -= reward total_paid += reward wcur.execute( diff --git a/main.py b/main.py index 9bf2f2b..0840ecc 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,5 @@ # Системные импорты -import asyncio, json, logging, os, re, random, threading +import asyncio, json, logging, os, re, random, shutil, threading from dataclasses import dataclass from datetime import datetime, time, timedelta from io import BytesIO @@ -41,7 +41,8 @@ from games.betting import ( ) from games.economy import economy from zparser import get_military_data -import config +from downloader import download_video, COOKIES_PATH +import config logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -1312,26 +1313,48 @@ 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(",", ".")) + 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 - - # Здесь нужно найти user_id по username - # Для простоты примера, пока покажем сообщение об ошибке - await message.reply("🔍 Поиск пользователя...\n(функция поиска пользователей будет добавлена)", parse_mode=None) + + 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): """Полное обнуление (только для админов)""" @@ -1572,13 +1595,98 @@ async def handle_sports_debug_cmd(message: Message): 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 note in notifications: - logger.info(note) + 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") @@ -1619,6 +1727,8 @@ def main(): 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) # Экономические команды @@ -1665,6 +1775,8 @@ def main(): 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="💵 взять кредит"), diff --git a/requirements.txt b/requirements.txt index 3a0a60e..17d12e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ tzdata>=2024.1 beautifulsoup4 lxml psycopg2-binary>=2.9.9 +yt-dlp>=2024.1.0 diff --git a/webapp/api.py b/webapp/api.py index ccde165..ee29af3 100644 --- a/webapp/api.py +++ b/webapp/api.py @@ -111,10 +111,23 @@ class SchedulePair(BaseModel): # ──────────────────── Lifespan ──────────────────── +_http_session: aiohttp.ClientSession | None = None + + +def get_http_session() -> aiohttp.ClientSession: + return _http_session + + @asynccontextmanager async def lifespan(app: FastAPI): + global _http_session + _http_session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=120), + connector=aiohttp.TCPConnector(limit=64, ttl_dns_cache=300), + ) logger.info("Mini App API started") yield + await _http_session.close() logger.info("Mini App API stopped") @@ -479,17 +492,14 @@ async def proxy_shorties_media(url: str, request: Request): if range_header: headers["Range"] = range_header - timeout = aiohttp.ClientTimeout(total=120) - session = aiohttp.ClientSession(timeout=timeout) + session = get_http_session() try: resp = await session.get(url, headers=headers, allow_redirects=True) except aiohttp.ClientError: - await session.close() raise HTTPException(status_code=502, detail="Failed to fetch media") if resp.status not in {200, 206}: await resp.release() - await session.close() raise HTTPException(status_code=resp.status, detail="Upstream error") content_type = resp.headers.get("Content-Type", "application/octet-stream") @@ -499,7 +509,6 @@ async def proxy_shorties_media(url: str, request: Request): body = await resp.text() finally: await resp.release() - await session.close() def _proxy_media_url(target_url: str) -> str: return f"/api/proxy-shorties-media?url={quote(target_url, safe='')}" @@ -551,7 +560,6 @@ async def proxy_shorties_media(url: str, request: Request): yield chunk finally: await resp.release() - await session.close() return StreamingResponse( _stream(), diff --git a/webapp/frontend/js/app.js b/webapp/frontend/js/app.js index 1b26b73..1750baf 100644 --- a/webapp/frontend/js/app.js +++ b/webapp/frontend/js/app.js @@ -34,7 +34,8 @@ const navBtns = document.querySelectorAll('.nav-btn'); // ── Router ── function navigate(page, data = null) { document.querySelectorAll('.furtok-card video').forEach((video) => { - if (video && video._hlsInstance) { + _videoObserver.unobserve(video); + if (video._hlsInstance) { try { video._hlsInstance.destroy(); } catch {} video._hlsInstance = null; } @@ -83,6 +84,10 @@ function escapeHtml(value) { function initShortiesVideoPlayback(video) { if (!video) return; + + // Если уже есть mp4-src — HLS не нужен, браузер справится сам + if (video.src && !video.src.endsWith('#')) return; + const hlsSrc = video.dataset.hlsSrc || ''; if (!hlsSrc) return; @@ -94,9 +99,10 @@ function initShortiesVideoPlayback(video) { if (window.Hls && window.Hls.isSupported()) { const hls = new window.Hls({ - maxBufferLength: 30, - backBufferLength: 30, + maxBufferLength: 15, + backBufferLength: 5, enableWorker: true, + lowLatencyMode: false, }); hls.loadSource(hlsSrc); hls.attachMedia(video); @@ -104,6 +110,23 @@ function initShortiesVideoPlayback(video) { } } +// IntersectionObserver: играем только видимое видео, паузим скрытые +const _videoObserver = new IntersectionObserver((entries) => { + entries.forEach(entry => { + const video = entry.target; + if (entry.isIntersecting) { + video.play().catch(() => {}); + } else { + video.pause(); + } + }); +}, { threshold: 0.6 }); + +function observeVideo(video) { + video.removeAttribute('autoplay'); + _videoObserver.observe(video); +} + function getShortiesShareUrl(post) { const source = String(post?.source || '').trim(); if (source) return source; @@ -898,7 +921,7 @@ async function loadFurtokPage(feedEl) { const useHls = isShorties && !directMp4 && Boolean(post.hls_url); const videoSrc = useHls ? '' : proxyMp4; const hlsSrc = isShorties ? escapeHtml(proxyHls) : ''; - mediaHtml = ``; + mediaHtml = ``; } const title = post.title ? escapeHtml(post.title) : ''; @@ -943,6 +966,7 @@ async function loadFurtokPage(feedEl) { if (video.paused) video.play(); else video.pause(); }); + observeVideo(video); } const shareBtn = card.querySelector('.furtok-share-btn'); @@ -966,14 +990,6 @@ async function loadFurtokPage(feedEl) { furtokCards.push(card); }); - // Автоплей первого видео при первой загрузке - if (furtokCurrentIndex === 0 && furtokCards.length > 0) { - const firstVideo = furtokCards[0].querySelector('video'); - if (firstVideo) { - firstVideo.muted = true; - firstVideo.play().catch(() => {}); - } - } haptic('success'); } catch (e) { diff --git a/webapp/frontend/nginx.conf b/webapp/frontend/nginx.conf index 82b5ed0..471ab56 100644 --- a/webapp/frontend/nginx.conf +++ b/webapp/frontend/nginx.conf @@ -32,7 +32,9 @@ server { } location /api/ { - proxy_pass http://webapp-api:8080; + resolver 127.0.0.11 valid=10s; + set $api http://webapp-api:8080; + proxy_pass $api; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host;