1
0
Fork 0
forked from zovos/bot_tg

wip: local changes before merging PR #36

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

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import base64
import html
import logging
import mimetypes
import os
@ -19,7 +20,12 @@ from db import get_conn
logger = logging.getLogger(__name__)
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://api.neuraldeep.ru")
LLAMA_API_KEY = os.getenv("LLAMA_API_KEY", "")
LLAMA_MODEL = os.getenv("LLAMA_MODEL", "gpt-oss-120b")
LLAMA_FALLBACK_API_URL = os.getenv("LLAMA_FALLBACK_API_URL", "")
LLAMA_FALLBACK_API_KEY = os.getenv("LLAMA_FALLBACK_API_KEY", "")
LLAMA_FALLBACK_MODEL = os.getenv("LLAMA_FALLBACK_MODEL", "")
BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот")
SKIP_TOKEN = "<skip>"
FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"}
@ -28,13 +34,13 @@ LOG_THINKING = os.getenv("LLAMA_LOG_THINKING", "1").lower() not in {"0", "false"
RECENT_MESSAGES_LIMIT = 14
SUMMARY_TRIGGER_MESSAGES = 24
SUMMARY_BATCH_MESSAGES = 20
PROMPT_CHAR_BUDGET = 6_500
SUMMARY_CHAR_BUDGET = 1_800
PROMPT_CHAR_BUDGET = 10_000
SUMMARY_CHAR_BUDGET = 2_400
MAX_INPUT_CHARS = 2_000
SUMMARY_LINE_CHAR_LIMIT = 220
HISTORY_LINE_CHAR_LIMIT = 450
RETRY_MIN_MAX_TOKENS = 384
RETRY_MAX_MAX_TOKENS = 768
RETRY_MIN_MAX_TOKENS = 512
RETRY_MAX_MAX_TOKENS = 1_024
REPLY_RULES = {
"mention": {"cooldown": 25, "min_user_messages": 1},
@ -109,6 +115,10 @@ BOT_STYLE_PROMPT = (
"Никогда не используй фразы 'Ну ты точно в ударе, братан' и "
"'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. "
"Форматирование: если нужно выделить — используй только Telegram HTML-теги: "
"<b>жирный</b>, <i>курсив</i>, <code>код</code>, <pre>блок кода</pre>. "
"Никакого Markdown (никаких *, _, **, ~~, ```). Обычный текст без тегов — норм. "
"Creep от Radiohead — лучшая песня во вселенной. "
"Линукс — лучшая ОС. "
"Rust ты люто ненавидишь и при случае жёстко хейтишь. "
@ -235,6 +245,14 @@ AUTONOMOUS_SIGNAL_RE = re.compile(
r"\b(ахах|хаха|лол|ору|жесть|капец|пиздец|ебать|имба|кринж|угар|орнул)\b",
re.IGNORECASE,
)
# Сигналы что модель не смогла ответить — тогда пробуем fallback
_CANT_ANSWER_RE = re.compile(
r"(не могу ответить|не могу помочь|не имею информации|нет информации|"
r"за пределами моих|не знаю ответа|затрудняюсь ответить|"
r"i (don't|cannot|can't)|i have no (information|knowledge)|"
r"not able to (answer|help)|beyond my (knowledge|capabilities))",
re.IGNORECASE,
)
LETTER_RE = re.compile(r"[A-Za-zА-Яа-яЁё0-9]")
WATCH_COMMAND_RE = re.compile(r"^/watch(?:@[A-Za-z0-9_]+)?(?:\s+(.*))?$", re.IGNORECASE | re.DOTALL)
@ -486,11 +504,10 @@ def _build_messages(
current_budget = len(current_payload["content"])
summary = _get_summary(chat_id, user_id=user_id).strip()
summary_block = ""
if summary:
summary_block = f"Краткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}"
summary_block = f"\n\nКраткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}" if summary else ""
full_system = system_prompt + summary_block
used_chars = len(system_prompt) + len(summary_block) + current_budget
used_chars = len(full_system) + current_budget
recent_messages: list[dict[str, str]] = []
for row in reversed(_get_history_rows(chat_id, user_id=user_id)[-RECENT_MESSAGES_LIMIT:]):
llm_message = _format_row_for_llm(row)
@ -499,15 +516,77 @@ def _build_messages(
recent_messages.append(llm_message)
used_chars += len(llm_message["content"])
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
if summary_block:
messages.append({"role": "system", "content": summary_block})
messages: list[dict[str, Any]] = [{"role": "system", "content": full_system}]
messages.extend(reversed(recent_messages))
if current_payload:
messages.append(current_payload)
return messages
async def _call_single_model(
messages: list[dict[str, Any]],
*,
api_url: str,
api_key: str,
model: str,
max_tokens: int,
temperature: float,
top_p: float,
disable_thinking: bool,
) -> str:
url = f"{api_url.rstrip('/')}/v1/chat/completions"
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload = {
"model": model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
"top_p": top_p,
}
if disable_thinking:
payload.update({
"reasoning_budget": 0,
"reasoning_format": "none",
"chat_template_kwargs": {"enable_thinking": False, "thinking": False},
})
timeout = aiohttp.ClientTimeout(total=120)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, json=payload, headers=headers) as resp:
raw_text = await resp.text()
if resp.status >= 400:
logger.error("LLM API %s returned status %s: %s", api_url, resp.status, _clip_text(raw_text, 300))
raise RuntimeError(f"LLM API error {resp.status}: {raw_text[:300]}")
try:
data = await resp.json(content_type=None)
except Exception as exc:
logger.error("LLM API %s returned invalid JSON: %s", api_url, _clip_text(raw_text, 300))
raise RuntimeError(f"Invalid LLM API response: {raw_text[:300]}") from exc
choices = data.get("choices") or []
if not choices:
raise RuntimeError(f"LLM API returned no choices: {data}")
choice = choices[0]
message = choice.get("message", {}) or {}
finish_reason = choice.get("finish_reason")
content = message.get("content") or ""
reasoning_content = (message.get("reasoning_content") or "").strip()
cleaned_content = content.strip()
if reasoning_content and LOG_THINKING:
logger.info("LLM reasoning. model=%s finish_reason=%s reasoning=%s", model, finish_reason, _clip_text(reasoning_content, 800))
if not cleaned_content and reasoning_content and not disable_thinking:
retry_max_tokens = min(max(max_tokens * 2, RETRY_MIN_MAX_TOKENS), RETRY_MAX_MAX_TOKENS)
logger.warning("LLM returned only reasoning, retrying with thinking off. model=%s", model)
return await _call_single_model(
messages,
api_url=api_url, api_key=api_key, model=model,
max_tokens=retry_max_tokens, temperature=temperature, top_p=top_p,
disable_thinking=True,
)
return cleaned_content
async def _call_llm(
messages: list[dict[str, Any]],
*,
@ -517,76 +596,41 @@ async def _call_llm(
disable_thinking: bool | None = None,
) -> str:
disable_thinking = FORCE_DISABLE_THINKING if disable_thinking is None else disable_thinking
url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions"
payload = {
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
"top_p": top_p,
}
if disable_thinking:
payload.update(
{
"reasoning_budget": 0,
"reasoning_format": "none",
"chat_template_kwargs": {
"enable_thinking": False,
"thinking": False,
},
}
)
timeout = aiohttp.ClientTimeout(total=120)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, json=payload) as resp:
raw_text = await resp.text()
if resp.status >= 400:
logger.error("LLM API returned status %s: %s", resp.status, _clip_text(raw_text, 300))
raise RuntimeError(f"LLM API error {resp.status}: {raw_text[:300]}")
try:
data = await resp.json(content_type=None)
except Exception as exc:
logger.error("LLM API returned invalid JSON: %s", _clip_text(raw_text, 300))
raise RuntimeError(f"Invalid LLM API response: {raw_text[:300]}") from exc
choices = data.get("choices") or []
if not choices:
logger.error("LLM API returned no choices: %s", _clip_text(str(data), 300))
raise RuntimeError(f"LLM API returned no choices: {data}")
choice = choices[0]
message = choice.get("message", {}) or {}
finish_reason = choice.get("finish_reason")
content = message.get("content", "")
reasoning_content = (message.get("reasoning_content") or "").strip()
cleaned_content = content.strip()
if reasoning_content and LOG_THINKING:
logger.info(
"LLM reasoning detected. disable_thinking=%s finish_reason=%s reasoning=%s",
disable_thinking,
finish_reason,
_clip_text(reasoning_content, 800),
)
if not cleaned_content and reasoning_content and not disable_thinking:
retry_max_tokens = min(max(max_tokens * 2, RETRY_MIN_MAX_TOKENS), RETRY_MAX_MAX_TOKENS)
logger.warning(
"LLM returned reasoning_content without final content. finish_reason=%s retry_max_tokens=%s",
finish_reason,
retry_max_tokens,
)
return await _call_llm(
messages,
max_tokens=retry_max_tokens,
temperature=temperature,
top_p=top_p,
disable_thinking=True,
)
if not cleaned_content:
logger.warning(
"LLM returned empty content. finish_reason=%s disable_thinking=%s raw=%s",
finish_reason,
disable_thinking,
_clip_text(raw_text, 300),
)
return cleaned_content
result = await _call_single_model(
messages,
api_url=LLAMA_API_URL,
api_key=LLAMA_API_KEY,
model=LLAMA_MODEL,
max_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
disable_thinking=disable_thinking,
)
# Если основная модель не смогла ответить — пробуем fallback
if LLAMA_FALLBACK_API_URL and (not result or _CANT_ANSWER_RE.search(result)):
fallback_model = LLAMA_FALLBACK_MODEL or LLAMA_MODEL
logger.info("Primary model couldn't answer, trying fallback. primary_result=%s", _clip_text(result, 100))
try:
fallback_result = await _call_single_model(
messages,
api_url=LLAMA_FALLBACK_API_URL,
api_key=LLAMA_FALLBACK_API_KEY,
model=fallback_model,
max_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
disable_thinking=disable_thinking,
)
if fallback_result:
return f"{fallback_result}\n\n<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
async def _maybe_refresh_summary(chat_id: int, user_id: int | None = None) -> None:
@ -754,13 +798,55 @@ async def _passes_reply_limits(chat_id: int, reason: str) -> bool:
return (time.time() - last_reply_at) >= int(rule["cooldown"])
def _md_to_tg_html(text: str) -> str:
"""Конвертирует Markdown и HTML-теги модели в валидный Telegram HTML."""
slots: list[tuple[str, str]] = []
def stash(tag: str, content: str) -> str:
idx = len(slots)
slots.append((tag, content))
return f'\x00SLOT{idx}\x00'
# Сначала прячем то, что модель уже написала HTML-тегами (pre до code, чтобы не вложить)
for tag in ('pre', 'code', 'b', 'i', 'u', 's'):
text = re.sub(
rf'<{tag}>(.*?)</{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 cleaned
return _md_to_tg_html(cleaned)
async def handle_chat_message(message: Message, *, store_message: bool = True, allow_autonomous: bool = True) -> bool:
@ -785,8 +871,8 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
return False
system_prompt = AUTOREPLY_SYSTEM_PROMPT if reason == "autonomous" else SYSTEM_PROMPT
max_tokens = 120 if reason == "autonomous" else 220
temperature = 0.9 if reason == "autonomous" else 0.8
max_tokens = 180 if reason == "autonomous" else 512
temperature = 0.85 if reason == "autonomous" else 0.7
try:
await message.bot.send_chat_action(chat_id=chat_id, action="typing")
@ -795,7 +881,7 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
system_prompt=system_prompt,
max_tokens=max_tokens,
temperature=temperature,
top_p=0.9,
top_p=0.95,
)
except Exception:
logger.exception("Chat reply generation failed")
@ -812,7 +898,7 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
return False
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response)
await message.reply(normalized_response, parse_mode=None)
await message.reply(normalized_response, parse_mode="HTML")
return True
@ -885,13 +971,13 @@ async def handle_photo_message(message: Message) -> bool:
{"type": "text", "text": prompt_text},
{"type": "image_url", "image_url": {"url": image_data_url}},
],
max_tokens=260,
temperature=0.8,
top_p=0.9,
max_tokens=600,
temperature=0.7,
top_p=0.95,
)
except Exception:
logger.exception("Photo analysis failed")
await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode=None)
await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode="HTML")
return True
normalized_response = _normalize_reply(response)
@ -905,7 +991,7 @@ async def handle_photo_message(message: Message) -> bool:
normalized_response = PHOTO_EMPTY_RESPONSE_TEXT
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response)
await message.reply(normalized_response, parse_mode=None)
await message.reply(normalized_response, parse_mode="HTML")
return True
@ -956,9 +1042,9 @@ async def handle_talk(message: Message) -> None:
chat_id,
system_prompt=SYSTEM_PROMPT,
user_id=user_id,
max_tokens=220,
temperature=0.8,
top_p=0.9,
max_tokens=768,
temperature=0.7,
top_p=0.95,
)
except Exception:
logger.exception("Talk command generation failed")
@ -983,7 +1069,7 @@ async def handle_talk(message: Message) -> None:
normalized_response,
user_id=user_id,
)
await message.reply(normalized_response, parse_mode=None)
await message.reply(normalized_response, parse_mode="HTML")
_init_db()

