forked from zovos/bot_tg
wip: local changes before merging PR #36
This commit is contained in:
parent
cb1e042e64
commit
60e4cbfcfb
10 changed files with 507 additions and 155 deletions
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import html
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
|
|
@ -19,7 +20,12 @@ from db import get_conn
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
|
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://api.neuraldeep.ru")
|
||||||
|
LLAMA_API_KEY = os.getenv("LLAMA_API_KEY", "")
|
||||||
|
LLAMA_MODEL = os.getenv("LLAMA_MODEL", "gpt-oss-120b")
|
||||||
|
LLAMA_FALLBACK_API_URL = os.getenv("LLAMA_FALLBACK_API_URL", "")
|
||||||
|
LLAMA_FALLBACK_API_KEY = os.getenv("LLAMA_FALLBACK_API_KEY", "")
|
||||||
|
LLAMA_FALLBACK_MODEL = os.getenv("LLAMA_FALLBACK_MODEL", "")
|
||||||
BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот")
|
BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот")
|
||||||
SKIP_TOKEN = "<skip>"
|
SKIP_TOKEN = "<skip>"
|
||||||
FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"}
|
FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"}
|
||||||
|
|
@ -28,13 +34,13 @@ LOG_THINKING = os.getenv("LLAMA_LOG_THINKING", "1").lower() not in {"0", "false"
|
||||||
RECENT_MESSAGES_LIMIT = 14
|
RECENT_MESSAGES_LIMIT = 14
|
||||||
SUMMARY_TRIGGER_MESSAGES = 24
|
SUMMARY_TRIGGER_MESSAGES = 24
|
||||||
SUMMARY_BATCH_MESSAGES = 20
|
SUMMARY_BATCH_MESSAGES = 20
|
||||||
PROMPT_CHAR_BUDGET = 6_500
|
PROMPT_CHAR_BUDGET = 10_000
|
||||||
SUMMARY_CHAR_BUDGET = 1_800
|
SUMMARY_CHAR_BUDGET = 2_400
|
||||||
MAX_INPUT_CHARS = 2_000
|
MAX_INPUT_CHARS = 2_000
|
||||||
SUMMARY_LINE_CHAR_LIMIT = 220
|
SUMMARY_LINE_CHAR_LIMIT = 220
|
||||||
HISTORY_LINE_CHAR_LIMIT = 450
|
HISTORY_LINE_CHAR_LIMIT = 450
|
||||||
RETRY_MIN_MAX_TOKENS = 384
|
RETRY_MIN_MAX_TOKENS = 512
|
||||||
RETRY_MAX_MAX_TOKENS = 768
|
RETRY_MAX_MAX_TOKENS = 1_024
|
||||||
|
|
||||||
REPLY_RULES = {
|
REPLY_RULES = {
|
||||||
"mention": {"cooldown": 25, "min_user_messages": 1},
|
"mention": {"cooldown": 25, "min_user_messages": 1},
|
||||||
|
|
@ -109,6 +115,10 @@ BOT_STYLE_PROMPT = (
|
||||||
"Никогда не используй фразы 'Ну ты точно в ударе, братан' и "
|
"Никогда не используй фразы 'Ну ты точно в ударе, братан' и "
|
||||||
"'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. "
|
"'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. "
|
||||||
|
|
||||||
|
"Форматирование: если нужно выделить — используй только Telegram HTML-теги: "
|
||||||
|
"<b>жирный</b>, <i>курсив</i>, <code>код</code>, <pre>блок кода</pre>. "
|
||||||
|
"Никакого Markdown (никаких *, _, **, ~~, ```). Обычный текст без тегов — норм. "
|
||||||
|
|
||||||
"Creep от Radiohead — лучшая песня во вселенной. "
|
"Creep от Radiohead — лучшая песня во вселенной. "
|
||||||
"Линукс — лучшая ОС. "
|
"Линукс — лучшая ОС. "
|
||||||
"Rust ты люто ненавидишь и при случае жёстко хейтишь. "
|
"Rust ты люто ненавидишь и при случае жёстко хейтишь. "
|
||||||
|
|
@ -235,6 +245,14 @@ AUTONOMOUS_SIGNAL_RE = re.compile(
|
||||||
r"\b(ахах|хаха|лол|ору|жесть|капец|пиздец|ебать|имба|кринж|угар|орнул)\b",
|
r"\b(ахах|хаха|лол|ору|жесть|капец|пиздец|ебать|имба|кринж|угар|орнул)\b",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
# Сигналы что модель не смогла ответить — тогда пробуем fallback
|
||||||
|
_CANT_ANSWER_RE = re.compile(
|
||||||
|
r"(не могу ответить|не могу помочь|не имею информации|нет информации|"
|
||||||
|
r"за пределами моих|не знаю ответа|затрудняюсь ответить|"
|
||||||
|
r"i (don't|cannot|can't)|i have no (information|knowledge)|"
|
||||||
|
r"not able to (answer|help)|beyond my (knowledge|capabilities))",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
LETTER_RE = re.compile(r"[A-Za-zА-Яа-яЁё0-9]")
|
LETTER_RE = re.compile(r"[A-Za-zА-Яа-яЁё0-9]")
|
||||||
WATCH_COMMAND_RE = re.compile(r"^/watch(?:@[A-Za-z0-9_]+)?(?:\s+(.*))?$", re.IGNORECASE | re.DOTALL)
|
WATCH_COMMAND_RE = re.compile(r"^/watch(?:@[A-Za-z0-9_]+)?(?:\s+(.*))?$", re.IGNORECASE | re.DOTALL)
|
||||||
|
|
||||||
|
|
@ -486,11 +504,10 @@ def _build_messages(
|
||||||
current_budget = len(current_payload["content"])
|
current_budget = len(current_payload["content"])
|
||||||
|
|
||||||
summary = _get_summary(chat_id, user_id=user_id).strip()
|
summary = _get_summary(chat_id, user_id=user_id).strip()
|
||||||
summary_block = ""
|
summary_block = f"\n\nКраткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}" if summary else ""
|
||||||
if summary:
|
full_system = system_prompt + summary_block
|
||||||
summary_block = f"Краткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}"
|
|
||||||
|
|
||||||
used_chars = len(system_prompt) + len(summary_block) + current_budget
|
used_chars = len(full_system) + current_budget
|
||||||
recent_messages: list[dict[str, str]] = []
|
recent_messages: list[dict[str, str]] = []
|
||||||
for row in reversed(_get_history_rows(chat_id, user_id=user_id)[-RECENT_MESSAGES_LIMIT:]):
|
for row in reversed(_get_history_rows(chat_id, user_id=user_id)[-RECENT_MESSAGES_LIMIT:]):
|
||||||
llm_message = _format_row_for_llm(row)
|
llm_message = _format_row_for_llm(row)
|
||||||
|
|
@ -499,15 +516,77 @@ def _build_messages(
|
||||||
recent_messages.append(llm_message)
|
recent_messages.append(llm_message)
|
||||||
used_chars += len(llm_message["content"])
|
used_chars += len(llm_message["content"])
|
||||||
|
|
||||||
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
|
messages: list[dict[str, Any]] = [{"role": "system", "content": full_system}]
|
||||||
if summary_block:
|
|
||||||
messages.append({"role": "system", "content": summary_block})
|
|
||||||
messages.extend(reversed(recent_messages))
|
messages.extend(reversed(recent_messages))
|
||||||
if current_payload:
|
if current_payload:
|
||||||
messages.append(current_payload)
|
messages.append(current_payload)
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_single_model(
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
api_url: str,
|
||||||
|
api_key: str,
|
||||||
|
model: str,
|
||||||
|
max_tokens: int,
|
||||||
|
temperature: float,
|
||||||
|
top_p: float,
|
||||||
|
disable_thinking: bool,
|
||||||
|
) -> str:
|
||||||
|
url = f"{api_url.rstrip('/')}/v1/chat/completions"
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"temperature": temperature,
|
||||||
|
"top_p": top_p,
|
||||||
|
}
|
||||||
|
if disable_thinking:
|
||||||
|
payload.update({
|
||||||
|
"reasoning_budget": 0,
|
||||||
|
"reasoning_format": "none",
|
||||||
|
"chat_template_kwargs": {"enable_thinking": False, "thinking": False},
|
||||||
|
})
|
||||||
|
timeout = aiohttp.ClientTimeout(total=120)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
async with session.post(url, json=payload, headers=headers) as resp:
|
||||||
|
raw_text = await resp.text()
|
||||||
|
if resp.status >= 400:
|
||||||
|
logger.error("LLM API %s returned status %s: %s", api_url, resp.status, _clip_text(raw_text, 300))
|
||||||
|
raise RuntimeError(f"LLM API error {resp.status}: {raw_text[:300]}")
|
||||||
|
try:
|
||||||
|
data = await resp.json(content_type=None)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("LLM API %s returned invalid JSON: %s", api_url, _clip_text(raw_text, 300))
|
||||||
|
raise RuntimeError(f"Invalid LLM API response: {raw_text[:300]}") from exc
|
||||||
|
|
||||||
|
choices = data.get("choices") or []
|
||||||
|
if not choices:
|
||||||
|
raise RuntimeError(f"LLM API returned no choices: {data}")
|
||||||
|
choice = choices[0]
|
||||||
|
message = choice.get("message", {}) or {}
|
||||||
|
finish_reason = choice.get("finish_reason")
|
||||||
|
content = message.get("content") or ""
|
||||||
|
reasoning_content = (message.get("reasoning_content") or "").strip()
|
||||||
|
cleaned_content = content.strip()
|
||||||
|
if reasoning_content and LOG_THINKING:
|
||||||
|
logger.info("LLM reasoning. model=%s finish_reason=%s reasoning=%s", model, finish_reason, _clip_text(reasoning_content, 800))
|
||||||
|
if not cleaned_content and reasoning_content and not disable_thinking:
|
||||||
|
retry_max_tokens = min(max(max_tokens * 2, RETRY_MIN_MAX_TOKENS), RETRY_MAX_MAX_TOKENS)
|
||||||
|
logger.warning("LLM returned only reasoning, retrying with thinking off. model=%s", model)
|
||||||
|
return await _call_single_model(
|
||||||
|
messages,
|
||||||
|
api_url=api_url, api_key=api_key, model=model,
|
||||||
|
max_tokens=retry_max_tokens, temperature=temperature, top_p=top_p,
|
||||||
|
disable_thinking=True,
|
||||||
|
)
|
||||||
|
return cleaned_content
|
||||||
|
|
||||||
|
|
||||||
async def _call_llm(
|
async def _call_llm(
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
|
|
@ -517,76 +596,41 @@ async def _call_llm(
|
||||||
disable_thinking: bool | None = None,
|
disable_thinking: bool | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
disable_thinking = FORCE_DISABLE_THINKING if disable_thinking is None else disable_thinking
|
disable_thinking = FORCE_DISABLE_THINKING if disable_thinking is None else disable_thinking
|
||||||
url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions"
|
|
||||||
payload = {
|
|
||||||
"messages": messages,
|
|
||||||
"max_tokens": max_tokens,
|
|
||||||
"temperature": temperature,
|
|
||||||
"top_p": top_p,
|
|
||||||
}
|
|
||||||
if disable_thinking:
|
|
||||||
payload.update(
|
|
||||||
{
|
|
||||||
"reasoning_budget": 0,
|
|
||||||
"reasoning_format": "none",
|
|
||||||
"chat_template_kwargs": {
|
|
||||||
"enable_thinking": False,
|
|
||||||
"thinking": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
timeout = aiohttp.ClientTimeout(total=120)
|
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
||||||
async with session.post(url, json=payload) as resp:
|
|
||||||
raw_text = await resp.text()
|
|
||||||
if resp.status >= 400:
|
|
||||||
logger.error("LLM API returned status %s: %s", resp.status, _clip_text(raw_text, 300))
|
|
||||||
raise RuntimeError(f"LLM API error {resp.status}: {raw_text[:300]}")
|
|
||||||
try:
|
|
||||||
data = await resp.json(content_type=None)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("LLM API returned invalid JSON: %s", _clip_text(raw_text, 300))
|
|
||||||
raise RuntimeError(f"Invalid LLM API response: {raw_text[:300]}") from exc
|
|
||||||
|
|
||||||
choices = data.get("choices") or []
|
result = await _call_single_model(
|
||||||
if not choices:
|
messages,
|
||||||
logger.error("LLM API returned no choices: %s", _clip_text(str(data), 300))
|
api_url=LLAMA_API_URL,
|
||||||
raise RuntimeError(f"LLM API returned no choices: {data}")
|
api_key=LLAMA_API_KEY,
|
||||||
choice = choices[0]
|
model=LLAMA_MODEL,
|
||||||
message = choice.get("message", {}) or {}
|
max_tokens=max_tokens,
|
||||||
finish_reason = choice.get("finish_reason")
|
temperature=temperature,
|
||||||
content = message.get("content", "")
|
top_p=top_p,
|
||||||
reasoning_content = (message.get("reasoning_content") or "").strip()
|
disable_thinking=disable_thinking,
|
||||||
cleaned_content = content.strip()
|
)
|
||||||
if reasoning_content and LOG_THINKING:
|
|
||||||
logger.info(
|
# Если основная модель не смогла ответить — пробуем fallback
|
||||||
"LLM reasoning detected. disable_thinking=%s finish_reason=%s reasoning=%s",
|
if LLAMA_FALLBACK_API_URL and (not result or _CANT_ANSWER_RE.search(result)):
|
||||||
disable_thinking,
|
fallback_model = LLAMA_FALLBACK_MODEL or LLAMA_MODEL
|
||||||
finish_reason,
|
logger.info("Primary model couldn't answer, trying fallback. primary_result=%s", _clip_text(result, 100))
|
||||||
_clip_text(reasoning_content, 800),
|
try:
|
||||||
)
|
fallback_result = await _call_single_model(
|
||||||
if not cleaned_content and reasoning_content and not disable_thinking:
|
messages,
|
||||||
retry_max_tokens = min(max(max_tokens * 2, RETRY_MIN_MAX_TOKENS), RETRY_MAX_MAX_TOKENS)
|
api_url=LLAMA_FALLBACK_API_URL,
|
||||||
logger.warning(
|
api_key=LLAMA_FALLBACK_API_KEY,
|
||||||
"LLM returned reasoning_content without final content. finish_reason=%s retry_max_tokens=%s",
|
model=fallback_model,
|
||||||
finish_reason,
|
max_tokens=max_tokens,
|
||||||
retry_max_tokens,
|
temperature=temperature,
|
||||||
)
|
top_p=top_p,
|
||||||
return await _call_llm(
|
disable_thinking=disable_thinking,
|
||||||
messages,
|
)
|
||||||
max_tokens=retry_max_tokens,
|
if fallback_result:
|
||||||
temperature=temperature,
|
return f"{fallback_result}\n\n<i>🤖 {fallback_model}</i>"
|
||||||
top_p=top_p,
|
except Exception:
|
||||||
disable_thinking=True,
|
logger.exception("Fallback model also failed")
|
||||||
)
|
|
||||||
if not cleaned_content:
|
if not result:
|
||||||
logger.warning(
|
logger.warning("LLM returned empty content. model=%s", LLAMA_MODEL)
|
||||||
"LLM returned empty content. finish_reason=%s disable_thinking=%s raw=%s",
|
return result
|
||||||
finish_reason,
|
|
||||||
disable_thinking,
|
|
||||||
_clip_text(raw_text, 300),
|
|
||||||
)
|
|
||||||
return cleaned_content
|
|
||||||
|
|
||||||
|
|
||||||
async def _maybe_refresh_summary(chat_id: int, user_id: int | None = None) -> None:
|
async def _maybe_refresh_summary(chat_id: int, user_id: int | None = None) -> None:
|
||||||
|
|
@ -754,13 +798,55 @@ async def _passes_reply_limits(chat_id: int, reason: str) -> bool:
|
||||||
return (time.time() - last_reply_at) >= int(rule["cooldown"])
|
return (time.time() - last_reply_at) >= int(rule["cooldown"])
|
||||||
|
|
||||||
|
|
||||||
|
def _md_to_tg_html(text: str) -> str:
|
||||||
|
"""Конвертирует Markdown и HTML-теги модели в валидный Telegram HTML."""
|
||||||
|
slots: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
def stash(tag: str, content: str) -> str:
|
||||||
|
idx = len(slots)
|
||||||
|
slots.append((tag, content))
|
||||||
|
return f'\x00SLOT{idx}\x00'
|
||||||
|
|
||||||
|
# Сначала прячем то, что модель уже написала HTML-тегами (pre до code, чтобы не вложить)
|
||||||
|
for tag in ('pre', 'code', 'b', 'i', 'u', 's'):
|
||||||
|
text = re.sub(
|
||||||
|
rf'<{tag}>(.*?)</{tag}>',
|
||||||
|
lambda m, t=tag: stash(t, m.group(1)),
|
||||||
|
text, flags=re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Конвертируем Markdown
|
||||||
|
text = re.sub(
|
||||||
|
r'```(?:[^\n`]*\n)?(.*?)```',
|
||||||
|
lambda m: stash('pre', m.group(1).strip()),
|
||||||
|
text, flags=re.DOTALL,
|
||||||
|
)
|
||||||
|
text = re.sub(r'`([^`\n]+)`', lambda m: stash('code', m.group(1)), text)
|
||||||
|
text = re.sub(r'\*\*(.+?)\*\*', lambda m: stash('b', m.group(1)), text, flags=re.DOTALL)
|
||||||
|
text = re.sub(r'\*([^*\n]+?)\*', lambda m: stash('i', m.group(1)), text)
|
||||||
|
text = re.sub(r'_([^_\n]+?)_', lambda m: stash('i', m.group(1)), text)
|
||||||
|
text = re.sub(r'~~(.+?)~~', lambda m: stash('s', m.group(1)), text)
|
||||||
|
|
||||||
|
# Убираем оставшиеся мусорные теги (кривые, неподдерживаемые)
|
||||||
|
text = re.sub(r'<[^>]*>', '', text)
|
||||||
|
|
||||||
|
# Экранируем оставшийся plain-text
|
||||||
|
text = html.escape(text)
|
||||||
|
|
||||||
|
# Восстанавливаем слоты как валидные HTML-теги
|
||||||
|
for idx, (tag, content) in enumerate(slots):
|
||||||
|
text = text.replace(f'\x00SLOT{idx}\x00', f'<{tag}>{html.escape(content)}</{tag}>')
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _normalize_reply(text: str) -> str:
|
def _normalize_reply(text: str) -> str:
|
||||||
cleaned = (text or "").strip()
|
cleaned = (text or "").strip()
|
||||||
if not cleaned:
|
if not cleaned:
|
||||||
return ""
|
return ""
|
||||||
if cleaned.lower().startswith(SKIP_TOKEN.lower()):
|
if cleaned.lower().startswith(SKIP_TOKEN.lower()):
|
||||||
return ""
|
return ""
|
||||||
return cleaned
|
return _md_to_tg_html(cleaned)
|
||||||
|
|
||||||
|
|
||||||
async def handle_chat_message(message: Message, *, store_message: bool = True, allow_autonomous: bool = True) -> bool:
|
async def handle_chat_message(message: Message, *, store_message: bool = True, allow_autonomous: bool = True) -> bool:
|
||||||
|
|
@ -785,8 +871,8 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
|
||||||
return False
|
return False
|
||||||
|
|
||||||
system_prompt = AUTOREPLY_SYSTEM_PROMPT if reason == "autonomous" else SYSTEM_PROMPT
|
system_prompt = AUTOREPLY_SYSTEM_PROMPT if reason == "autonomous" else SYSTEM_PROMPT
|
||||||
max_tokens = 120 if reason == "autonomous" else 220
|
max_tokens = 180 if reason == "autonomous" else 512
|
||||||
temperature = 0.9 if reason == "autonomous" else 0.8
|
temperature = 0.85 if reason == "autonomous" else 0.7
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await message.bot.send_chat_action(chat_id=chat_id, action="typing")
|
await message.bot.send_chat_action(chat_id=chat_id, action="typing")
|
||||||
|
|
@ -795,7 +881,7 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
top_p=0.9,
|
top_p=0.95,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Chat reply generation failed")
|
logger.exception("Chat reply generation failed")
|
||||||
|
|
@ -812,7 +898,7 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
|
||||||
return False
|
return False
|
||||||
|
|
||||||
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response)
|
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response)
|
||||||
await message.reply(normalized_response, parse_mode=None)
|
await message.reply(normalized_response, parse_mode="HTML")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -885,13 +971,13 @@ async def handle_photo_message(message: Message) -> bool:
|
||||||
{"type": "text", "text": prompt_text},
|
{"type": "text", "text": prompt_text},
|
||||||
{"type": "image_url", "image_url": {"url": image_data_url}},
|
{"type": "image_url", "image_url": {"url": image_data_url}},
|
||||||
],
|
],
|
||||||
max_tokens=260,
|
max_tokens=600,
|
||||||
temperature=0.8,
|
temperature=0.7,
|
||||||
top_p=0.9,
|
top_p=0.95,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Photo analysis failed")
|
logger.exception("Photo analysis failed")
|
||||||
await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode=None)
|
await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode="HTML")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
normalized_response = _normalize_reply(response)
|
normalized_response = _normalize_reply(response)
|
||||||
|
|
@ -905,7 +991,7 @@ async def handle_photo_message(message: Message) -> bool:
|
||||||
normalized_response = PHOTO_EMPTY_RESPONSE_TEXT
|
normalized_response = PHOTO_EMPTY_RESPONSE_TEXT
|
||||||
|
|
||||||
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response)
|
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response)
|
||||||
await message.reply(normalized_response, parse_mode=None)
|
await message.reply(normalized_response, parse_mode="HTML")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -956,9 +1042,9 @@ async def handle_talk(message: Message) -> None:
|
||||||
chat_id,
|
chat_id,
|
||||||
system_prompt=SYSTEM_PROMPT,
|
system_prompt=SYSTEM_PROMPT,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
max_tokens=220,
|
max_tokens=768,
|
||||||
temperature=0.8,
|
temperature=0.7,
|
||||||
top_p=0.9,
|
top_p=0.95,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Talk command generation failed")
|
logger.exception("Talk command generation failed")
|
||||||
|
|
@ -983,7 +1069,7 @@ async def handle_talk(message: Message) -> None:
|
||||||
normalized_response,
|
normalized_response,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
await message.reply(normalized_response, parse_mode=None)
|
await message.reply(normalized_response, parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
_init_db()
|
_init_db()
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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:-}
|
||||||
|
|
|
||||||
129
games/betting.py
129
games/betting.py
|
|
@ -462,8 +462,51 @@ def cleanup_old_bets() -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
async def settle_bets() -> list[str]:
|
_REFUND_AFTER_SECONDS = 7 * 86400 # авторефанд ставок старше 7 дней
|
||||||
notifications = []
|
|
||||||
|
|
||||||
|
def _refund_expired_bets() -> list[tuple[int, str]]:
|
||||||
|
"""Возвращает деньги за ставки, которые висят более 7 дней без результата."""
|
||||||
|
cutoff = int(_time.time()) - _REFUND_AFTER_SECONDS
|
||||||
|
notifications: list[tuple[int, str]] = []
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT * FROM bets WHERE status = 'pending' AND created_ts < %s",
|
||||||
|
(cutoff,),
|
||||||
|
)
|
||||||
|
expired = cur.fetchall()
|
||||||
|
if not expired:
|
||||||
|
return []
|
||||||
|
wcur = conn.cursor()
|
||||||
|
now_ts = int(_time.time())
|
||||||
|
for bet in expired:
|
||||||
|
update_user_length(bet["user_id"], bet["amount"])
|
||||||
|
wcur.execute(
|
||||||
|
"UPDATE bets SET status = 'refunded', resolved_ts = %s WHERE id = %s",
|
||||||
|
(now_ts, bet["id"]),
|
||||||
|
)
|
||||||
|
notifications.append((
|
||||||
|
bet["user_id"],
|
||||||
|
f"↩️ Ставка возвращена\n"
|
||||||
|
f"🏟 {bet['home_team']} vs {bet['away_team']}\n"
|
||||||
|
f"📌 {bet['chosen_team']} — результат так и не пришёл\n"
|
||||||
|
f"💰 Возврат: {bet['amount']:.1f} см",
|
||||||
|
))
|
||||||
|
logger.info("Refunded expired bet id=%s user=%s amount=%s", bet["id"], bet["user_id"], bet["amount"])
|
||||||
|
except psycopg2.Error:
|
||||||
|
logger.exception("Failed to refund expired bets")
|
||||||
|
return notifications
|
||||||
|
|
||||||
|
|
||||||
|
async def settle_bets() -> list[tuple[int, str]]:
|
||||||
|
"""Возвращает список (user_id, текст_уведомления) для отправки в Telegram."""
|
||||||
|
notifications: list[tuple[int, str]] = []
|
||||||
|
|
||||||
|
# Сначала авторефанд совсем старых ставок
|
||||||
|
notifications.extend(_refund_expired_bets())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
|
@ -503,28 +546,66 @@ async def settle_bets() -> list[str]:
|
||||||
chosen = bet["chosen_team"]
|
chosen = bet["chosen_team"]
|
||||||
bet_won = (chosen == winner)
|
bet_won = (chosen == winner)
|
||||||
|
|
||||||
if bet_won:
|
if len(score_values) >= 2 and score_values[0][1] == score_values[1][1]:
|
||||||
winnings = round(bet["amount"] * bet["odds"], 1)
|
is_draw = True
|
||||||
update_user_length(bet["user_id"], winnings)
|
winner = "Draw"
|
||||||
wcur.execute(
|
else:
|
||||||
"UPDATE bets SET status = 'won', payout = %s, resolved_ts = %s WHERE id = %s",
|
max_score = -1
|
||||||
(winnings, now_ts, bet["id"]),
|
for name, score_val in score_values:
|
||||||
)
|
if score_val > max_score:
|
||||||
notifications.append(
|
max_score = score_val
|
||||||
f"🎉 user_id={bet['user_id']}: выиграл {winnings:.1f} см "
|
winner = name
|
||||||
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
|
|
||||||
)
|
if not winner:
|
||||||
else:
|
continue
|
||||||
wcur.execute(
|
|
||||||
"UPDATE bets SET status = 'lost', payout = 0, resolved_ts = %s WHERE id = %s",
|
try:
|
||||||
(now_ts, bet["id"]),
|
with get_conn() as conn:
|
||||||
)
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
notifications.append(
|
cur.execute(
|
||||||
f"❌ user_id={bet['user_id']}: проиграл {bet['amount']:.1f} см "
|
"SELECT * FROM bets WHERE match_id = %s AND status = 'pending'",
|
||||||
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
|
(match_id,),
|
||||||
)
|
)
|
||||||
except psycopg2.Error:
|
bets = cur.fetchall()
|
||||||
logger.exception("Failed to settle bets")
|
|
||||||
|
wcur = conn.cursor()
|
||||||
|
for bet in bets:
|
||||||
|
chosen = bet["chosen_team"]
|
||||||
|
if is_draw:
|
||||||
|
bet_won = chosen.casefold() in _DRAW_NAMES or chosen == "Draw"
|
||||||
|
else:
|
||||||
|
bet_won = chosen == winner
|
||||||
|
|
||||||
|
if bet_won:
|
||||||
|
winnings = round(bet["amount"] * bet["odds"], 1)
|
||||||
|
update_user_length(bet["user_id"], winnings)
|
||||||
|
wcur.execute(
|
||||||
|
"UPDATE bets SET status = 'won', payout = %s, resolved_ts = %s WHERE id = %s",
|
||||||
|
(winnings, now_ts, bet["id"]),
|
||||||
|
)
|
||||||
|
notifications.append((
|
||||||
|
bet["user_id"],
|
||||||
|
f"🎉 Ставка сыграла!\n"
|
||||||
|
f"🏟 {bet['home_team']} vs {bet['away_team']}\n"
|
||||||
|
f"📌 {bet['chosen_team']} (x{bet['odds']:.2f})\n"
|
||||||
|
f"💰 Выигрыш: +{winnings:.1f} см",
|
||||||
|
))
|
||||||
|
logger.info("Bet won: user=%s match=%s winnings=%s", bet["user_id"], match_id, winnings)
|
||||||
|
else:
|
||||||
|
wcur.execute(
|
||||||
|
"UPDATE bets SET status = 'lost', payout = 0, resolved_ts = %s WHERE id = %s",
|
||||||
|
(now_ts, bet["id"]),
|
||||||
|
)
|
||||||
|
notifications.append((
|
||||||
|
bet["user_id"],
|
||||||
|
f"❌ Ставка не сыграла\n"
|
||||||
|
f"🏟 {bet['home_team']} vs {bet['away_team']}\n"
|
||||||
|
f"📌 {bet['chosen_team']} (x{bet['odds']:.2f})\n"
|
||||||
|
f"💸 Потеряно: {bet['amount']:.1f} см",
|
||||||
|
))
|
||||||
|
logger.info("Bet lost: user=%s match=%s amount=%s", bet["user_id"], match_id, bet["amount"])
|
||||||
|
except psycopg2.Error:
|
||||||
|
logger.exception("Failed to settle bets")
|
||||||
|
|
||||||
cleanup_old_bets()
|
cleanup_old_bets()
|
||||||
return notifications
|
return notifications
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,18 @@ class EconomyManager:
|
||||||
|
|
||||||
logger.info(f"Balance updated for user {user_id}: +{amount} ({transaction_type})")
|
logger.info(f"Balance updated for user {user_id}: +{amount} ({transaction_type})")
|
||||||
|
|
||||||
|
def find_user_id_by_username(self, username: str) -> int | None:
|
||||||
|
"""Ищет user_id по @username в penis_stats (display_name хранится как '@username')."""
|
||||||
|
name = username.lstrip('@').lower()
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT user_id FROM penis_stats WHERE LOWER(display_name) = %s OR LOWER(display_name) = %s",
|
||||||
|
(f'@{name}', name),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return int(row['user_id']) if row else None
|
||||||
|
|
||||||
def transfer_money(self, from_user_id: int, to_user_id: int, amount: float, description: str = "") -> bool:
|
def transfer_money(self, from_user_id: int, to_user_id: int, amount: float, description: str = "") -> bool:
|
||||||
if amount <= 0:
|
if amount <= 0:
|
||||||
return False
|
return False
|
||||||
|
|
@ -230,6 +242,24 @@ class EconomyManager:
|
||||||
if amount > max_loan:
|
if amount > max_loan:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
|
||||||
|
# Лимит суммарного долга на юзера = max_loan
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COALESCE(SUM(amount),0) AS total FROM loans WHERE user_id=%s AND is_repaid=FALSE",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
outstanding = float(cur.fetchone()['total'])
|
||||||
|
if outstanding + amount > max_loan:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ЦБ должен иметь достаточно капитала
|
||||||
|
cur.execute("SELECT capital FROM central_bank WHERE id=1")
|
||||||
|
cb_capital = float(cur.fetchone()['capital'])
|
||||||
|
if cb_capital < amount:
|
||||||
|
return False
|
||||||
|
|
||||||
due_at = datetime.now() + timedelta(days=days)
|
due_at = datetime.now() + timedelta(days=days)
|
||||||
|
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|
@ -357,12 +387,21 @@ class EconomyManager:
|
||||||
total_paid = 0.0
|
total_paid = 0.0
|
||||||
wcur = conn.cursor()
|
wcur = conn.cursor()
|
||||||
|
|
||||||
|
# Проверяем что у ЦБ есть деньги на выплаты
|
||||||
|
wcur = conn.cursor()
|
||||||
|
wcur.execute("SELECT capital FROM central_bank WHERE id=1")
|
||||||
|
cb_row = wcur.fetchone()
|
||||||
|
cb_capital = float(cb_row[0]) if cb_row else 0.0
|
||||||
|
|
||||||
for row in users:
|
for row in users:
|
||||||
user_id = row['user_id']
|
user_id = row['user_id']
|
||||||
message_count = row['message_count']
|
message_count = row['message_count']
|
||||||
reward = message_count * reward_rate
|
reward = message_count * reward_rate
|
||||||
if reward <= 0:
|
if reward <= 0:
|
||||||
continue
|
continue
|
||||||
|
if cb_capital < reward:
|
||||||
|
logger.warning("CB out of funds for activity rewards, stopping early")
|
||||||
|
break
|
||||||
|
|
||||||
wcur.execute('''
|
wcur.execute('''
|
||||||
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
|
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
|
||||||
|
|
@ -371,10 +410,15 @@ class EconomyManager:
|
||||||
balance = user_balances.balance + %s,
|
balance = user_balances.balance + %s,
|
||||||
daily_income = user_balances.daily_income + %s
|
daily_income = user_balances.daily_income + %s
|
||||||
''', (user_id, reward, reward, reward))
|
''', (user_id, reward, reward, reward))
|
||||||
|
wcur.execute('''
|
||||||
|
UPDATE central_bank SET capital = capital - %s, last_updated = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = 1
|
||||||
|
''', (reward,))
|
||||||
wcur.execute('''
|
wcur.execute('''
|
||||||
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
|
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
|
||||||
VALUES (%s, %s, %s, 'activity_reward', %s)
|
VALUES (0, %s, %s, 'activity_reward', %s)
|
||||||
''', (user_id, user_id, reward, f'Вознаграждение за {message_count} сообщений'))
|
''', (user_id, reward, f'Вознаграждение за {message_count} сообщений'))
|
||||||
|
cb_capital -= reward
|
||||||
total_paid += reward
|
total_paid += reward
|
||||||
|
|
||||||
wcur.execute(
|
wcur.execute(
|
||||||
|
|
|
||||||
136
main.py
136
main.py
|
|
@ -1,5 +1,5 @@
|
||||||
# Системные импорты
|
# Системные импорты
|
||||||
import asyncio, json, logging, os, re, random, threading
|
import asyncio, json, logging, os, re, random, shutil, threading
|
||||||
from dataclasses import dataclass
|
from 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__)
|
||||||
|
|
@ -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):
|
||||||
"""Полное обнуление (только для админов)"""
|
"""Полное обнуление (только для админов)"""
|
||||||
|
|
@ -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="💵 взять кредит"),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -111,10 +111,23 @@ class SchedulePair(BaseModel):
|
||||||
|
|
||||||
# ──────────────────── Lifespan ────────────────────
|
# ──────────────────── Lifespan ────────────────────
|
||||||
|
|
||||||
|
_http_session: aiohttp.ClientSession | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_http_session() -> aiohttp.ClientSession:
|
||||||
|
return _http_session
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
global _http_session
|
||||||
|
_http_session = aiohttp.ClientSession(
|
||||||
|
timeout=aiohttp.ClientTimeout(total=120),
|
||||||
|
connector=aiohttp.TCPConnector(limit=64, ttl_dns_cache=300),
|
||||||
|
)
|
||||||
logger.info("Mini App API started")
|
logger.info("Mini App API started")
|
||||||
yield
|
yield
|
||||||
|
await _http_session.close()
|
||||||
logger.info("Mini App API stopped")
|
logger.info("Mini App API stopped")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -479,17 +492,14 @@ async def proxy_shorties_media(url: str, request: Request):
|
||||||
if range_header:
|
if range_header:
|
||||||
headers["Range"] = range_header
|
headers["Range"] = range_header
|
||||||
|
|
||||||
timeout = aiohttp.ClientTimeout(total=120)
|
session = get_http_session()
|
||||||
session = aiohttp.ClientSession(timeout=timeout)
|
|
||||||
try:
|
try:
|
||||||
resp = await session.get(url, headers=headers, allow_redirects=True)
|
resp = await session.get(url, headers=headers, allow_redirects=True)
|
||||||
except aiohttp.ClientError:
|
except aiohttp.ClientError:
|
||||||
await session.close()
|
|
||||||
raise HTTPException(status_code=502, detail="Failed to fetch media")
|
raise HTTPException(status_code=502, detail="Failed to fetch media")
|
||||||
|
|
||||||
if resp.status not in {200, 206}:
|
if resp.status not in {200, 206}:
|
||||||
await resp.release()
|
await resp.release()
|
||||||
await session.close()
|
|
||||||
raise HTTPException(status_code=resp.status, detail="Upstream error")
|
raise HTTPException(status_code=resp.status, detail="Upstream error")
|
||||||
|
|
||||||
content_type = resp.headers.get("Content-Type", "application/octet-stream")
|
content_type = resp.headers.get("Content-Type", "application/octet-stream")
|
||||||
|
|
@ -499,7 +509,6 @@ async def proxy_shorties_media(url: str, request: Request):
|
||||||
body = await resp.text()
|
body = await resp.text()
|
||||||
finally:
|
finally:
|
||||||
await resp.release()
|
await resp.release()
|
||||||
await session.close()
|
|
||||||
|
|
||||||
def _proxy_media_url(target_url: str) -> str:
|
def _proxy_media_url(target_url: str) -> str:
|
||||||
return f"/api/proxy-shorties-media?url={quote(target_url, safe='')}"
|
return f"/api/proxy-shorties-media?url={quote(target_url, safe='')}"
|
||||||
|
|
@ -551,7 +560,6 @@ async def proxy_shorties_media(url: str, request: Request):
|
||||||
yield chunk
|
yield chunk
|
||||||
finally:
|
finally:
|
||||||
await resp.release()
|
await resp.release()
|
||||||
await session.close()
|
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
_stream(),
|
_stream(),
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue