1
0
Fork 0
forked from zovos/bot_tg

Compare commits

..

5 commits

12 changed files with 642 additions and 172 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,15 @@ 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
TALK_MAX_TOKENS = 1536
TALK_THINKING_BUDGET = 1024
REPLY_RULES = { REPLY_RULES = {
"mention": {"cooldown": 25, "min_user_messages": 1}, "mention": {"cooldown": 25, "min_user_messages": 1},
@ -109,6 +117,10 @@ BOT_STYLE_PROMPT = (
"Никогда не используй фразы 'Ну ты точно в ударе, братан' и " "Никогда не используй фразы 'Ну ты точно в ударе, братан' и "
"'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. " "'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. "
"Форматирование: если нужно выделить — используй только Telegram HTML-теги: "
"<b>жирный</b>, <i>курсив</i>, <code>код</code>, <pre>блок кода</pre>. "
"Никакого Markdown (никаких *, _, **, ~~, ```). Обычный текст без тегов — норм. "
"Creep от Radiohead — лучшая песня во вселенной. " "Creep от Radiohead — лучшая песня во вселенной. "
"Линукс — лучшая ОС. " "Линукс — лучшая ОС. "
"Rust ты люто ненавидишь и при случае жёстко хейтишь. " "Rust ты люто ненавидишь и при случае жёстко хейтишь. "
@ -118,10 +130,21 @@ BOT_STYLE_PROMPT = (
SYSTEM_PROMPT = ( SYSTEM_PROMPT = (
f"{BOT_STYLE_PROMPT}\n\n" f"{BOT_STYLE_PROMPT}\n\n"
"Ты отвечаешь как живой участник одного Telegram-чата. " "Ты отвечаешь как живой участник Telegram-чата с несколькими разными людьми. "
"Сообщения в истории записаны в формате «Имя: текст» — каждое имя это отдельный человек, не путай их. "
"Отвечай тому, кто написал последним, учитывай контекст именно его сообщений. "
"Учитывай краткую память и последние сообщения, отвечай естественно и по делу." "Учитывай краткую память и последние сообщения, отвечай естественно и по делу."
) )
TALK_SYSTEM_PROMPT = (
f"{BOT_STYLE_PROMPT}\n\n"
"Ты ведёшь личный разговор тет-а-тет с одним конкретным человеком через /talk. "
"Помни всё, что он говорил раньше — ты видишь историю этого разговора и должен её учитывать. "
"Замечай если человек продолжает старую тему или переходит к новой. "
"Отвечай КОРОТКО: 13 предложения максимум. "
"Только если тебя прямо попросили объяснить, развернуть или написать подробно — тогда можно больше."
)
AUTOREPLY_SYSTEM_PROMPT = ( AUTOREPLY_SYSTEM_PROMPT = (
f"{BOT_STYLE_PROMPT}\n\n" f"{BOT_STYLE_PROMPT}\n\n"
"Ты иногда сам коротко и уместно влезаешь в разговор в Telegram-чате. " "Ты иногда сам коротко и уместно влезаешь в разговор в Telegram-чате. "
@ -235,6 +258,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 +517,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 +529,80 @@ 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,
reasoning_budget: int = 0,
) -> 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},
})
elif reasoning_budget > 0:
payload["reasoning_budget"] = reasoning_budget
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, reasoning_budget=0,
)
return cleaned_content
async def _call_llm( async def _call_llm(
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
*, *,
@ -515,78 +610,46 @@ async def _call_llm(
temperature: float, temperature: float,
top_p: float, top_p: float,
disable_thinking: bool | None = None, disable_thinking: bool | None = None,
reasoning_budget: int = 0,
) -> 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() reasoning_budget=reasoning_budget,
if reasoning_content and LOG_THINKING: )
logger.info(
"LLM reasoning detected. disable_thinking=%s finish_reason=%s reasoning=%s", # Если основная модель не смогла ответить — пробуем fallback
disable_thinking, if LLAMA_FALLBACK_API_URL and (not result or _CANT_ANSWER_RE.search(result)):
finish_reason, fallback_model = LLAMA_FALLBACK_MODEL or LLAMA_MODEL
_clip_text(reasoning_content, 800), logger.info("Primary model couldn't answer, trying fallback. primary_result=%s", _clip_text(result, 100))
) try:
if not cleaned_content and reasoning_content and not disable_thinking: fallback_result = await _call_single_model(
retry_max_tokens = min(max(max_tokens * 2, RETRY_MIN_MAX_TOKENS), RETRY_MAX_MAX_TOKENS) messages,
logger.warning( api_url=LLAMA_FALLBACK_API_URL,
"LLM returned reasoning_content without final content. finish_reason=%s retry_max_tokens=%s", api_key=LLAMA_FALLBACK_API_KEY,
finish_reason, model=fallback_model,
retry_max_tokens, max_tokens=max_tokens,
) temperature=temperature,
return await _call_llm( top_p=top_p,
messages, disable_thinking=disable_thinking,
max_tokens=retry_max_tokens, reasoning_budget=reasoning_budget,
temperature=temperature, )
top_p=top_p, if fallback_result:
disable_thinking=True, return f"{fallback_result}\n\n<i>🤖 {fallback_model}</i>"
) except Exception:
if not cleaned_content: logger.exception("Fallback model also failed")
logger.warning(
"LLM returned empty content. finish_reason=%s disable_thinking=%s raw=%s", if not result:
finish_reason, logger.warning("LLM returned empty content. model=%s", LLAMA_MODEL)
disable_thinking, return result
_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:
@ -652,6 +715,8 @@ async def _generate_response(
max_tokens: int, max_tokens: int,
temperature: float, temperature: float,
top_p: float, top_p: float,
disable_thinking: bool | None = None,
reasoning_budget: int = 0,
) -> str: ) -> str:
await _maybe_refresh_summary(chat_id, user_id=user_id) await _maybe_refresh_summary(chat_id, user_id=user_id)
if current_content is None: if current_content is None:
@ -677,6 +742,8 @@ async def _generate_response(
max_tokens=max_tokens, max_tokens=max_tokens,
temperature=temperature, temperature=temperature,
top_p=top_p, top_p=top_p,
disable_thinking=disable_thinking,
reasoning_budget=reasoning_budget,
) )
@ -754,13 +821,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 +894,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 +904,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 +921,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 +994,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 +1014,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
@ -954,11 +1063,13 @@ async def handle_talk(message: Message) -> None:
await message.bot.send_chat_action(chat_id=chat_id, action="typing") await message.bot.send_chat_action(chat_id=chat_id, action="typing")
response = await _generate_response( response = await _generate_response(
chat_id, chat_id,
system_prompt=SYSTEM_PROMPT, system_prompt=TALK_SYSTEM_PROMPT,
user_id=user_id, user_id=user_id,
max_tokens=220, max_tokens=TALK_MAX_TOKENS,
temperature=0.8, temperature=0.7,
top_p=0.9, top_p=0.95,
disable_thinking=False,
reasoning_budget=TALK_THINKING_BUDGET,
) )
except Exception: except Exception:
logger.exception("Talk command generation failed") logger.exception("Talk command generation failed")
@ -983,7 +1094,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

@ -286,26 +286,26 @@ async def get_match_by_index(sport_alias: str, index: int) -> dict | None:
return None return None
def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: float) -> str: def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: float) -> tuple[bool, str]:
current = get_user_length(user_id) current = get_user_length(user_id)
if current is None: if current is None:
return "Сначала заведи счёт через /penis, братуха." return False, "Сначала заведи счёт через /penis, братуха."
if current <= 0: if current <= 0:
return "🚫 С кредитом ставки не принимаем." return False, "🚫 С кредитом ставки не принимаем."
if amount <= 0: if amount <= 0:
return "Ставка должна быть больше нуля." return False, "Ставка должна быть больше нуля."
if amount > config.BET_MAX_AMOUNT: if amount > config.BET_MAX_AMOUNT:
return f"Максимальная ставка — {config.BET_MAX_AMOUNT} см." return False, f"Максимальная ставка — {config.BET_MAX_AMOUNT} см."
if amount > current: if round(amount, 2) > round(current, 2):
return f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер." return False, f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер."
team_odds = match["odds"].get(chosen_team) team_odds = match["odds"].get(chosen_team)
if team_odds is None: if team_odds is None:
return f"Исход не найден. Доступные: {describe_match_outcomes(match)}" return False, f"Исход не найден. Доступные: {describe_match_outcomes(match)}"
new_length = update_user_length(user_id, -amount) new_length = update_user_length(user_id, -amount)
if new_length is None: if new_length is None:
return "Ошибка БД." return False, "Ошибка БД."
now_ts = int(_time.time()) now_ts = int(_time.time())
try: try:
@ -323,12 +323,11 @@ def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: f
except psycopg2.Error: except psycopg2.Error:
logger.exception("Failed to place bet") logger.exception("Failed to place bet")
update_user_length(user_id, amount) update_user_length(user_id, amount)
return "Ошибка БД." return False, "Ошибка БД."
potential = round(amount * team_odds, 1) potential = round(amount * team_odds, 1)
return ( return True, (
f"✅ Ставка принята!\n" f"✅ Ставка принята!\n"
f"🏟 {match['home']} vs {match['away']}\n"
f"📌 {chosen_team} (x{team_odds:.2f})\n" f"📌 {chosen_team} (x{team_odds:.2f})\n"
f"💰 Ставка: {amount:.1f} см\n" f"💰 Ставка: {amount:.1f} см\n"
f"🎯 Возможный выигрыш: {potential:.1f} см\n" f"🎯 Возможный выигрыш: {potential:.1f} см\n"
@ -462,8 +461,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)
@ -489,6 +531,8 @@ async def settle_bets() -> list[str]:
if not winner: if not winner:
continue continue
is_draw = (winner.casefold() in _DRAW_NAMES or winner == "Draw")
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)
@ -501,7 +545,10 @@ async def settle_bets() -> list[str]:
wcur = conn.cursor() wcur = conn.cursor()
for bet in bets: for bet in bets:
chosen = bet["chosen_team"] chosen = bet["chosen_team"]
bet_won = (chosen == winner) if is_draw:
bet_won = chosen.casefold() in _DRAW_NAMES or chosen == "Draw"
else:
bet_won = chosen == winner
if bet_won: if bet_won:
winnings = round(bet["amount"] * bet["odds"], 1) winnings = round(bet["amount"] * bet["odds"], 1)
@ -510,19 +557,27 @@ async def settle_bets() -> list[str]:
"UPDATE bets SET status = 'won', payout = %s, resolved_ts = %s WHERE id = %s", "UPDATE bets SET status = 'won', payout = %s, resolved_ts = %s WHERE id = %s",
(winnings, now_ts, bet["id"]), (winnings, now_ts, bet["id"]),
) )
notifications.append( notifications.append((
f"🎉 user_id={bet['user_id']}: выиграл {winnings:.1f} см " bet["user_id"],
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})" 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: else:
wcur.execute( wcur.execute(
"UPDATE bets SET status = 'lost', payout = 0, resolved_ts = %s WHERE id = %s", "UPDATE bets SET status = 'lost', payout = 0, resolved_ts = %s WHERE id = %s",
(now_ts, bet["id"]), (now_ts, bet["id"]),
) )
notifications.append( notifications.append((
f"❌ user_id={bet['user_id']}: проиграл {bet['amount']:.1f} см " bet["user_id"],
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})" 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: except psycopg2.Error:
logger.exception("Failed to settle bets") logger.exception("Failed to settle bets")

View file

@ -75,7 +75,7 @@ def play_casino(user_id: int, bet: float) -> str:
return f"Максимальная ставка — {config.CASINO_MAX_BET} см, не жадничай." return f"Максимальная ставка — {config.CASINO_MAX_BET} см, не жадничай."
if bet <= 0: if bet <= 0:
return "Ставка должна быть больше нуля, фраер." return "Ставка должна быть больше нуля, фраер."
if bet > current: if round(bet, 2) > round(current, 2):
return f"У тебя {current:.1f} см, а ставишь {bet:.1f}. Столько нет, фраер." return f"У тебя {current:.1f} см, а ставишь {bet:.1f}. Столько нет, фраер."
reels, matches = spin_slots() reels, matches = spin_slots()

View file

@ -162,12 +162,25 @@ 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
from_balance = self.get_user_balance(from_user_id) from_balance = self.get_user_balance(from_user_id)
if amount > from_balance * 0.2 or from_balance < amount: limit = round(from_balance * 0.2, 2)
if round(amount, 2) > limit or round(from_balance, 2) < round(amount, 2):
return False return False
with get_conn() as conn: with get_conn() as conn:
@ -189,15 +202,17 @@ class EconomyManager:
logger.info(f"Transfer: {amount} from user {from_user_id} to user {to_user_id}") logger.info(f"Transfer: {amount} from user {from_user_id} to user {to_user_id}")
return True return True
def create_deposit(self, user_id: int, amount: float, days: int) -> bool: def create_deposit(self, user_id: int, amount: float, days: int) -> tuple[bool, str]:
if amount <= 0: if amount <= 0:
return False return False, "Сумма вклада должна быть больше нуля."
balance = self.get_user_balance(user_id) balance = self.get_user_balance(user_id)
min_deposit = float(self.get_setting('deposit_min_amount')) min_deposit = float(self.get_setting('deposit_min_amount'))
if amount < min_deposit or balance < amount: if round(amount, 2) < round(min_deposit, 2):
return False return False, f"Минимальная сумма вклада — {min_deposit:.1f} см. Твой баланс: {balance:.1f} см."
if round(balance, 2) < round(amount, 2):
return False, f"Недостаточно средств. Твой баланс: {balance:.1f} см, а сумма вклада: {amount:.1f} см."
central_bank_rate = float(self.get_setting('central_bank_rate')) central_bank_rate = float(self.get_setting('central_bank_rate'))
matures_at = datetime.now() + timedelta(days=days) matures_at = datetime.now() + timedelta(days=days)
@ -218,7 +233,7 @@ class EconomyManager:
''', (user_id, amount, central_bank_rate, matures_at)) ''', (user_id, amount, central_bank_rate, matures_at))
logger.info(f"Deposit created: user {user_id}, amount {amount}, rate {central_bank_rate}%") logger.info(f"Deposit created: user {user_id}, amount {amount}, rate {central_bank_rate}%")
return True return True, "Вклад успешно открыт!"
def create_loan(self, user_id: int, amount: float, days: int) -> bool: def create_loan(self, user_id: int, amount: float, days: int) -> bool:
if amount <= 0: if amount <= 0:
@ -227,9 +242,27 @@ class EconomyManager:
max_loan = float(self.get_setting('loan_max_amount')) max_loan = float(self.get_setting('loan_max_amount'))
loan_rate = float(self.get_setting('loan_interest_rate')) loan_rate = float(self.get_setting('loan_interest_rate'))
if amount > max_loan: if round(amount, 2) > round(max_loan, 2):
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 round(outstanding + amount, 2) > round(max_loan, 2):
return False
# ЦБ должен иметь достаточно капитала
cur.execute("SELECT capital FROM central_bank WHERE id=1")
cb_capital = float(cur.fetchone()['capital'])
if round(cb_capital, 2) < round(amount, 2):
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:
@ -263,10 +296,10 @@ class EconomyManager:
if not loan or loan['is_repaid']: if not loan or loan['is_repaid']:
return False return False
total_repayment = loan['amount'] * (1 + loan['interest_rate'] / 100) total_repayment = round(loan['amount'] * (1 + loan['interest_rate'] / 100), 2)
balance = self.get_user_balance(user_id) balance = self.get_user_balance(user_id)
if balance < total_repayment: if round(balance, 2) < total_repayment:
return False return False
wcur = conn.cursor() wcur = conn.cursor()
@ -357,12 +390,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 +413,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(
@ -435,7 +482,7 @@ class EconomyManager:
logger.info(f"Central bank updated: {capital_change} ({transaction_type})") logger.info(f"Central bank updated: {capital_change} ({transaction_type})")
def emit_money(self, amount: float, reason: str = "") -> bool: def emit_money(self, amount: float, reason: str = "") -> bool:
if self.get_central_bank_stats()['capital'] < amount: if round(self.get_central_bank_stats()['capital'], 2) < round(amount, 2):
return False return False
self.update_central_bank(-amount, 'emission', reason) self.update_central_bank(-amount, 'emission', reason)
logger.info(f"Money emitted: {amount} cm ({reason})") logger.info(f"Money emitted: {amount} cm ({reason})")

142
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,7 +41,8 @@ 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
import config from downloader import download_video, COOKIES_PATH
import config
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -1262,12 +1263,12 @@ async def handle_deposit_cmd(message: Message):
await message.reply("Сумма и дни должны быть положительными.", parse_mode=None) await message.reply("Сумма и дни должны быть положительными.", parse_mode=None)
return return
success = await asyncio.to_thread(economy.create_deposit, message.from_user.id, amount, days) success, error_msg = await asyncio.to_thread(economy.create_deposit, message.from_user.id, amount, days)
if success: if success:
rate = economy.get_setting('central_bank_rate') rate = economy.get_setting('central_bank_rate')
await message.reply(f"✅ Вклад открыт!\n💰 Сумма: {amount:.1f} см\n📅 Срок: {days} дней\n📈 Ставка: {rate}% годовых", parse_mode=None) await message.reply(f"✅ Вклад открыт!\n💰 Сумма: {amount:.1f} см\n📅 Срок: {days} дней\n📈 Ставка: {rate}% годовых", parse_mode=None)
else: else:
await message.reply("Не удалось открыть вклад. Проверь баланс и минимальную сумму.", parse_mode=None) await message.reply(f"Не удалось открыть вклад. {error_msg}", parse_mode=None)
async def handle_loans_cmd(message: Message): async def handle_loans_cmd(message: Message):
"""Показать кредиты""" """Показать кредиты"""
@ -1312,26 +1313,48 @@ async def handle_transfer_cmd(message: Message):
if not message.from_user: if not message.from_user:
await message.answer("Команда доступна только пользователям.") await message.answer("Команда доступна только пользователям.")
return return
parts = message.text.split(maxsplit=2) parts = message.text.split(maxsplit=2)
if len(parts) < 3: if len(parts) < 3:
await message.reply("Формат: /transfer @username [сумма]\nПример: /transfer @user123 10.5", parse_mode=None) await message.reply("Формат: /transfer @username [сумма]\nПример: /transfer @user123 10.5", parse_mode=None)
return return
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
if amount <= 0: if amount <= 0:
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):
"""Полное обнуление (только для админов)""" """Полное обнуление (только для админов)"""
@ -1539,7 +1562,7 @@ async def handle_bet_cmd(message: Message):
) )
return return
result = await asyncio.to_thread(place_bet, message.from_user.id, match, sport_alias, team, amount) success, result = await asyncio.to_thread(place_bet, message.from_user.id, match, sport_alias, team, amount)
await message.reply(result, parse_mode=None) await message.reply(result, parse_mode=None)
async def handle_mybets_cmd(message: Message): async def handle_mybets_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

