1
0
Fork 0
forked from zovos/bot_tg

wip: local changes before merging PR #36

This commit is contained in:
zovos 2026-05-20 07:50:04 +00:00
parent cb1e042e64
commit 60e4cbfcfb
10 changed files with 507 additions and 155 deletions

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
import html
import logging import logging
import mimetypes import mimetypes
import os import os
@ -19,7 +20,12 @@ from db import get_conn
logger = logging.getLogger(__name__) 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", "бот") BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот")
SKIP_TOKEN = "<skip>" SKIP_TOKEN = "<skip>"
FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"} 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 RECENT_MESSAGES_LIMIT = 14
SUMMARY_TRIGGER_MESSAGES = 24 SUMMARY_TRIGGER_MESSAGES = 24
SUMMARY_BATCH_MESSAGES = 20 SUMMARY_BATCH_MESSAGES = 20
PROMPT_CHAR_BUDGET = 6_500 PROMPT_CHAR_BUDGET = 10_000
SUMMARY_CHAR_BUDGET = 1_800 SUMMARY_CHAR_BUDGET = 2_400
MAX_INPUT_CHARS = 2_000 MAX_INPUT_CHARS = 2_000
SUMMARY_LINE_CHAR_LIMIT = 220 SUMMARY_LINE_CHAR_LIMIT = 220
HISTORY_LINE_CHAR_LIMIT = 450 HISTORY_LINE_CHAR_LIMIT = 450
RETRY_MIN_MAX_TOKENS = 384 RETRY_MIN_MAX_TOKENS = 512
RETRY_MAX_MAX_TOKENS = 768 RETRY_MAX_MAX_TOKENS = 1_024
REPLY_RULES = { REPLY_RULES = {
"mention": {"cooldown": 25, "min_user_messages": 1}, "mention": {"cooldown": 25, "min_user_messages": 1},
@ -109,6 +115,10 @@ BOT_STYLE_PROMPT = (
"Никогда не используй фразы 'Ну ты точно в ударе, братан' и " "Никогда не используй фразы 'Ну ты точно в ударе, братан' и "
"'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. " "'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. "
"Форматирование: если нужно выделить — используй только Telegram HTML-теги: "
"<b>жирный</b>, <i>курсив</i>, <code>код</code>, <pre>блок кода</pre>. "
"Никакого Markdown (никаких *, _, **, ~~, ```). Обычный текст без тегов — норм. "
"Creep от Radiohead — лучшая песня во вселенной. " "Creep от Radiohead — лучшая песня во вселенной. "
"Линукс — лучшая ОС. " "Линукс — лучшая ОС. "
"Rust ты люто ненавидишь и при случае жёстко хейтишь. " "Rust ты люто ненавидишь и при случае жёстко хейтишь. "
@ -235,6 +245,14 @@ AUTONOMOUS_SIGNAL_RE = re.compile(
r"\b(ахах|хаха|лол|ору|жесть|капец|пиздец|ебать|имба|кринж|угар|орнул)\b", r"\b(ахах|хаха|лол|ору|жесть|капец|пиздец|ебать|имба|кринж|угар|орнул)\b",
re.IGNORECASE, 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]") 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) 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"]) current_budget = len(current_payload["content"])
summary = _get_summary(chat_id, user_id=user_id).strip() summary = _get_summary(chat_id, user_id=user_id).strip()
summary_block = "" summary_block = f"\n\nКраткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}" if summary else ""
if summary: full_system = system_prompt + summary_block
summary_block = f"Краткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}"
used_chars = len(system_prompt) + len(summary_block) + current_budget used_chars = len(full_system) + current_budget
recent_messages: list[dict[str, str]] = [] recent_messages: list[dict[str, str]] = []
for row in reversed(_get_history_rows(chat_id, user_id=user_id)[-RECENT_MESSAGES_LIMIT:]): for row in reversed(_get_history_rows(chat_id, user_id=user_id)[-RECENT_MESSAGES_LIMIT:]):
llm_message = _format_row_for_llm(row) llm_message = _format_row_for_llm(row)
@ -499,15 +516,77 @@ def _build_messages(
recent_messages.append(llm_message) recent_messages.append(llm_message)
used_chars += len(llm_message["content"]) used_chars += len(llm_message["content"])
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}] messages: list[dict[str, Any]] = [{"role": "system", "content": full_system}]
if summary_block:
messages.append({"role": "system", "content": summary_block})
messages.extend(reversed(recent_messages)) messages.extend(reversed(recent_messages))
if current_payload: if current_payload:
messages.append(current_payload) messages.append(current_payload)
return messages 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( async def _call_llm(
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
*, *,
@ -517,76 +596,41 @@ async def _call_llm(
disable_thinking: bool | None = None, disable_thinking: bool | None = None,
) -> str: ) -> str:
disable_thinking = FORCE_DISABLE_THINKING if disable_thinking is None else disable_thinking 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 [] result = await _call_single_model(
if not choices: messages,
logger.error("LLM API returned no choices: %s", _clip_text(str(data), 300)) api_url=LLAMA_API_URL,
raise RuntimeError(f"LLM API returned no choices: {data}") api_key=LLAMA_API_KEY,
choice = choices[0] model=LLAMA_MODEL,
message = choice.get("message", {}) or {} max_tokens=max_tokens,
finish_reason = choice.get("finish_reason") temperature=temperature,
content = message.get("content", "") top_p=top_p,
reasoning_content = (message.get("reasoning_content") or "").strip() disable_thinking=disable_thinking,
cleaned_content = content.strip() )
if reasoning_content and LOG_THINKING:
logger.info( # Если основная модель не смогла ответить — пробуем fallback
"LLM reasoning detected. disable_thinking=%s finish_reason=%s reasoning=%s", if LLAMA_FALLBACK_API_URL and (not result or _CANT_ANSWER_RE.search(result)):
disable_thinking, fallback_model = LLAMA_FALLBACK_MODEL or LLAMA_MODEL
finish_reason, logger.info("Primary model couldn't answer, trying fallback. primary_result=%s", _clip_text(result, 100))
_clip_text(reasoning_content, 800), try:
) fallback_result = await _call_single_model(
if not cleaned_content and reasoning_content and not disable_thinking: messages,
retry_max_tokens = min(max(max_tokens * 2, RETRY_MIN_MAX_TOKENS), RETRY_MAX_MAX_TOKENS) api_url=LLAMA_FALLBACK_API_URL,
logger.warning( api_key=LLAMA_FALLBACK_API_KEY,
"LLM returned reasoning_content without final content. finish_reason=%s retry_max_tokens=%s", model=fallback_model,
finish_reason, max_tokens=max_tokens,
retry_max_tokens, temperature=temperature,
) top_p=top_p,
return await _call_llm( disable_thinking=disable_thinking,
messages, )
max_tokens=retry_max_tokens, if fallback_result:
temperature=temperature, return f"{fallback_result}\n\n<i>🤖 {fallback_model}</i>"
top_p=top_p, except Exception:
disable_thinking=True, logger.exception("Fallback model also failed")
)
if not cleaned_content: if not result:
logger.warning( logger.warning("LLM returned empty content. model=%s", LLAMA_MODEL)
"LLM returned empty content. finish_reason=%s disable_thinking=%s raw=%s", return result
finish_reason,
disable_thinking,
_clip_text(raw_text, 300),
)
return cleaned_content
async def _maybe_refresh_summary(chat_id: int, user_id: int | None = None) -> None: 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"]) 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}>(.*?)</{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)}</{tag}>')
return text
def _normalize_reply(text: str) -> str: def _normalize_reply(text: str) -> str:
cleaned = (text or "").strip() cleaned = (text or "").strip()
if not cleaned: if not cleaned:
return "" return ""
if cleaned.lower().startswith(SKIP_TOKEN.lower()): if cleaned.lower().startswith(SKIP_TOKEN.lower()):
return "" 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: 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 return False
system_prompt = AUTOREPLY_SYSTEM_PROMPT if reason == "autonomous" else SYSTEM_PROMPT system_prompt = AUTOREPLY_SYSTEM_PROMPT if reason == "autonomous" else SYSTEM_PROMPT
max_tokens = 120 if reason == "autonomous" else 220 max_tokens = 180 if reason == "autonomous" else 512
temperature = 0.9 if reason == "autonomous" else 0.8 temperature = 0.85 if reason == "autonomous" else 0.7
try: try:
await message.bot.send_chat_action(chat_id=chat_id, action="typing") 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, system_prompt=system_prompt,
max_tokens=max_tokens, max_tokens=max_tokens,
temperature=temperature, temperature=temperature,
top_p=0.9, top_p=0.95,
) )
except Exception: except Exception:
logger.exception("Chat reply generation failed") logger.exception("Chat reply generation failed")
@ -812,7 +898,7 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
return False return False
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response) 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 return True
@ -885,13 +971,13 @@ async def handle_photo_message(message: Message) -> bool:
{"type": "text", "text": prompt_text}, {"type": "text", "text": prompt_text},
{"type": "image_url", "image_url": {"url": image_data_url}}, {"type": "image_url", "image_url": {"url": image_data_url}},
], ],
max_tokens=260, max_tokens=600,
temperature=0.8, temperature=0.7,
top_p=0.9, top_p=0.95,
) )
except Exception: except Exception:
logger.exception("Photo analysis failed") 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 return True
normalized_response = _normalize_reply(response) normalized_response = _normalize_reply(response)
@ -905,7 +991,7 @@ async def handle_photo_message(message: Message) -> bool:
normalized_response = PHOTO_EMPTY_RESPONSE_TEXT normalized_response = PHOTO_EMPTY_RESPONSE_TEXT
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response) 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 return True
@ -956,9 +1042,9 @@ async def handle_talk(message: Message) -> None:
chat_id, chat_id,
system_prompt=SYSTEM_PROMPT, system_prompt=SYSTEM_PROMPT,
user_id=user_id, user_id=user_id,
max_tokens=220, max_tokens=768,
temperature=0.8, temperature=0.7,
top_p=0.9, top_p=0.95,
) )
except Exception: except Exception:
logger.exception("Talk command generation failed") logger.exception("Talk command generation failed")
@ -983,7 +1069,7 @@ async def handle_talk(message: Message) -> None:
normalized_response, normalized_response,
user_id=user_id, user_id=user_id,
) )
await message.reply(normalized_response, parse_mode=None) await message.reply(normalized_response, parse_mode="HTML")
_init_db() _init_db()