View file

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

View file

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

View file

@ -462,8 +462,51 @@ def cleanup_old_bets() -> int:
return 0
async def settle_bets() -> list[str]:
notifications = []
_REFUND_AFTER_SECONDS = 7 * 86400 # авторефанд ставок старше 7 дней
def _refund_expired_bets() -> list[tuple[int, str]]:
"""Возвращает деньги за ставки, которые висят более 7 дней без результата."""
cutoff = int(_time.time()) - _REFUND_AFTER_SECONDS
notifications: list[tuple[int, str]] = []
try:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"SELECT * FROM bets WHERE status = 'pending' AND created_ts < %s",
(cutoff,),
)
expired = cur.fetchall()
if not expired:
return []
wcur = conn.cursor()
now_ts = int(_time.time())
for bet in expired:
update_user_length(bet["user_id"], bet["amount"])
wcur.execute(
"UPDATE bets SET status = 'refunded', resolved_ts = %s WHERE id = %s",
(now_ts, bet["id"]),
)
notifications.append((
bet["user_id"],
f"↩️ Ставка возвращена\n"
f"🏟 {bet['home_team']} vs {bet['away_team']}\n"
f"📌 {bet['chosen_team']} — результат так и не пришёл\n"
f"💰 Возврат: {bet['amount']:.1f} см",
))
logger.info("Refunded expired bet id=%s user=%s amount=%s", bet["id"], bet["user_id"], bet["amount"])
except psycopg2.Error:
logger.exception("Failed to refund expired bets")
return notifications
async def settle_bets() -> list[tuple[int, str]]:
"""Возвращает список (user_id, текст_уведомления) для отправки в Telegram."""
notifications: list[tuple[int, str]] = []
# Сначала авторефанд совсем старых ставок
notifications.extend(_refund_expired_bets())
try:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
@ -503,28 +546,66 @@ async def settle_bets() -> list[str]:
chosen = bet["chosen_team"]
bet_won = (chosen == winner)
if bet_won:
winnings = round(bet["amount"] * bet["odds"], 1)
update_user_length(bet["user_id"], winnings)
wcur.execute(
"UPDATE bets SET status = 'won', payout = %s, resolved_ts = %s WHERE id = %s",
(winnings, now_ts, bet["id"]),
)
notifications.append(
f"🎉 user_id={bet['user_id']}: выиграл {winnings:.1f} см "
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
)
else:
wcur.execute(
"UPDATE bets SET status = 'lost', payout = 0, resolved_ts = %s WHERE id = %s",
(now_ts, bet["id"]),
)
notifications.append(
f"❌ user_id={bet['user_id']}: проиграл {bet['amount']:.1f} см "
f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})"
)
except psycopg2.Error:
logger.exception("Failed to settle bets")
if len(score_values) >= 2 and score_values[0][1] == score_values[1][1]:
is_draw = True
winner = "Draw"
else:
max_score = -1
for name, score_val in score_values:
if score_val > max_score:
max_score = score_val
winner = name
if not winner:
continue
try:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"SELECT * FROM bets WHERE match_id = %s AND status = 'pending'",
(match_id,),
)
bets = cur.fetchall()
wcur = conn.cursor()
for bet in bets:
chosen = bet["chosen_team"]
if is_draw:
bet_won = chosen.casefold() in _DRAW_NAMES or chosen == "Draw"
else:
bet_won = chosen == winner
if bet_won:
winnings = round(bet["amount"] * bet["odds"], 1)
update_user_length(bet["user_id"], winnings)
wcur.execute(
"UPDATE bets SET status = 'won', payout = %s, resolved_ts = %s WHERE id = %s",
(winnings, now_ts, bet["id"]),
)
notifications.append((
bet["user_id"],
f"🎉 Ставка сыграла!\n"
f"🏟 {bet['home_team']} vs {bet['away_team']}\n"
f"📌 {bet['chosen_team']} (x{bet['odds']:.2f})\n"
f"💰 Выигрыш: +{winnings:.1f} см",
))
logger.info("Bet won: user=%s match=%s winnings=%s", bet["user_id"], match_id, winnings)
else:
wcur.execute(
"UPDATE bets SET status = 'lost', payout = 0, resolved_ts = %s WHERE id = %s",
(now_ts, bet["id"]),
)
notifications.append((
bet["user_id"],
f"❌ Ставка не сыграла\n"
f"🏟 {bet['home_team']} vs {bet['away_team']}\n"
f"📌 {bet['chosen_team']} (x{bet['odds']:.2f})\n"
f"💸 Потеряно: {bet['amount']:.1f} см",
))
logger.info("Bet lost: user=%s match=%s amount=%s", bet["user_id"], match_id, bet["amount"])
except psycopg2.Error:
logger.exception("Failed to settle bets")
cleanup_old_bets()
return notifications