114
test_precision.py Normal file
View file

@ -0,0 +1,114 @@
import sys
import os
import unittest
from unittest.mock import MagicMock, patch
# Вставляем путь к проекту в sys.path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# 1. Глобальный мок для psycopg2, пула соединений и функции get_conn,
# чтобы избежать реального подключения к БД во время импорта и выполнения тестов.
import psycopg2
psycopg2.connect = MagicMock()
import psycopg2.pool
psycopg2.pool.ThreadedConnectionPool = MagicMock()
import db
# Мокаем get_conn как контекстный менеджер
mock_conn = MagicMock()
mock_cur = MagicMock()
mock_conn.cursor.return_value = mock_cur
# Для RealDictCursor
mock_cur_dict = MagicMock()
mock_conn.cursor.return_value = mock_cur_dict
mock_get_conn = MagicMock()
mock_get_conn.return_value.__enter__.return_value = mock_conn
db.get_conn = mock_get_conn
# 2. Импортируем тестируемые модули после настройки моков
from games.betting import place_bet
from games.casino import play_casino
from games.economy import economy
class TestPrecision(unittest.TestCase):
@patch('games.betting.get_user_length')
@patch('games.betting.update_user_length')
def test_place_bet_exact_balance(self, mock_update_user_length, mock_get_user_length):
# Баланс 0.199999988 (32-bit float представление 0.2)
mock_get_user_length.return_value = 0.199999988
mock_update_user_length.return_value = 0.0
match = {
"id": "match_123",
"home": "Team A",
"away": "Team B",
"odds": {"Team A": 2.0, "Team B": 1.8}
}
# Ставка ровно 0.2 должна быть успешной благодаря округлению round(amount, 2) <= round(current, 2)
success, message = place_bet(12345, match, "dota2", "Team A", 0.2)
self.assertTrue(success)
self.assertIn("Ставка принята!", message)
# Ставка 0.21 при балансе 0.199999988 должна быть отклонена
success_fail, message_fail = place_bet(12345, match, "dota2", "Team A", 0.21)
self.assertFalse(success_fail)
self.assertIn("Не хватает, фраер", message_fail)
@patch('games.casino.economy.get_user_balance')
@patch('games.casino.spin_slots')
@patch('games.casino.economy.update_balance')
def test_casino_exact_balance(self, mock_update, mock_spin, mock_get_user_balance):
# Баланс 0.199999988
mock_get_user_balance.return_value = 0.199999988
mock_spin.return_value = (["🍒", "🍒", "🍇"], 2)
mock_update.return_value = 0.0
# Ставка ровно 0.2 в казино должна быть успешной
result = play_casino(12345, 0.2)
self.assertNotIn("Столько нет, фраер", result)
# Ставка 0.21 при балансе 0.199999988 должна быть отклонена
result_fail = play_casino(12345, 0.21)
self.assertIn("Столько нет, фраер", result_fail)
@patch('games.economy.economy.get_user_balance')
@patch('games.economy.economy.get_setting')
def test_economy_deposit_exact_balance(self, mock_get_setting, mock_get_user_balance):
# Баланс 9.999999 (32-bit float представление 10.0), минимальный вклад 10.0
mock_get_user_balance.return_value = 9.999999
mock_get_setting.side_effect = lambda key: "10.0" if key == "deposit_min_amount" else "7.5"
# Вклад на 10.0 при балансе 9.999999 (эквивалентен 10.0) должен пройти успешно благодаря округлению
success, msg = economy.create_deposit(12345, 10.0, 30)
self.assertTrue(success)
self.assertEqual(msg, "Вклад успешно открыт!")
# Вклад на 10.01 должен быть отклонен из-за нехватки баланса
success_fail, msg_fail = economy.create_deposit(12345, 10.01, 30)
self.assertFalse(success_fail)
self.assertIn("Недостаточно средств", msg_fail)
# Вклад на 9.99 должен быть отклонен из-за минимальной суммы
success_fail2, msg_fail2 = economy.create_deposit(12345, 9.99, 30)
self.assertFalse(success_fail2)
self.assertIn("Минимальная сумма вклада", msg_fail2)
@patch('games.economy.economy.get_user_balance')
def test_economy_transfer_exact_balance(self, mock_get_user_balance):
# При балансе 9.999999 (эквивалентен 10.0) лимит перевода (20%) составляет ровно 2.0.
mock_get_user_balance.return_value = 9.999999
# Перевод ровно 2.0 должен пройти успешно
success = economy.transfer_money(12345, 67890, 2.0, "Test transfer")
self.assertTrue(success)
# Перевод 2.01 должен превысить 20% лимит (2.0) и отклониться
success_fail = economy.transfer_money(12345, 67890, 2.01, "Test transfer")
self.assertFalse(success_fail)
if __name__ == '__main__':
unittest.main()

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")
@ -240,7 +253,9 @@ async def create_bet(
raise HTTPException(status_code=400, detail="Неверный исход ставки") raise HTTPException(status_code=400, detail="Неверный исход ставки")
# 3. Разместить ставку (синхронная функция) # 3. Разместить ставку (синхронная функция)
result = await asyncio.to_thread(place_bet, user_id, match, req.league, team, req.amount) success, result = await asyncio.to_thread(place_bet, user_id, match, req.league, team, req.amount)
if not success:
raise HTTPException(status_code=400, detail=result)
return {"message": result} return {"message": result}
@ -281,7 +296,7 @@ async def play_casino_api(
raise HTTPException(status_code=400, detail=f"Макс ставка: {config.CASINO_MAX_BET} см") raise HTTPException(status_code=400, detail=f"Макс ставка: {config.CASINO_MAX_BET} см")
if req.bet <= 0: if req.bet <= 0:
raise HTTPException(status_code=400, detail="Ставка > 0") raise HTTPException(status_code=400, detail="Ставка > 0")
if req.bet > current: if round(req.bet, 2) > round(current, 2):
raise HTTPException(status_code=400, detail=f"У тебя {current:.1f} см") raise HTTPException(status_code=400, detail=f"У тебя {current:.1f} см")
reels, match_count = await asyncio.to_thread(spin_slots) reels, match_count = await asyncio.to_thread(spin_slots)
@ -479,17 +494,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 +511,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 +562,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;