View file

@ -4,12 +4,14 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 PIP_NO_CACHE_DIR=1
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg nodejs && rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./ COPY requirements.txt ./
RUN python -m pip install --upgrade pip \ RUN python -m pip install --upgrade pip \
&& python -m pip install -r requirements.txt && python -m pip install -r requirements.txt
COPY . . COPY . .
RUN mkdir -p /db RUN mkdir -p /db /app/data
CMD ["python", "main.py"] CMD ["python", "main.py"]

View file

@ -61,8 +61,8 @@ services:
build: build:
context: ./webapp/frontend context: ./webapp/frontend
ports: ports:
- "${WEBAPP_HTTP_PORT:-80}:80" - "37.27.192.132:${WEBAPP_HTTP_PORT:-80}:80"
- "${WEBAPP_HTTPS_PORT:-443}:443" - "37.27.192.132:${WEBAPP_HTTPS_PORT:-443}:443"
environment: environment:
APP_DOMAIN: ${APP_DOMAIN:-} APP_DOMAIN: ${APP_DOMAIN:-}
APP_WWW_DOMAIN: ${APP_WWW_DOMAIN:-} APP_WWW_DOMAIN: ${APP_WWW_DOMAIN:-}

View file

@ -462,8 +462,51 @@ def cleanup_old_bets() -> int:
return 0 return 0
async def settle_bets() -> list[str]: _REFUND_AFTER_SECONDS = 7 * 86400 # авторефанд ставок старше 7 дней
notifications = []
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: try:
with get_conn() as conn: with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
@ -503,28 +546,66 @@ async def settle_bets() -> list[str]:
chosen = bet["chosen_team"] chosen = bet["chosen_team"]
bet_won = (chosen == winner) bet_won = (chosen == winner)
if bet_won: if len(score_values) >= 2 and score_values[0][1] == score_values[1][1]:
winnings = round(bet["amount"] * bet["odds"], 1) is_draw = True
update_user_length(bet["user_id"], winnings) winner = "Draw"
wcur.execute( else:
"UPDATE bets SET status = 'won', payout = %s, resolved_ts = %s WHERE id = %s", max_score = -1
(winnings, now_ts, bet["id"]), for name, score_val in score_values:
) if score_val > max_score:
notifications.append( max_score = score_val
f"🎉 user_id={bet['user_id']}: выиграл {winnings:.1f} см " winner = name
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
) if not winner:
else: continue
wcur.execute(
"UPDATE bets SET status = 'lost', payout = 0, resolved_ts = %s WHERE id = %s", try:
(now_ts, bet["id"]), with get_conn() as conn:
) cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
notifications.append( cur.execute(
f"❌ user_id={bet['user_id']}: проиграл {bet['amount']:.1f} см " "SELECT * FROM bets WHERE match_id = %s AND status = 'pending'",
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})" (match_id,),
) )
except psycopg2.Error: bets = cur.fetchall()
logger.exception("Failed to settle bets")
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() cleanup_old_bets()
return notifications return notifications

