forked from zovos/bot_tg
Compare commits
No commits in common. "d5698ab76ea68be090e0b94e04419014fdde5ac1" and "e10aa85e6f0aba7e0a6a7c1deee40fe299c83628" have entirely different histories.
d5698ab76e
...
e10aa85e6f
12 changed files with 172 additions and 642 deletions
|
|
@ -2,7 +2,6 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import base64
|
||||
import html
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
|
|
@ -20,12 +19,7 @@ from db import get_conn
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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", "")
|
||||
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
|
||||
BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот")
|
||||
SKIP_TOKEN = "<skip>"
|
||||
FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"}
|
||||
|
|
@ -34,15 +28,13 @@ LOG_THINKING = os.getenv("LLAMA_LOG_THINKING", "1").lower() not in {"0", "false"
|
|||
RECENT_MESSAGES_LIMIT = 14
|
||||
SUMMARY_TRIGGER_MESSAGES = 24
|
||||
SUMMARY_BATCH_MESSAGES = 20
|
||||
PROMPT_CHAR_BUDGET = 10_000
|
||||
SUMMARY_CHAR_BUDGET = 2_400
|
||||
PROMPT_CHAR_BUDGET = 6_500
|
||||
SUMMARY_CHAR_BUDGET = 1_800
|
||||
MAX_INPUT_CHARS = 2_000
|
||||
SUMMARY_LINE_CHAR_LIMIT = 220
|
||||
HISTORY_LINE_CHAR_LIMIT = 450
|
||||
RETRY_MIN_MAX_TOKENS = 512
|
||||
RETRY_MAX_MAX_TOKENS = 1_024
|
||||
TALK_MAX_TOKENS = 1536
|
||||
TALK_THINKING_BUDGET = 1024
|
||||
RETRY_MIN_MAX_TOKENS = 384
|
||||
RETRY_MAX_MAX_TOKENS = 768
|
||||
|
||||
REPLY_RULES = {
|
||||
"mention": {"cooldown": 25, "min_user_messages": 1},
|
||||
|
|
@ -117,10 +109,6 @@ BOT_STYLE_PROMPT = (
|
|||
"Никогда не используй фразы 'Ну ты точно в ударе, братан' и "
|
||||
"'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. "
|
||||
|
||||
"Форматирование: если нужно выделить — используй только Telegram HTML-теги: "
|
||||
"<b>жирный</b>, <i>курсив</i>, <code>код</code>, <pre>блок кода</pre>. "
|
||||
"Никакого Markdown (никаких *, _, **, ~~, ```). Обычный текст без тегов — норм. "
|
||||
|
||||
"Creep от Radiohead — лучшая песня во вселенной. "
|
||||
"Линукс — лучшая ОС. "
|
||||
"Rust ты люто ненавидишь и при случае жёстко хейтишь. "
|
||||
|
|
@ -130,21 +118,10 @@ BOT_STYLE_PROMPT = (
|
|||
|
||||
SYSTEM_PROMPT = (
|
||||
f"{BOT_STYLE_PROMPT}\n\n"
|
||||
"Ты отвечаешь как живой участник Telegram-чата с несколькими разными людьми. "
|
||||
"Сообщения в истории записаны в формате «Имя: текст» — каждое имя это отдельный человек, не путай их. "
|
||||
"Отвечай тому, кто написал последним, учитывай контекст именно его сообщений. "
|
||||
"Ты отвечаешь как живой участник одного Telegram-чата. "
|
||||
"Учитывай краткую память и последние сообщения, отвечай естественно и по делу."
|
||||
)
|
||||
|
||||
TALK_SYSTEM_PROMPT = (
|
||||
f"{BOT_STYLE_PROMPT}\n\n"
|
||||
"Ты ведёшь личный разговор тет-а-тет с одним конкретным человеком через /talk. "
|
||||
"Помни всё, что он говорил раньше — ты видишь историю этого разговора и должен её учитывать. "
|
||||
"Замечай если человек продолжает старую тему или переходит к новой. "
|
||||
"Отвечай КОРОТКО: 1–3 предложения максимум. "
|
||||
"Только если тебя прямо попросили объяснить, развернуть или написать подробно — тогда можно больше."
|
||||
)
|
||||
|
||||
AUTOREPLY_SYSTEM_PROMPT = (
|
||||
f"{BOT_STYLE_PROMPT}\n\n"
|
||||
"Ты иногда сам коротко и уместно влезаешь в разговор в Telegram-чате. "
|
||||
|
|
@ -258,14 +235,6 @@ AUTONOMOUS_SIGNAL_RE = re.compile(
|
|||
r"\b(ахах|хаха|лол|ору|жесть|капец|пиздец|ебать|имба|кринж|угар|орнул)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Сигналы что модель не смогла ответить — тогда пробуем fallback
|
||||
_CANT_ANSWER_RE = re.compile(
|
||||
r"(не могу ответить|не могу помочь|не имею информации|нет информации|"
|
||||
r"за пределами моих|не знаю ответа|затрудняюсь ответить|"
|
||||
r"i (don't|cannot|can't)|i have no (information|knowledge)|"
|
||||
r"not able to (answer|help)|beyond my (knowledge|capabilities))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
LETTER_RE = re.compile(r"[A-Za-zА-Яа-яЁё0-9]")
|
||||
WATCH_COMMAND_RE = re.compile(r"^/watch(?:@[A-Za-z0-9_]+)?(?:\s+(.*))?$", re.IGNORECASE | re.DOTALL)
|
||||
|
||||
|
|
@ -517,10 +486,11 @@ def _build_messages(
|
|||
current_budget = len(current_payload["content"])
|
||||
|
||||
summary = _get_summary(chat_id, user_id=user_id).strip()
|
||||
summary_block = f"\n\nКраткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}" if summary else ""
|
||||
full_system = system_prompt + summary_block
|
||||
summary_block = ""
|
||||
if summary:
|
||||
summary_block = f"Краткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}"
|
||||
|
||||
used_chars = len(full_system) + current_budget
|
||||
used_chars = len(system_prompt) + len(summary_block) + current_budget
|
||||
recent_messages: list[dict[str, str]] = []
|
||||
for row in reversed(_get_history_rows(chat_id, user_id=user_id)[-RECENT_MESSAGES_LIMIT:]):
|
||||
llm_message = _format_row_for_llm(row)
|
||||
|
|
@ -529,80 +499,15 @@ def _build_messages(
|
|||
recent_messages.append(llm_message)
|
||||
used_chars += len(llm_message["content"])
|
||||
|
||||
messages: list[dict[str, Any]] = [{"role": "system", "content": full_system}]
|
||||
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
|
||||
if summary_block:
|
||||
messages.append({"role": "system", "content": summary_block})
|
||||
messages.extend(reversed(recent_messages))
|
||||
if current_payload:
|
||||
messages.append(current_payload)
|
||||
return messages
|
||||
|
||||
|
||||
async def _call_single_model(
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
api_url: str,
|
||||
api_key: str,
|
||||
model: str,
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
top_p: float,
|
||||
disable_thinking: bool,
|
||||
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(
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
|
|
@ -610,46 +515,78 @@ async def _call_llm(
|
|||
temperature: float,
|
||||
top_p: float,
|
||||
disable_thinking: bool | None = None,
|
||||
reasoning_budget: int = 0,
|
||||
) -> str:
|
||||
disable_thinking = FORCE_DISABLE_THINKING if disable_thinking is None else disable_thinking
|
||||
url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions"
|
||||
payload = {
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
"top_p": top_p,
|
||||
}
|
||||
if disable_thinking:
|
||||
payload.update(
|
||||
{
|
||||
"reasoning_budget": 0,
|
||||
"reasoning_format": "none",
|
||||
"chat_template_kwargs": {
|
||||
"enable_thinking": False,
|
||||
"thinking": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
timeout = aiohttp.ClientTimeout(total=120)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(url, json=payload) as resp:
|
||||
raw_text = await resp.text()
|
||||
if resp.status >= 400:
|
||||
logger.error("LLM API returned status %s: %s", resp.status, _clip_text(raw_text, 300))
|
||||
raise RuntimeError(f"LLM API error {resp.status}: {raw_text[:300]}")
|
||||
try:
|
||||
data = await resp.json(content_type=None)
|
||||
except Exception as exc:
|
||||
logger.error("LLM API returned invalid JSON: %s", _clip_text(raw_text, 300))
|
||||
raise RuntimeError(f"Invalid LLM API response: {raw_text[:300]}") from exc
|
||||
|
||||
result = await _call_single_model(
|
||||
messages,
|
||||
api_url=LLAMA_API_URL,
|
||||
api_key=LLAMA_API_KEY,
|
||||
model=LLAMA_MODEL,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
disable_thinking=disable_thinking,
|
||||
reasoning_budget=reasoning_budget,
|
||||
)
|
||||
|
||||
# Если основная модель не смогла ответить — пробуем fallback
|
||||
if LLAMA_FALLBACK_API_URL and (not result or _CANT_ANSWER_RE.search(result)):
|
||||
fallback_model = LLAMA_FALLBACK_MODEL or LLAMA_MODEL
|
||||
logger.info("Primary model couldn't answer, trying fallback. primary_result=%s", _clip_text(result, 100))
|
||||
try:
|
||||
fallback_result = await _call_single_model(
|
||||
messages,
|
||||
api_url=LLAMA_FALLBACK_API_URL,
|
||||
api_key=LLAMA_FALLBACK_API_KEY,
|
||||
model=fallback_model,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
disable_thinking=disable_thinking,
|
||||
reasoning_budget=reasoning_budget,
|
||||
)
|
||||
if fallback_result:
|
||||
return f"{fallback_result}\n\n<i>🤖 {fallback_model}</i>"
|
||||
except Exception:
|
||||
logger.exception("Fallback model also failed")
|
||||
|
||||
if not result:
|
||||
logger.warning("LLM returned empty content. model=%s", LLAMA_MODEL)
|
||||
return result
|
||||
choices = data.get("choices") or []
|
||||
if not choices:
|
||||
logger.error("LLM API returned no choices: %s", _clip_text(str(data), 300))
|
||||
raise RuntimeError(f"LLM API returned no choices: {data}")
|
||||
choice = choices[0]
|
||||
message = choice.get("message", {}) or {}
|
||||
finish_reason = choice.get("finish_reason")
|
||||
content = message.get("content", "")
|
||||
reasoning_content = (message.get("reasoning_content") or "").strip()
|
||||
cleaned_content = content.strip()
|
||||
if reasoning_content and LOG_THINKING:
|
||||
logger.info(
|
||||
"LLM reasoning detected. disable_thinking=%s finish_reason=%s reasoning=%s",
|
||||
disable_thinking,
|
||||
finish_reason,
|
||||
_clip_text(reasoning_content, 800),
|
||||
)
|
||||
if not cleaned_content and reasoning_content and not disable_thinking:
|
||||
retry_max_tokens = min(max(max_tokens * 2, RETRY_MIN_MAX_TOKENS), RETRY_MAX_MAX_TOKENS)
|
||||
logger.warning(
|
||||
"LLM returned reasoning_content without final content. finish_reason=%s retry_max_tokens=%s",
|
||||
finish_reason,
|
||||
retry_max_tokens,
|
||||
)
|
||||
return await _call_llm(
|
||||
messages,
|
||||
max_tokens=retry_max_tokens,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
disable_thinking=True,
|
||||
)
|
||||
if not cleaned_content:
|
||||
logger.warning(
|
||||
"LLM returned empty content. finish_reason=%s disable_thinking=%s raw=%s",
|
||||
finish_reason,
|
||||
disable_thinking,
|
||||
_clip_text(raw_text, 300),
|
||||
)
|
||||
return cleaned_content
|
||||
|
||||
|
||||
async def _maybe_refresh_summary(chat_id: int, user_id: int | None = None) -> None:
|
||||
|
|
@ -715,8 +652,6 @@ async def _generate_response(
|
|||
max_tokens: int,
|
||||
temperature: float,
|
||||
top_p: float,
|
||||
disable_thinking: bool | None = None,
|
||||
reasoning_budget: int = 0,
|
||||
) -> str:
|
||||
await _maybe_refresh_summary(chat_id, user_id=user_id)
|
||||
if current_content is None:
|
||||
|
|
@ -742,8 +677,6 @@ async def _generate_response(
|
|||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
disable_thinking=disable_thinking,
|
||||
reasoning_budget=reasoning_budget,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -821,55 +754,13 @@ async def _passes_reply_limits(chat_id: int, reason: str) -> bool:
|
|||
return (time.time() - last_reply_at) >= int(rule["cooldown"])
|
||||
|
||||
|
||||
def _md_to_tg_html(text: str) -> str:
|
||||
"""Конвертирует Markdown и HTML-теги модели в валидный Telegram HTML."""
|
||||
slots: list[tuple[str, str]] = []
|
||||
|
||||
def stash(tag: str, content: str) -> str:
|
||||
idx = len(slots)
|
||||
slots.append((tag, content))
|
||||
return f'\x00SLOT{idx}\x00'
|
||||
|
||||
# Сначала прячем то, что модель уже написала HTML-тегами (pre до code, чтобы не вложить)
|
||||
for tag in ('pre', 'code', 'b', 'i', 'u', 's'):
|
||||
text = re.sub(
|
||||
rf'<{tag}>(.*?)</{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:
|
||||
cleaned = (text or "").strip()
|
||||
if not cleaned:
|
||||
return ""
|
||||
if cleaned.lower().startswith(SKIP_TOKEN.lower()):
|
||||
return ""
|
||||
return _md_to_tg_html(cleaned)
|
||||
return cleaned
|
||||
|
||||
|
||||
async def handle_chat_message(message: Message, *, store_message: bool = True, allow_autonomous: bool = True) -> bool:
|
||||
|
|
@ -894,8 +785,8 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
|
|||
return False
|
||||
|
||||
system_prompt = AUTOREPLY_SYSTEM_PROMPT if reason == "autonomous" else SYSTEM_PROMPT
|
||||
max_tokens = 180 if reason == "autonomous" else 512
|
||||
temperature = 0.85 if reason == "autonomous" else 0.7
|
||||
max_tokens = 120 if reason == "autonomous" else 220
|
||||
temperature = 0.9 if reason == "autonomous" else 0.8
|
||||
|
||||
try:
|
||||
await message.bot.send_chat_action(chat_id=chat_id, action="typing")
|
||||
|
|
@ -904,7 +795,7 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
|
|||
system_prompt=system_prompt,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
top_p=0.95,
|
||||
top_p=0.9,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Chat reply generation failed")
|
||||
|
|
@ -921,7 +812,7 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
|
|||
return False
|
||||
|
||||
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response)
|
||||
await message.reply(normalized_response, parse_mode="HTML")
|
||||
await message.reply(normalized_response, parse_mode=None)
|
||||
return True
|
||||
|
||||
|
||||
|
|
@ -994,13 +885,13 @@ async def handle_photo_message(message: Message) -> bool:
|
|||
{"type": "text", "text": prompt_text},
|
||||
{"type": "image_url", "image_url": {"url": image_data_url}},
|
||||
],
|
||||
max_tokens=600,
|
||||
temperature=0.7,
|
||||
top_p=0.95,
|
||||
max_tokens=260,
|
||||
temperature=0.8,
|
||||
top_p=0.9,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Photo analysis failed")
|
||||
await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode="HTML")
|
||||
await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode=None)
|
||||
return True
|
||||
|
||||
normalized_response = _normalize_reply(response)
|
||||
|
|
@ -1014,7 +905,7 @@ async def handle_photo_message(message: Message) -> bool:
|
|||
normalized_response = PHOTO_EMPTY_RESPONSE_TEXT
|
||||
|
||||
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response)
|
||||
await message.reply(normalized_response, parse_mode="HTML")
|
||||
await message.reply(normalized_response, parse_mode=None)
|
||||
return True
|
||||
|
||||
|
||||
|
|
@ -1063,13 +954,11 @@ async def handle_talk(message: Message) -> None:
|
|||
await message.bot.send_chat_action(chat_id=chat_id, action="typing")
|
||||
response = await _generate_response(
|
||||
chat_id,
|
||||
system_prompt=TALK_SYSTEM_PROMPT,
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
user_id=user_id,
|
||||
max_tokens=TALK_MAX_TOKENS,
|
||||
temperature=0.7,
|
||||
top_p=0.95,
|
||||
disable_thinking=False,
|
||||
reasoning_budget=TALK_THINKING_BUDGET,
|
||||
max_tokens=220,
|
||||
temperature=0.8,
|
||||
top_p=0.9,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Talk command generation failed")
|
||||
|
|
@ -1094,7 +983,7 @@ async def handle_talk(message: Message) -> None:
|
|||
normalized_response,
|
||||
user_id=user_id,
|
||||
)
|
||||
await message.reply(normalized_response, parse_mode="HTML")
|
||||
await message.reply(normalized_response, parse_mode=None)
|
||||
|
||||
|
||||
_init_db()
|
||||
|
|
|
|||
|
|
@ -4,14 +4,12 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||
PIP_NO_CACHE_DIR=1
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg nodejs && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN python -m pip install --upgrade pip \
|
||||
&& python -m pip install -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /db /app/data
|
||||
RUN mkdir -p /db
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
|
|
|
|||
|
|
@ -61,8 +61,8 @@ services:
|
|||
build:
|
||||
context: ./webapp/frontend
|
||||
ports:
|
||||
- "37.27.192.132:${WEBAPP_HTTP_PORT:-80}:80"
|
||||
- "37.27.192.132:${WEBAPP_HTTPS_PORT:-443}:443"
|
||||
- "${WEBAPP_HTTP_PORT:-80}:80"
|
||||
- "${WEBAPP_HTTPS_PORT:-443}:443"
|
||||
environment:
|
||||
APP_DOMAIN: ${APP_DOMAIN:-}
|
||||
APP_WWW_DOMAIN: ${APP_WWW_DOMAIN:-}
|
||||
|
|
|
|||
101
games/betting.py
101
games/betting.py
|
|
@ -286,26 +286,26 @@ async def get_match_by_index(sport_alias: str, index: int) -> dict | None:
|
|||
return None
|
||||
|
||||
|
||||
def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: float) -> tuple[bool, str]:
|
||||
def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: float) -> str:
|
||||
current = get_user_length(user_id)
|
||||
if current is None:
|
||||
return False, "Сначала заведи счёт через /penis, братуха."
|
||||
return "Сначала заведи счёт через /penis, братуха."
|
||||
if current <= 0:
|
||||
return False, "🚫 С кредитом ставки не принимаем."
|
||||
return "🚫 С кредитом ставки не принимаем."
|
||||
if amount <= 0:
|
||||
return False, "Ставка должна быть больше нуля."
|
||||
return "Ставка должна быть больше нуля."
|
||||
if amount > config.BET_MAX_AMOUNT:
|
||||
return False, f"Максимальная ставка — {config.BET_MAX_AMOUNT} см."
|
||||
if round(amount, 2) > round(current, 2):
|
||||
return False, f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер."
|
||||
return f"Максимальная ставка — {config.BET_MAX_AMOUNT} см."
|
||||
if amount > current:
|
||||
return f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер."
|
||||
|
||||
team_odds = match["odds"].get(chosen_team)
|
||||
if team_odds is None:
|
||||
return False, f"Исход не найден. Доступные: {describe_match_outcomes(match)}"
|
||||
return f"Исход не найден. Доступные: {describe_match_outcomes(match)}"
|
||||
|
||||
new_length = update_user_length(user_id, -amount)
|
||||
if new_length is None:
|
||||
return False, "Ошибка БД."
|
||||
return "Ошибка БД."
|
||||
|
||||
now_ts = int(_time.time())
|
||||
try:
|
||||
|
|
@ -323,11 +323,12 @@ def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: f
|
|||
except psycopg2.Error:
|
||||
logger.exception("Failed to place bet")
|
||||
update_user_length(user_id, amount)
|
||||
return False, "Ошибка БД."
|
||||
return "Ошибка БД."
|
||||
|
||||
potential = round(amount * team_odds, 1)
|
||||
return True, (
|
||||
return (
|
||||
f"✅ Ставка принята!\n"
|
||||
f"🏟 {match['home']} vs {match['away']}\n"
|
||||
f"📌 {chosen_team} (x{team_odds:.2f})\n"
|
||||
f"💰 Ставка: {amount:.1f} см\n"
|
||||
f"🎯 Возможный выигрыш: {potential:.1f} см\n"
|
||||
|
|
@ -461,51 +462,8 @@ def cleanup_old_bets() -> int:
|
|||
return 0
|
||||
|
||||
|
||||
_REFUND_AFTER_SECONDS = 7 * 86400 # авторефанд ставок старше 7 дней
|
||||
|
||||
|
||||
def _refund_expired_bets() -> list[tuple[int, str]]:
|
||||
"""Возвращает деньги за ставки, которые висят более 7 дней без результата."""
|
||||
cutoff = int(_time.time()) - _REFUND_AFTER_SECONDS
|
||||
notifications: list[tuple[int, str]] = []
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
"SELECT * FROM bets WHERE status = 'pending' AND created_ts < %s",
|
||||
(cutoff,),
|
||||
)
|
||||
expired = cur.fetchall()
|
||||
if not expired:
|
||||
return []
|
||||
wcur = conn.cursor()
|
||||
now_ts = int(_time.time())
|
||||
for bet in expired:
|
||||
update_user_length(bet["user_id"], bet["amount"])
|
||||
wcur.execute(
|
||||
"UPDATE bets SET status = 'refunded', resolved_ts = %s WHERE id = %s",
|
||||
(now_ts, bet["id"]),
|
||||
)
|
||||
notifications.append((
|
||||
bet["user_id"],
|
||||
f"↩️ Ставка возвращена\n"
|
||||
f"🏟 {bet['home_team']} vs {bet['away_team']}\n"
|
||||
f"📌 {bet['chosen_team']} — результат так и не пришёл\n"
|
||||
f"💰 Возврат: {bet['amount']:.1f} см",
|
||||
))
|
||||
logger.info("Refunded expired bet id=%s user=%s amount=%s", bet["id"], bet["user_id"], bet["amount"])
|
||||
except psycopg2.Error:
|
||||
logger.exception("Failed to refund expired bets")
|
||||
return notifications
|
||||
|
||||
|
||||
async def settle_bets() -> list[tuple[int, str]]:
|
||||
"""Возвращает список (user_id, текст_уведомления) для отправки в Telegram."""
|
||||
notifications: list[tuple[int, str]] = []
|
||||
|
||||
# Сначала авторефанд совсем старых ставок
|
||||
notifications.extend(_refund_expired_bets())
|
||||
|
||||
async def settle_bets() -> list[str]:
|
||||
notifications = []
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
|
|
@ -531,8 +489,6 @@ async def settle_bets() -> list[tuple[int, str]]:
|
|||
if not winner:
|
||||
continue
|
||||
|
||||
is_draw = (winner.casefold() in _DRAW_NAMES or winner == "Draw")
|
||||
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
|
|
@ -545,10 +501,7 @@ async def settle_bets() -> list[tuple[int, str]]:
|
|||
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
|
||||
bet_won = (chosen == winner)
|
||||
|
||||
if bet_won:
|
||||
winnings = round(bet["amount"] * bet["odds"], 1)
|
||||
|
|
@ -557,27 +510,19 @@ async def settle_bets() -> list[tuple[int, str]]:
|
|||
"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)
|
||||
notifications.append(
|
||||
f"🎉 user_id={bet['user_id']}: выиграл {winnings:.1f} см "
|
||||
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
|
||||
)
|
||||
else:
|
||||
wcur.execute(
|
||||
"UPDATE bets SET status = 'lost', payout = 0, resolved_ts = %s WHERE id = %s",
|
||||
(now_ts, bet["id"]),
|
||||
)
|
||||
notifications.append((
|
||||
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"])
|
||||
notifications.append(
|
||||
f"❌ user_id={bet['user_id']}: проиграл {bet['amount']:.1f} см "
|
||||
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
|
||||
)
|
||||
except psycopg2.Error:
|
||||
logger.exception("Failed to settle bets")
|
||||
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ def play_casino(user_id: int, bet: float) -> str:
|
|||
return f"Максимальная ставка — {config.CASINO_MAX_BET} см, не жадничай."
|
||||
if bet <= 0:
|
||||
return "Ставка должна быть больше нуля, фраер."
|
||||
if round(bet, 2) > round(current, 2):
|
||||
if bet > current:
|
||||
return f"У тебя {current:.1f} см, а ставишь {bet:.1f}. Столько нет, фраер."
|
||||
|
||||
reels, matches = spin_slots()
|
||||
|
|
|
|||
|
|
@ -162,25 +162,12 @@ class EconomyManager:
|
|||
|
||||
logger.info(f"Balance updated for user {user_id}: +{amount} ({transaction_type})")
|
||||
|
||||
def find_user_id_by_username(self, username: str) -> int | None:
|
||||
"""Ищет user_id по @username в penis_stats (display_name хранится как '@username')."""
|
||||
name = username.lstrip('@').lower()
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
"SELECT user_id FROM penis_stats WHERE LOWER(display_name) = %s OR LOWER(display_name) = %s",
|
||||
(f'@{name}', name),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return int(row['user_id']) if row else None
|
||||
|
||||
def transfer_money(self, from_user_id: int, to_user_id: int, amount: float, description: str = "") -> bool:
|
||||
if amount <= 0:
|
||||
return False
|
||||
|
||||
from_balance = self.get_user_balance(from_user_id)
|
||||
limit = round(from_balance * 0.2, 2)
|
||||
if round(amount, 2) > limit or round(from_balance, 2) < round(amount, 2):
|
||||
if amount > from_balance * 0.2 or from_balance < amount:
|
||||
return False
|
||||
|
||||
with get_conn() as conn:
|
||||
|
|
@ -202,17 +189,15 @@ class EconomyManager:
|
|||
logger.info(f"Transfer: {amount} from user {from_user_id} to user {to_user_id}")
|
||||
return True
|
||||
|
||||
def create_deposit(self, user_id: int, amount: float, days: int) -> tuple[bool, str]:
|
||||
def create_deposit(self, user_id: int, amount: float, days: int) -> bool:
|
||||
if amount <= 0:
|
||||
return False, "Сумма вклада должна быть больше нуля."
|
||||
return False
|
||||
|
||||
balance = self.get_user_balance(user_id)
|
||||
min_deposit = float(self.get_setting('deposit_min_amount'))
|
||||
|
||||
if round(amount, 2) < round(min_deposit, 2):
|
||||
return False, f"Минимальная сумма вклада — {min_deposit:.1f} см. Твой баланс: {balance:.1f} см."
|
||||
if round(balance, 2) < round(amount, 2):
|
||||
return False, f"Недостаточно средств. Твой баланс: {balance:.1f} см, а сумма вклада: {amount:.1f} см."
|
||||
if amount < min_deposit or balance < amount:
|
||||
return False
|
||||
|
||||
central_bank_rate = float(self.get_setting('central_bank_rate'))
|
||||
matures_at = datetime.now() + timedelta(days=days)
|
||||
|
|
@ -233,7 +218,7 @@ class EconomyManager:
|
|||
''', (user_id, amount, central_bank_rate, matures_at))
|
||||
|
||||
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:
|
||||
if amount <= 0:
|
||||
|
|
@ -242,27 +227,9 @@ class EconomyManager:
|
|||
max_loan = float(self.get_setting('loan_max_amount'))
|
||||
loan_rate = float(self.get_setting('loan_interest_rate'))
|
||||
|
||||
if round(amount, 2) > round(max_loan, 2):
|
||||
if amount > max_loan:
|
||||
return False
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
|
||||
# Лимит суммарного долга на юзера = max_loan
|
||||
cur.execute(
|
||||
"SELECT COALESCE(SUM(amount),0) AS total FROM loans WHERE user_id=%s AND is_repaid=FALSE",
|
||||
(user_id,),
|
||||
)
|
||||
outstanding = float(cur.fetchone()['total'])
|
||||
if 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)
|
||||
|
||||
with get_conn() as conn:
|
||||
|
|
@ -296,10 +263,10 @@ class EconomyManager:
|
|||
if not loan or loan['is_repaid']:
|
||||
return False
|
||||
|
||||
total_repayment = round(loan['amount'] * (1 + loan['interest_rate'] / 100), 2)
|
||||
total_repayment = loan['amount'] * (1 + loan['interest_rate'] / 100)
|
||||
|
||||
balance = self.get_user_balance(user_id)
|
||||
if round(balance, 2) < total_repayment:
|
||||
if balance < total_repayment:
|
||||
return False
|
||||
|
||||
wcur = conn.cursor()
|
||||
|
|
@ -390,21 +357,12 @@ class EconomyManager:
|
|||
total_paid = 0.0
|
||||
wcur = conn.cursor()
|
||||
|
||||
# Проверяем что у ЦБ есть деньги на выплаты
|
||||
wcur = conn.cursor()
|
||||
wcur.execute("SELECT capital FROM central_bank WHERE id=1")
|
||||
cb_row = wcur.fetchone()
|
||||
cb_capital = float(cb_row[0]) if cb_row else 0.0
|
||||
|
||||
for row in users:
|
||||
user_id = row['user_id']
|
||||
message_count = row['message_count']
|
||||
reward = message_count * reward_rate
|
||||
if reward <= 0:
|
||||
continue
|
||||
if cb_capital < reward:
|
||||
logger.warning("CB out of funds for activity rewards, stopping early")
|
||||
break
|
||||
|
||||
wcur.execute('''
|
||||
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
|
||||
|
|
@ -413,15 +371,10 @@ class EconomyManager:
|
|||
balance = user_balances.balance + %s,
|
||||
daily_income = user_balances.daily_income + %s
|
||||
''', (user_id, reward, reward, reward))
|
||||
wcur.execute('''
|
||||
UPDATE central_bank SET capital = capital - %s, last_updated = CURRENT_TIMESTAMP
|
||||
WHERE id = 1
|
||||
''', (reward,))
|
||||
wcur.execute('''
|
||||
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
|
||||
VALUES (0, %s, %s, 'activity_reward', %s)
|
||||
''', (user_id, reward, f'Вознаграждение за {message_count} сообщений'))
|
||||
cb_capital -= reward
|
||||
VALUES (%s, %s, %s, 'activity_reward', %s)
|
||||
''', (user_id, user_id, reward, f'Вознаграждение за {message_count} сообщений'))
|
||||
total_paid += reward
|
||||
|
||||
wcur.execute(
|
||||
|
|
@ -482,7 +435,7 @@ class EconomyManager:
|
|||
logger.info(f"Central bank updated: {capital_change} ({transaction_type})")
|
||||
|
||||
def emit_money(self, amount: float, reason: str = "") -> bool:
|
||||
if round(self.get_central_bank_stats()['capital'], 2) < round(amount, 2):
|
||||
if self.get_central_bank_stats()['capital'] < amount:
|
||||
return False
|
||||
self.update_central_bank(-amount, 'emission', reason)
|
||||
logger.info(f"Money emitted: {amount} cm ({reason})")
|
||||
|
|
|
|||
132
main.py
132
main.py
|
|
@ -1,5 +1,5 @@
|
|||
# Системные импорты
|
||||
import asyncio, json, logging, os, re, random, shutil, threading
|
||||
import asyncio, json, logging, os, re, random, threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, time, timedelta
|
||||
from io import BytesIO
|
||||
|
|
@ -41,7 +41,6 @@ from games.betting import (
|
|||
)
|
||||
from games.economy import economy
|
||||
from zparser import get_military_data
|
||||
from downloader import download_video, COOKIES_PATH
|
||||
import config
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
|
@ -1263,12 +1262,12 @@ async def handle_deposit_cmd(message: Message):
|
|||
await message.reply("Сумма и дни должны быть положительными.", parse_mode=None)
|
||||
return
|
||||
|
||||
success, error_msg = await asyncio.to_thread(economy.create_deposit, message.from_user.id, amount, days)
|
||||
success = await asyncio.to_thread(economy.create_deposit, message.from_user.id, amount, days)
|
||||
if success:
|
||||
rate = economy.get_setting('central_bank_rate')
|
||||
await message.reply(f"✅ Вклад открыт!\n💰 Сумма: {amount:.1f} см\n📅 Срок: {days} дней\n📈 Ставка: {rate}% годовых", parse_mode=None)
|
||||
else:
|
||||
await message.reply(f"❌ Не удалось открыть вклад. {error_msg}", parse_mode=None)
|
||||
await message.reply("❌ Не удалось открыть вклад. Проверь баланс и минимальную сумму.", parse_mode=None)
|
||||
|
||||
async def handle_loans_cmd(message: Message):
|
||||
"""Показать кредиты"""
|
||||
|
|
@ -1321,7 +1320,7 @@ async def handle_transfer_cmd(message: Message):
|
|||
|
||||
username = parts[1].lstrip('@')
|
||||
try:
|
||||
amount = round(float(parts[2].replace(",", ".")), 1)
|
||||
amount = float(parts[2].replace(",", "."))
|
||||
except ValueError:
|
||||
await message.reply("Сумма должна быть числом.", parse_mode=None)
|
||||
return
|
||||
|
|
@ -1330,31 +1329,9 @@ async def handle_transfer_cmd(message: Message):
|
|||
await message.reply("Сумма должна быть положительной.", parse_mode=None)
|
||||
return
|
||||
|
||||
to_user_id = await asyncio.to_thread(economy.find_user_id_by_username, username)
|
||||
if not to_user_id:
|
||||
await message.reply(f"Пользователь @{username} не найден. Он должен хотя бы раз сыграть в /penis.", parse_mode=None)
|
||||
return
|
||||
|
||||
if to_user_id == message.from_user.id:
|
||||
await message.reply("Себе переводить нельзя.", parse_mode=None)
|
||||
return
|
||||
|
||||
success = await asyncio.to_thread(
|
||||
economy.transfer_money, message.from_user.id, to_user_id, amount, f"transfer to @{username}"
|
||||
)
|
||||
if success:
|
||||
my_balance = economy.get_user_balance(message.from_user.id)
|
||||
await message.reply(
|
||||
f"✅ Переведено {amount:.1f} см → @{username}\nТвой баланс: {my_balance:.1f} см",
|
||||
parse_mode=None,
|
||||
)
|
||||
else:
|
||||
my_balance = economy.get_user_balance(message.from_user.id)
|
||||
limit = round(my_balance * 0.2, 1)
|
||||
await message.reply(
|
||||
f"❌ Не удалось перевести. Баланс: {my_balance:.1f} см, лимит одного перевода: {limit:.1f} см (20%).",
|
||||
parse_mode=None,
|
||||
)
|
||||
# Здесь нужно найти user_id по username
|
||||
# Для простоты примера, пока покажем сообщение об ошибке
|
||||
await message.reply("🔍 Поиск пользователя...\n(функция поиска пользователей будет добавлена)", parse_mode=None)
|
||||
|
||||
async def handle_vanomasa_cmd(message: Message):
|
||||
"""Полное обнуление (только для админов)"""
|
||||
|
|
@ -1562,7 +1539,7 @@ async def handle_bet_cmd(message: Message):
|
|||
)
|
||||
return
|
||||
|
||||
success, result = await asyncio.to_thread(place_bet, message.from_user.id, match, sport_alias, team, amount)
|
||||
result = await asyncio.to_thread(place_bet, message.from_user.id, match, sport_alias, team, amount)
|
||||
await message.reply(result, parse_mode=None)
|
||||
|
||||
async def handle_mybets_cmd(message: Message):
|
||||
|
|
@ -1595,98 +1572,13 @@ async def handle_sports_debug_cmd(message: Message):
|
|||
result = await debug_sports()
|
||||
await message.reply(result, parse_mode=None)
|
||||
|
||||
_YT_URL_RE = re.compile(
|
||||
r"https?://(?:www\.)?(?:youtube\.com/(?:watch|shorts|live)|youtu\.be)/\S+",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
async def handle_dow_cmd(message: Message, bot: Bot):
|
||||
parts = (message.text or "").split(maxsplit=1)
|
||||
|
||||
# Если /dow без URL — пробуем взять ссылку из сообщения-источника (reply)
|
||||
url = ""
|
||||
if len(parts) >= 2:
|
||||
url = parts[1].strip()
|
||||
elif message.reply_to_message:
|
||||
src_text = message.reply_to_message.text or message.reply_to_message.caption or ""
|
||||
m = _YT_URL_RE.search(src_text)
|
||||
if m:
|
||||
url = m.group(0)
|
||||
|
||||
if not url:
|
||||
await message.reply("Формат: /dow <ссылка>\nИли ответь на сообщение с YouTube-ссылкой командой /dow")
|
||||
return
|
||||
|
||||
status = await message.reply("⏳ Скачиваю...")
|
||||
file_path, title, error = await download_video(url)
|
||||
|
||||
if error:
|
||||
await status.edit_text(f"❌ Ошибка скачивания:\n{error[:500]}")
|
||||
return
|
||||
|
||||
try:
|
||||
size_mb = file_path.stat().st_size / (1024 * 1024)
|
||||
if size_mb > 50:
|
||||
await status.edit_text(f"❌ Видео весит {size_mb:.1f} МБ — Telegram не позволяет отправить >50 МБ")
|
||||
return
|
||||
|
||||
await status.edit_text("📤 Отправляю...")
|
||||
target = message.reply_to_message or message
|
||||
await target.reply_video(
|
||||
FSInputFile(str(file_path)),
|
||||
caption=title[:1024],
|
||||
supports_streaming=True,
|
||||
)
|
||||
await status.delete()
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to send downloaded video")
|
||||
await status.edit_text(f"❌ Не смог отправить: {exc}")
|
||||
finally:
|
||||
shutil.rmtree(str(file_path.parent), ignore_errors=True)
|
||||
|
||||
|
||||
async def handle_setcookies_cmd(message: Message, bot: Bot):
|
||||
"""Загрузить файл кук для YouTube (только в личке, ответом на документ)."""
|
||||
if message.chat.type != "private":
|
||||
await message.reply("Куки можно загрузить только в личных сообщениях с ботом.")
|
||||
return
|
||||
|
||||
doc = None
|
||||
if message.document:
|
||||
doc = message.document
|
||||
elif message.reply_to_message and message.reply_to_message.document:
|
||||
doc = message.reply_to_message.document
|
||||
|
||||
if not doc:
|
||||
await message.reply(
|
||||
"Пришли файл cookies (Netscape/txt) в этот чат с подписью /setcookies "
|
||||
"или ответь на сообщение с файлом командой /setcookies"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
COOKIES_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_info = await bot.get_file(doc.file_id)
|
||||
buf = BytesIO()
|
||||
await bot.download_file(file_info.file_path, destination=buf)
|
||||
buf.seek(0)
|
||||
COOKIES_PATH.write_bytes(buf.read())
|
||||
await message.reply(f"✅ Куки сохранены ({doc.file_size or '?'} байт). yt-dlp будет их использовать.")
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to save cookies")
|
||||
await message.reply(f"❌ Не удалось сохранить: {exc}")
|
||||
|
||||
|
||||
async def _settle_loop(bot: Bot):
|
||||
while True:
|
||||
await asyncio.sleep(1800)
|
||||
try:
|
||||
notifications = await settle_bets()
|
||||
for user_id, text in notifications:
|
||||
try:
|
||||
await bot.send_message(user_id, text)
|
||||
except Exception:
|
||||
logger.warning("Could not notify user %s about bet result", user_id)
|
||||
for note in notifications:
|
||||
logger.info(note)
|
||||
except Exception:
|
||||
logger.exception("settle_bets failed")
|
||||
|
||||
|
|
@ -1727,8 +1619,6 @@ def main():
|
|||
dp.message.register(handle_gen_mem, Command("gen_mem"))
|
||||
dp.message.register(handle_uwu_cmd, Command("uwu"))
|
||||
dp.message.register(handle_nude_cmd, Command("nude"))
|
||||
dp.message.register(handle_dow_cmd, Command("dow"))
|
||||
dp.message.register(handle_setcookies_cmd, Command("setcookies"))
|
||||
dp.message.register(handle_photo_message, _is_watch_image_message)
|
||||
|
||||
# Экономические команды
|
||||
|
|
@ -1775,8 +1665,6 @@ def main():
|
|||
BotCommand(command="svodka", description="СВО: итоги"),
|
||||
BotCommand(command="uwu", description="Случайная картинка с e621"),
|
||||
BotCommand(command="nude", description="Голые женщины из открытых источников"),
|
||||
BotCommand(command="dow", description="Скачать видео по ссылке"),
|
||||
BotCommand(command="setcookies", description="Загрузить куки YouTube (личка)"),
|
||||
BotCommand(command="balance", description="💰 баланс и статистика"),
|
||||
BotCommand(command="deposit", description="💎 открыть вклад"),
|
||||
BotCommand(command="loan", description="💵 взять кредит"),
|
||||
|
|
|
|||
|
|
@ -6,4 +6,3 @@ tzdata>=2024.1
|
|||
beautifulsoup4
|
||||
lxml
|
||||
psycopg2-binary>=2.9.9
|
||||
yt-dlp>=2024.1.0
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
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()
|
||||
|
|
@ -111,23 +111,10 @@ class SchedulePair(BaseModel):
|
|||
|
||||
# ──────────────────── Lifespan ────────────────────
|
||||
|
||||
_http_session: aiohttp.ClientSession | None = None
|
||||
|
||||
|
||||
def get_http_session() -> aiohttp.ClientSession:
|
||||
return _http_session
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global _http_session
|
||||
_http_session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=120),
|
||||
connector=aiohttp.TCPConnector(limit=64, ttl_dns_cache=300),
|
||||
)
|
||||
logger.info("Mini App API started")
|
||||
yield
|
||||
await _http_session.close()
|
||||
logger.info("Mini App API stopped")
|
||||
|
||||
|
||||
|
|
@ -253,9 +240,7 @@ async def create_bet(
|
|||
raise HTTPException(status_code=400, detail="Неверный исход ставки")
|
||||
|
||||
# 3. Разместить ставку (синхронная функция)
|
||||
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)
|
||||
result = await asyncio.to_thread(place_bet, user_id, match, req.league, team, req.amount)
|
||||
return {"message": result}
|
||||
|
||||
|
||||
|
|
@ -296,7 +281,7 @@ async def play_casino_api(
|
|||
raise HTTPException(status_code=400, detail=f"Макс ставка: {config.CASINO_MAX_BET} см")
|
||||
if req.bet <= 0:
|
||||
raise HTTPException(status_code=400, detail="Ставка > 0")
|
||||
if round(req.bet, 2) > round(current, 2):
|
||||
if req.bet > current:
|
||||
raise HTTPException(status_code=400, detail=f"У тебя {current:.1f} см")
|
||||
|
||||
reels, match_count = await asyncio.to_thread(spin_slots)
|
||||
|
|
@ -494,14 +479,17 @@ async def proxy_shorties_media(url: str, request: Request):
|
|||
if range_header:
|
||||
headers["Range"] = range_header
|
||||
|
||||
session = get_http_session()
|
||||
timeout = aiohttp.ClientTimeout(total=120)
|
||||
session = aiohttp.ClientSession(timeout=timeout)
|
||||
try:
|
||||
resp = await session.get(url, headers=headers, allow_redirects=True)
|
||||
except aiohttp.ClientError:
|
||||
await session.close()
|
||||
raise HTTPException(status_code=502, detail="Failed to fetch media")
|
||||
|
||||
if resp.status not in {200, 206}:
|
||||
await resp.release()
|
||||
await session.close()
|
||||
raise HTTPException(status_code=resp.status, detail="Upstream error")
|
||||
|
||||
content_type = resp.headers.get("Content-Type", "application/octet-stream")
|
||||
|
|
@ -511,6 +499,7 @@ async def proxy_shorties_media(url: str, request: Request):
|
|||
body = await resp.text()
|
||||
finally:
|
||||
await resp.release()
|
||||
await session.close()
|
||||
|
||||
def _proxy_media_url(target_url: str) -> str:
|
||||
return f"/api/proxy-shorties-media?url={quote(target_url, safe='')}"
|
||||
|
|
@ -562,6 +551,7 @@ async def proxy_shorties_media(url: str, request: Request):
|
|||
yield chunk
|
||||
finally:
|
||||
await resp.release()
|
||||
await session.close()
|
||||
|
||||
return StreamingResponse(
|
||||
_stream(),
|
||||
|
|
|
|||
|
|
@ -34,8 +34,7 @@ const navBtns = document.querySelectorAll('.nav-btn');
|
|||
// ── Router ──
|
||||
function navigate(page, data = null) {
|
||||
document.querySelectorAll('.furtok-card video').forEach((video) => {
|
||||
_videoObserver.unobserve(video);
|
||||
if (video._hlsInstance) {
|
||||
if (video && video._hlsInstance) {
|
||||
try { video._hlsInstance.destroy(); } catch {}
|
||||
video._hlsInstance = null;
|
||||
}
|
||||
|
|
@ -84,10 +83,6 @@ function escapeHtml(value) {
|
|||
|
||||
function initShortiesVideoPlayback(video) {
|
||||
if (!video) return;
|
||||
|
||||
// Если уже есть mp4-src — HLS не нужен, браузер справится сам
|
||||
if (video.src && !video.src.endsWith('#')) return;
|
||||
|
||||
const hlsSrc = video.dataset.hlsSrc || '';
|
||||
if (!hlsSrc) return;
|
||||
|
||||
|
|
@ -99,10 +94,9 @@ function initShortiesVideoPlayback(video) {
|
|||
|
||||
if (window.Hls && window.Hls.isSupported()) {
|
||||
const hls = new window.Hls({
|
||||
maxBufferLength: 15,
|
||||
backBufferLength: 5,
|
||||
maxBufferLength: 30,
|
||||
backBufferLength: 30,
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
});
|
||||
hls.loadSource(hlsSrc);
|
||||
hls.attachMedia(video);
|
||||
|
|
@ -110,23 +104,6 @@ function initShortiesVideoPlayback(video) {
|
|||
}
|
||||
}
|
||||
|
||||
// IntersectionObserver: играем только видимое видео, паузим скрытые
|
||||
const _videoObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
const video = entry.target;
|
||||
if (entry.isIntersecting) {
|
||||
video.play().catch(() => {});
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.6 });
|
||||
|
||||
function observeVideo(video) {
|
||||
video.removeAttribute('autoplay');
|
||||
_videoObserver.observe(video);
|
||||
}
|
||||
|
||||
function getShortiesShareUrl(post) {
|
||||
const source = String(post?.source || '').trim();
|
||||
if (source) return source;
|
||||
|
|
@ -921,7 +898,7 @@ async function loadFurtokPage(feedEl) {
|
|||
const useHls = isShorties && !directMp4 && Boolean(post.hls_url);
|
||||
const videoSrc = useHls ? '' : proxyMp4;
|
||||
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 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 autoplay playsinline preload="metadata" muted></video>`;
|
||||
}
|
||||
|
||||
const title = post.title ? escapeHtml(post.title) : '';
|
||||
|
|
@ -966,7 +943,6 @@ async function loadFurtokPage(feedEl) {
|
|||
if (video.paused) video.play();
|
||||
else video.pause();
|
||||
});
|
||||
observeVideo(video);
|
||||
}
|
||||
|
||||
const shareBtn = card.querySelector('.furtok-share-btn');
|
||||
|
|
@ -990,6 +966,14 @@ async function loadFurtokPage(feedEl) {
|
|||
furtokCards.push(card);
|
||||
});
|
||||
|
||||
// Автоплей первого видео при первой загрузке
|
||||
if (furtokCurrentIndex === 0 && furtokCards.length > 0) {
|
||||
const firstVideo = furtokCards[0].querySelector('video');
|
||||
if (firstVideo) {
|
||||
firstVideo.muted = true;
|
||||
firstVideo.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
haptic('success');
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -32,9 +32,7 @@ server {
|
|||
}
|
||||
|
||||
location /api/ {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $api http://webapp-api:8080;
|
||||
proxy_pass $api;
|
||||
proxy_pass http://webapp-api:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
|
|
|
|||
Loading…
Reference in a new issue