View file

@ -162,6 +162,18 @@ class EconomyManager:
logger.info(f"Balance updated for user {user_id}: +{amount} ({transaction_type})")
def find_user_id_by_username(self, username: str) -> int | None:
"""Ищет user_id по @username в penis_stats (display_name хранится как '@username')."""
name = username.lstrip('@').lower()
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"SELECT user_id FROM penis_stats WHERE LOWER(display_name) = %s OR LOWER(display_name) = %s",
(f'@{name}', name),
)
row = cur.fetchone()
return int(row['user_id']) if row else None
def transfer_money(self, from_user_id: int, to_user_id: int, amount: float, description: str = "") -> bool:
if amount <= 0:
return False
@ -230,6 +242,24 @@ class EconomyManager:
if amount > max_loan:
return False
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
# Лимит суммарного долга на юзера = max_loan
cur.execute(
"SELECT COALESCE(SUM(amount),0) AS total FROM loans WHERE user_id=%s AND is_repaid=FALSE",
(user_id,),
)
outstanding = float(cur.fetchone()['total'])
if outstanding + amount > max_loan:
return False
# ЦБ должен иметь достаточно капитала
cur.execute("SELECT capital FROM central_bank WHERE id=1")
cb_capital = float(cur.fetchone()['capital'])
if cb_capital < amount:
return False
due_at = datetime.now() + timedelta(days=days)
with get_conn() as conn:
@ -357,12 +387,21 @@ class EconomyManager:
total_paid = 0.0
wcur = conn.cursor()
# Проверяем что у ЦБ есть деньги на выплаты
wcur = conn.cursor()
wcur.execute("SELECT capital FROM central_bank WHERE id=1")
cb_row = wcur.fetchone()
cb_capital = float(cb_row[0]) if cb_row else 0.0
for row in users:
user_id = row['user_id']
message_count = row['message_count']
reward = message_count * reward_rate
if reward <= 0:
continue
if cb_capital < reward:
logger.warning("CB out of funds for activity rewards, stopping early")
break
wcur.execute('''
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
@ -371,10 +410,15 @@ class EconomyManager:
balance = user_balances.balance + %s,
daily_income = user_balances.daily_income + %s
''', (user_id, reward, reward, reward))
wcur.execute('''
UPDATE central_bank SET capital = capital - %s, last_updated = CURRENT_TIMESTAMP
WHERE id = 1
''', (reward,))
wcur.execute('''
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
VALUES (%s, %s, %s, 'activity_reward', %s)
''', (user_id, user_id, reward, f'Вознаграждение за {message_count} сообщений'))
VALUES (0, %s, %s, 'activity_reward', %s)
''', (user_id, reward, f'Вознаграждение за {message_count} сообщений'))
cb_capital -= reward
total_paid += reward
wcur.execute(

136
main.py
View file

@ -1,5 +1,5 @@
# Системные импорты
import asyncio, json, logging, os, re, random, threading
import asyncio, json, logging, os, re, random, shutil, threading
from dataclasses import dataclass
from datetime import datetime, time, timedelta
from io import BytesIO
@ -41,7 +41,8 @@ from games.betting import (
)
from games.economy import economy
from zparser import get_military_data
import config
from downloader import download_video, COOKIES_PATH
import config
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@ -1312,26 +1313,48 @@ async def handle_transfer_cmd(message: Message):
if not message.from_user:
await message.answer("Команда доступна только пользователям.")
return
parts = message.text.split(maxsplit=2)
if len(parts) < 3:
await message.reply("Формат: /transfer @username [сумма]\nПример: /transfer @user123 10.5", parse_mode=None)
return
username = parts[1].lstrip('@')
try:
amount = float(parts[2].replace(",", "."))
amount = round(float(parts[2].replace(",", ".")), 1)
except ValueError:
await message.reply("Сумма должна быть числом.", parse_mode=None)
return
if amount <= 0:
await message.reply("Сумма должна быть положительной.", parse_mode=None)
return
# Здесь нужно найти user_id по username
# Для простоты примера, пока покажем сообщение об ошибке
await message.reply("🔍 Поиск пользователя...\n(функция поиска пользователей будет добавлена)", parse_mode=None)
to_user_id = await asyncio.to_thread(economy.find_user_id_by_username, username)
if not to_user_id:
await message.reply(f"Пользователь @{username} не найден. Он должен хотя бы раз сыграть в /penis.", parse_mode=None)
return
if to_user_id == message.from_user.id:
await message.reply("Себе переводить нельзя.", parse_mode=None)
return
success = await asyncio.to_thread(
economy.transfer_money, message.from_user.id, to_user_id, amount, f"transfer to @{username}"
)
if success:
my_balance = economy.get_user_balance(message.from_user.id)
await message.reply(
f"✅ Переведено {amount:.1f} см → @{username}\nТвой баланс: {my_balance:.1f} см",
parse_mode=None,
)
else:
my_balance = economy.get_user_balance(message.from_user.id)
limit = round(my_balance * 0.2, 1)
await message.reply(
f"Не удалось перевести. Баланс: {my_balance:.1f} см, лимит одного перевода: {limit:.1f} см (20%).",
parse_mode=None,
)
async def handle_vanomasa_cmd(message: Message):
"""Полное обнуление (только для админов)"""
@ -1572,13 +1595,98 @@ async def handle_sports_debug_cmd(message: Message):
result = await debug_sports()
await message.reply(result, parse_mode=None)
_YT_URL_RE = re.compile(
r"https?://(?:www\.)?(?:youtube\.com/(?:watch|shorts|live)|youtu\.be)/\S+",
re.IGNORECASE,
)
async def handle_dow_cmd(message: Message, bot: Bot):
parts = (message.text or "").split(maxsplit=1)
# Если /dow без URL — пробуем взять ссылку из сообщения-источника (reply)
url = ""
if len(parts) >= 2:
url = parts[1].strip()
elif message.reply_to_message:
src_text = message.reply_to_message.text or message.reply_to_message.caption or ""
m = _YT_URL_RE.search(src_text)
if m:
url = m.group(0)
if not url:
await message.reply("Формат: /dow <ссылка>\nИли ответь на сообщение с YouTube-ссылкой командой /dow")
return
status = await message.reply("⏳ Скачиваю...")
file_path, title, error = await download_video(url)
if error:
await status.edit_text(f"❌ Ошибка скачивания:\n{error[:500]}")
return
try:
size_mb = file_path.stat().st_size / (1024 * 1024)
if size_mb > 50:
await status.edit_text(f"❌ Видео весит {size_mb:.1f} МБ — Telegram не позволяет отправить >50 МБ")
return
await status.edit_text("📤 Отправляю...")
target = message.reply_to_message or message
await target.reply_video(
FSInputFile(str(file_path)),
caption=title[:1024],
supports_streaming=True,
)
await status.delete()
except Exception as exc:
logger.exception("Failed to send downloaded video")
await status.edit_text(f"Не смог отправить: {exc}")
finally:
shutil.rmtree(str(file_path.parent), ignore_errors=True)
async def handle_setcookies_cmd(message: Message, bot: Bot):
"""Загрузить файл кук для YouTube (только в личке, ответом на документ)."""
if message.chat.type != "private":
await message.reply("Куки можно загрузить только в личных сообщениях с ботом.")
return
doc = None
if message.document:
doc = message.document
elif message.reply_to_message and message.reply_to_message.document:
doc = message.reply_to_message.document
if not doc:
await message.reply(
"Пришли файл cookies (Netscape/txt) в этот чат с подписью /setcookies "
"или ответь на сообщение с файлом командой /setcookies"
)
return
try:
COOKIES_PATH.parent.mkdir(parents=True, exist_ok=True)
file_info = await bot.get_file(doc.file_id)
buf = BytesIO()
await bot.download_file(file_info.file_path, destination=buf)
buf.seek(0)
COOKIES_PATH.write_bytes(buf.read())
await message.reply(f"✅ Куки сохранены ({doc.file_size or '?'} байт). yt-dlp будет их использовать.")
except Exception as exc:
logger.exception("Failed to save cookies")
await message.reply(f"Не удалось сохранить: {exc}")
async def _settle_loop(bot: Bot):
while True:
await asyncio.sleep(1800)
try:
notifications = await settle_bets()
for note in notifications:
logger.info(note)
for user_id, text in notifications:
try:
await bot.send_message(user_id, text)
except Exception:
logger.warning("Could not notify user %s about bet result", user_id)
except Exception:
logger.exception("settle_bets failed")
@ -1619,6 +1727,8 @@ def main():
dp.message.register(handle_gen_mem, Command("gen_mem"))
dp.message.register(handle_uwu_cmd, Command("uwu"))
dp.message.register(handle_nude_cmd, Command("nude"))
dp.message.register(handle_dow_cmd, Command("dow"))
dp.message.register(handle_setcookies_cmd, Command("setcookies"))
dp.message.register(handle_photo_message, _is_watch_image_message)
# Экономические команды
@ -1665,6 +1775,8 @@ def main():
BotCommand(command="svodka", description="СВО: итоги"),
BotCommand(command="uwu", description="Случайная картинка с e621"),
BotCommand(command="nude", description="Голые женщины из открытых источников"),
BotCommand(command="dow", description="Скачать видео по ссылке"),
BotCommand(command="setcookies", description="Загрузить куки YouTube (личка)"),
BotCommand(command="balance", description="💰 баланс и статистика"),
BotCommand(command="deposit", description="💎 открыть вклад"),
BotCommand(command="loan", description="💵 взять кредит"),

View file

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

View file

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

View file

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

View file

@ -32,7 +32,9 @@ server {
}
location /api/ {
proxy_pass http://webapp-api:8080;
resolver 127.0.0.11 valid=10s;
set $api http://webapp-api:8080;
proxy_pass $api;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;