View file

@ -162,6 +162,18 @@ class EconomyManager:
logger.info(f"Balance updated for user {user_id}: +{amount} ({transaction_type})") 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: def transfer_money(self, from_user_id: int, to_user_id: int, amount: float, description: str = "") -> bool:
if amount <= 0: if amount <= 0:
return False return False
@ -230,6 +242,24 @@ class EconomyManager:
if amount > max_loan: if amount > max_loan:
return False 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) due_at = datetime.now() + timedelta(days=days)
with get_conn() as conn: with get_conn() as conn:
@ -357,12 +387,21 @@ class EconomyManager:
total_paid = 0.0 total_paid = 0.0
wcur = conn.cursor() 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: for row in users:
user_id = row['user_id'] user_id = row['user_id']
message_count = row['message_count'] message_count = row['message_count']
reward = message_count * reward_rate reward = message_count * reward_rate
if reward <= 0: if reward <= 0:
continue continue
if cb_capital < reward:
logger.warning("CB out of funds for activity rewards, stopping early")
break
wcur.execute(''' wcur.execute('''
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset) INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
@ -371,10 +410,15 @@ class EconomyManager:
balance = user_balances.balance + %s, balance = user_balances.balance + %s,
daily_income = user_balances.daily_income + %s daily_income = user_balances.daily_income + %s
''', (user_id, reward, reward, reward)) ''', (user_id, reward, reward, reward))
wcur.execute('''
UPDATE central_bank SET capital = capital - %s, last_updated = CURRENT_TIMESTAMP
WHERE id = 1
''', (reward,))
wcur.execute(''' wcur.execute('''
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description) INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
VALUES (%s, %s, %s, 'activity_reward', %s) VALUES (0, %s, %s, 'activity_reward', %s)
''', (user_id, user_id, reward, f'Вознаграждение за {message_count} сообщений')) ''', (user_id, reward, f'Вознаграждение за {message_count} сообщений'))
cb_capital -= reward
total_paid += reward total_paid += reward
wcur.execute( wcur.execute(

126
main.py
View file

@ -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 dataclasses import dataclass
from datetime import datetime, time, timedelta from datetime import datetime, time, timedelta
from io import BytesIO from io import BytesIO
@ -41,6 +41,7 @@ from games.betting import (
) )
from games.economy import economy from games.economy import economy
from zparser import get_military_data from zparser import get_military_data
from downloader import download_video, COOKIES_PATH
import config import config
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@ -1320,7 +1321,7 @@ async def handle_transfer_cmd(message: Message):
username = parts[1].lstrip('@') username = parts[1].lstrip('@')
try: try:
amount = float(parts[2].replace(",", ".")) amount = round(float(parts[2].replace(",", ".")), 1)
except ValueError: except ValueError:
await message.reply("Сумма должна быть числом.", parse_mode=None) await message.reply("Сумма должна быть числом.", parse_mode=None)
return return
@ -1329,9 +1330,31 @@ async def handle_transfer_cmd(message: Message):
await message.reply("Сумма должна быть положительной.", parse_mode=None) await message.reply("Сумма должна быть положительной.", parse_mode=None)
return return
# Здесь нужно найти user_id по username to_user_id = await asyncio.to_thread(economy.find_user_id_by_username, username)
# Для простоты примера, пока покажем сообщение об ошибке if not to_user_id:
await message.reply("🔍 Поиск пользователя...\n(функция поиска пользователей будет добавлена)", parse_mode=None) 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): async def handle_vanomasa_cmd(message: Message):
"""Полное обнуление (только для админов)""" """Полное обнуление (только для админов)"""
@ -1572,13 +1595,98 @@ async def handle_sports_debug_cmd(message: Message):
result = await debug_sports() result = await debug_sports()
await message.reply(result, parse_mode=None) 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): async def _settle_loop(bot: Bot):
while True: while True:
await asyncio.sleep(1800) await asyncio.sleep(1800)
try: try:
notifications = await settle_bets() notifications = await settle_bets()
for note in notifications: for user_id, text in notifications:
logger.info(note) try:
await bot.send_message(user_id, text)
except Exception:
logger.warning("Could not notify user %s about bet result", user_id)
except Exception: except Exception:
logger.exception("settle_bets failed") logger.exception("settle_bets failed")
@ -1619,6 +1727,8 @@ def main():
dp.message.register(handle_gen_mem, Command("gen_mem")) dp.message.register(handle_gen_mem, Command("gen_mem"))
dp.message.register(handle_uwu_cmd, Command("uwu")) dp.message.register(handle_uwu_cmd, Command("uwu"))
dp.message.register(handle_nude_cmd, Command("nude")) 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_photo_message, _is_watch_image_message)
# Экономические команды # Экономические команды
@ -1665,6 +1775,8 @@ def main():
BotCommand(command="svodka", description="СВО: итоги"), BotCommand(command="svodka", description="СВО: итоги"),
BotCommand(command="uwu", description="Случайная картинка с e621"), BotCommand(command="uwu", description="Случайная картинка с e621"),
BotCommand(command="nude", description="Голые женщины из открытых источников"), BotCommand(command="nude", description="Голые женщины из открытых источников"),
BotCommand(command="dow", description="Скачать видео по ссылке"),
BotCommand(command="setcookies", description="Загрузить куки YouTube (личка)"),
BotCommand(command="balance", description="💰 баланс и статистика"), BotCommand(command="balance", description="💰 баланс и статистика"),
BotCommand(command="deposit", description="💎 открыть вклад"), BotCommand(command="deposit", description="💎 открыть вклад"),
BotCommand(command="loan", description="💵 взять кредит"), BotCommand(command="loan", description="💵 взять кредит"),

