1
0
Fork 0
forked from zovos/bot_tg

Compare commits

..

No commits in common. "d5698ab76ea68be090e0b94e04419014fdde5ac1" and "e10aa85e6f0aba7e0a6a7c1deee40fe299c83628" have entirely different histories.

12 changed files with 172 additions and 642 deletions

View file

@ -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. "
"Помни всё, что он говорил раньше — ты видишь историю этого разговора и должен её учитывать. "
"Замечай если человек продолжает старую тему или переходит к новой. "
"Отвечай КОРОТКО: 13 предложения максимум. "
"Только если тебя прямо попросили объяснить, развернуть или написать подробно — тогда можно больше."
)
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()

View file

@ -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"]

View file

@ -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:-}

View file

@ -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")

View file

@ -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()

View file

@ -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
View file

@ -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="💵 взять кредит"),

View file

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

View file

@ -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()

View file

@ -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(),

View file

@ -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) {

View file

@ -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;