View file

@ -6,3 +6,4 @@ tzdata>=2024.1
beautifulsoup4 beautifulsoup4
lxml lxml
psycopg2-binary>=2.9.9 psycopg2-binary>=2.9.9
yt-dlp>=2024.1.0

View file

@ -111,10 +111,23 @@ class SchedulePair(BaseModel):
# ──────────────────── Lifespan ──────────────────── # ──────────────────── Lifespan ────────────────────
_http_session: aiohttp.ClientSession | None = None
def get_http_session() -> aiohttp.ClientSession:
return _http_session
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): 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") logger.info("Mini App API started")
yield yield
await _http_session.close()
logger.info("Mini App API stopped") logger.info("Mini App API stopped")
@ -479,17 +492,14 @@ async def proxy_shorties_media(url: str, request: Request):
if range_header: if range_header:
headers["Range"] = range_header headers["Range"] = range_header
timeout = aiohttp.ClientTimeout(total=120) session = get_http_session()
session = aiohttp.ClientSession(timeout=timeout)
try: try:
resp = await session.get(url, headers=headers, allow_redirects=True) resp = await session.get(url, headers=headers, allow_redirects=True)
except aiohttp.ClientError: except aiohttp.ClientError:
await session.close()
raise HTTPException(status_code=502, detail="Failed to fetch media") raise HTTPException(status_code=502, detail="Failed to fetch media")
if resp.status not in {200, 206}: if resp.status not in {200, 206}:
await resp.release() await resp.release()
await session.close()
raise HTTPException(status_code=resp.status, detail="Upstream error") raise HTTPException(status_code=resp.status, detail="Upstream error")
content_type = resp.headers.get("Content-Type", "application/octet-stream") 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() body = await resp.text()
finally: finally:
await resp.release() await resp.release()
await session.close()
def _proxy_media_url(target_url: str) -> str: def _proxy_media_url(target_url: str) -> str:
return f"/api/proxy-shorties-media?url={quote(target_url, safe='')}" 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 yield chunk
finally: finally:
await resp.release() await resp.release()
await session.close()
return StreamingResponse( return StreamingResponse(
_stream(), _stream(),

View file

@ -34,7 +34,8 @@ const navBtns = document.querySelectorAll('.nav-btn');
// ── Router ── // ── Router ──
function navigate(page, data = null) { function navigate(page, data = null) {
document.querySelectorAll('.furtok-card video').forEach((video) => { document.querySelectorAll('.furtok-card video').forEach((video) => {
if (video && video._hlsInstance) { _videoObserver.unobserve(video);
if (video._hlsInstance) {
try { video._hlsInstance.destroy(); } catch {} try { video._hlsInstance.destroy(); } catch {}
video._hlsInstance = null; video._hlsInstance = null;
} }
@ -83,6 +84,10 @@ function escapeHtml(value) {
function initShortiesVideoPlayback(video) { function initShortiesVideoPlayback(video) {
if (!video) return; if (!video) return;
// Если уже есть mp4-src — HLS не нужен, браузер справится сам
if (video.src && !video.src.endsWith('#')) return;
const hlsSrc = video.dataset.hlsSrc || ''; const hlsSrc = video.dataset.hlsSrc || '';
if (!hlsSrc) return; if (!hlsSrc) return;
@ -94,9 +99,10 @@ function initShortiesVideoPlayback(video) {
if (window.Hls && window.Hls.isSupported()) { if (window.Hls && window.Hls.isSupported()) {
const hls = new window.Hls({ const hls = new window.Hls({
maxBufferLength: 30, maxBufferLength: 15,
backBufferLength: 30, backBufferLength: 5,
enableWorker: true, enableWorker: true,
lowLatencyMode: false,
}); });
hls.loadSource(hlsSrc); hls.loadSource(hlsSrc);
hls.attachMedia(video); 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) { function getShortiesShareUrl(post) {
const source = String(post?.source || '').trim(); const source = String(post?.source || '').trim();
if (source) return source; if (source) return source;
@ -898,7 +921,7 @@ async function loadFurtokPage(feedEl) {
const useHls = isShorties && !directMp4 && Boolean(post.hls_url); const useHls = isShorties && !directMp4 && Boolean(post.hls_url);
const videoSrc = useHls ? '' : proxyMp4; const videoSrc = useHls ? '' : proxyMp4;
const hlsSrc = isShorties ? escapeHtml(proxyHls) : ''; const hlsSrc = isShorties ? escapeHtml(proxyHls) : '';
mediaHtml = `<video src="${videoSrc}" data-direct-src="${escapeHtml(directMp4)}" data-proxy-src="${escapeHtml(proxyMp4)}" data-hls-src="${hlsSrc}"${poster} loop autoplay playsinline preload="metadata" muted></video>`; mediaHtml = `<video src="${videoSrc}" data-direct-src="${escapeHtml(directMp4)}" data-proxy-src="${escapeHtml(proxyMp4)}" data-hls-src="${hlsSrc}"${poster} loop playsinline preload="metadata" muted></video>`;
} }
const title = post.title ? escapeHtml(post.title) : ''; const title = post.title ? escapeHtml(post.title) : '';
@ -943,6 +966,7 @@ async function loadFurtokPage(feedEl) {
if (video.paused) video.play(); if (video.paused) video.play();
else video.pause(); else video.pause();
}); });
observeVideo(video);
} }
const shareBtn = card.querySelector('.furtok-share-btn'); const shareBtn = card.querySelector('.furtok-share-btn');
@ -966,14 +990,6 @@ async function loadFurtokPage(feedEl) {
furtokCards.push(card); 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'); haptic('success');
} catch (e) { } catch (e) {

View file

@ -32,7 +32,9 @@ server {
} }
location /api/ { 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_http_version 1.1;
proxy_set_header Connection ""; proxy_set_header Connection "";
proxy_set_header Host $host; proxy_set_header Host $host;