1
0
Fork 0
forked from zovos/bot_tg

Compare commits

..

34 commits
main ... main

Author SHA1 Message Date
q
d4cd100ba0 Merge pull request 'Исправление точности вещественных чисел (Float Precision) при ставках, казино, переводах и вкладах' (#38) from Dan4ick/bot_tg:feature/FenyaBot into main
Reviewed-on: zovos/bot_tg#38
2026-07-11 17:56:01 +00:00
q
71a3982513 Merge pull request 'Add uwu command description to README.md' (#25) from Furrex/bot_tg:main into main
Reviewed-on: zovos/bot_tg#25
2026-07-05 15:16:30 +00:00
Danil
191aa8ef93 Исправление точности вещественных чисел при ставках, казино, переводах и вкладах 2026-05-20 23:33:42 +03:00
zovos
89a14f2ebf feat: multi-user awareness in chat, per-user /talk prompt, thinking tokens for /talk 2026-05-20 10:29:44 +00:00
zovos
acb7421cc0 fix: syntax error in betting.py after merge conflict resolution 2026-05-20 10:24:38 +00:00
zovos
60e4cbfcfb wip: local changes before merging PR #36 2026-05-20 10:02:14 +00:00
q
cb1e042e64 Merge pull request 'feature/FenyaBot blur questionable (q) rating in /uwu command' (#36) from Dan4ick/bot_tg:feature/FenyaBot into main
Reviewed-on: zovos/bot_tg#36
2026-05-20 10:00:12 +00:00
Danil
e10aa85e6f Replace sport betting with Liquipedia Dota 2 betting 2026-05-19 23:03:53 +03:00
Danil
2e3bd9bd99 Restrict betting system to Dota 2 only 2026-05-19 22:36:52 +03:00
Danil
dd3c993146 Merge remote-tracking branch 'upstream/main' into feature/FenyaBot 2026-05-19 22:19:56 +03:00
Danil
5304428738 feat: blur questionable (q) rating in /uwu command 2026-05-19 22:18:39 +03:00
q
add3553ce0 feat: переход с SQLite на PostgreSQL + скрипт миграции
- Все 4 SQLite базы объединены в одну PostgreSQL
- Новый db.py с ThreadedConnectionPool (psycopg2)
- docker-compose: сервис postgres с healthcheck, данные в ./db/postgres
- scripts/migrate_sqlite_to_pg.py — миграция существующих данных
- Убраны мёртвый код и дублирующий import в main.py и talk_handler.py
- .env удалён из git-индекса

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 23:25:22 +03:00
q
b0e03f8e23 попытка починить и оптимизировать shorties (#35) 2026-04-27 10:38:25 +00:00
hitoshi
6db4b06497 попытка починить и оптимизировать shorties 2026-04-24 19:24:06 +03:00
q
81af64c8b7 feat: единая экономика — penis game, вклады/кредиты через ЦБ
- penis_stats переехал в economy.sqlite3, длина хранится в user_balances.balance
- /top_penis читает из user_balances, /penis пишет туда же
- create_deposit: деньги уходят в капитал ЦБ
- create_loan: деньги берутся из капитала ЦБ
- repay_loan: деньги возвращаются в ЦБ
- добавлен process_matured_deposits: ЦБ выплачивает вклады с процентами
- init_penis_db() больше не вызывается

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 13:53:41 +03:00
q
70e7d23064 fix: бот не стартовал + падение в get_user_stats + handle_central_bank_cmd не отвечал
- economy_background_tasks() была вставлена в середину main(), из-за чего
  dp.startup.register и asyncio.run(dp.start_polling) оказались мёртвым кодом
  внутри while True — бот никогда не поднимался
- двойной fetchone() в get_user_stats (тот же баг что в PR #34, но в другом месте)
- conn.close() вызывался до запросов за активностью, потом ещё раз — ProgrammingError
- handle_central_bank_cmd строил текст но не делал await message.answer()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 13:08:30 +03:00
q
78b42691b7 Merge pull request 'fix: исправил падение бота - двойной fetchone() в ЦБ' (#34) from jeffrey228/bot_tg:main into main
Reviewed-on: zovos/bot_tg#34
2026-04-24 09:57:06 +00:00
itexpert228
6f5c6c4809
fix: исправил падение бота - двойной fetchone() в ЦБ 2026-04-24 12:38:01 +03:00
q
0f8df91701 Merge pull request 'main' (#33) from jeffrey228/bot_tg:main into main
Reviewed-on: zovos/bot_tg#33
2026-04-24 09:29:45 +00:00
itexpert228
34ba32fefa
сисиписи экономика сперма говно жопааааа сеееекс 2026-04-24 12:21:19 +03:00
itexpert228
3f416e9fe0
Revert "delete govnshe ebanoe"
This reverts commit 628099b443.
2026-04-24 12:15:31 +03:00
itexpert228
833ec9cf9a
сисиписи экономика сперма говно жопааааа сеееекс 2026-04-24 12:12:59 +03:00
q
5847e0e06a Merge pull request 'СЕКСАПДЕЙТ' (#31) from SUDOZOVCHIK/bot_tg:main into main
Reviewed-on: zovos/bot_tg#31
2026-04-23 11:50:22 +00:00
Hitoshi-Hub
1979ca3193 СЕКСАПДЕЙТ 2026-04-23 14:47:52 +03:00
q
41b5c001dc Merge pull request '/watch' (#30) from SUDOZOVCHIK/bot_tg:main into main
Reviewed-on: zovos/bot_tg#30
2026-04-23 10:08:24 +00:00
Hitoshi-Hub
3cee61ab2c хуй 2026-04-23 13:03:44 +03:00
Hitoshi-Hub
2740e848e4 Сделал так чтобы не отвечал на фото без /watch 2026-04-23 13:01:38 +03:00
q
a00dab8798 Спойлер для /uwu и /nude (#28) 2026-04-20 15:20:44 +00:00
Danil
88ceb2ef29 feat: spoiler for explicit content in /uwu and /nude 2026-04-20 18:11:39 +03:00
q
9216bde63a Merge pull request 'feature/FenyaBot' (#26) from Dan4ick/bot_tg:feature/FenyaBot into main
Reviewed-on: zovos/bot_tg#26
2026-04-16 09:19:46 +00:00
Danil
3b29648a8c fix: критические баги, FurTok картинки, image proxy, gitignore
- Удалён дубликат handle_uwu_cmd в uwu.py
- Исправлен UnboundLocalError (delta/next_dt) в main.py
- FurTok: поддержка картинок при кастомном запросе
- Добавлен image proxy /api/proxy-image для e621
- CSS: абсолютное позиционирование медиа в furtok-card
- .gitignore: docker-compose.override.yaml
2026-04-14 22:49:49 +03:00
Danil
dd36d8f1c3 FurTok: редактор тегов, подписи uwu, TikTok-скролл 2026-04-13 19:09:04 +03:00
Danil
c2e210c4ae Исправление критических багов и потенциальных проблем
- Удалена дублирующая функция handle_uwu_cmd в uwu.py (мёртвый код)

- Исправлен краш UnboundLocalError в /zvetok (next_dt и delta могли быть не определены)

- Баланс penis больше не уходит в минус при проигрыше в казино

- talk_handler теперь использует путь БД из config.py вместо своего (разные файлы БД)

- betting.py больше не крашится при импорте если /db/ недоступен

- Таймаут гадания снижен со 120 до 30 секунд (Telegram таймаутит раньше)
2026-04-13 18:50:37 +03:00
itexpert228
628099b443
delete govnshe ebanoe 2026-04-10 12:37:16 +03:00
31 changed files with 3902 additions and 931 deletions

0
.codex Normal file
View file

6
.env
View file

@ -1,6 +0,0 @@
BOT_TOKEN=8156642467:AAEalekr-33lFS3tTTkXIEiaawfDzR6lBJc
TZ=Europe/Moscow
ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923
LLAMA_API_URL=https://mirror.porno4free.ru/zovos-ai/
LLAMA_FORCE_DISABLE_THINKING=0
LLAMA_LOG_THINKING=1

1
.gitignore vendored
View file

@ -22,3 +22,4 @@ db/
AI/models/ AI/models/
main_legacy.py main_legacy.py
docker-compose.override.yaml

View file

@ -2,25 +2,30 @@ 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
import random import random
import re import re
import sqlite3
import time import time
from io import BytesIO from io import BytesIO
from pathlib import Path
from typing import Any from typing import Any
import aiohttp import aiohttp
import psycopg2.extras
from aiogram.types import Message from aiogram.types import Message
from db import get_conn
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Основные настройки поведения и стиля бота редактируются здесь. LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://api.neuraldeep.ru")
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/") LLAMA_API_KEY = os.getenv("LLAMA_API_KEY", "")
DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH") or str(Path(__file__).resolve().with_name("chat_history.sqlite3")) 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"}
@ -29,13 +34,15 @@ LOG_THINKING = os.getenv("LLAMA_LOG_THINKING", "1").lower() not in {"0", "false"
RECENT_MESSAGES_LIMIT = 14 RECENT_MESSAGES_LIMIT = 14
SUMMARY_TRIGGER_MESSAGES = 24 SUMMARY_TRIGGER_MESSAGES = 24
SUMMARY_BATCH_MESSAGES = 20 SUMMARY_BATCH_MESSAGES = 20
PROMPT_CHAR_BUDGET = 6_500 PROMPT_CHAR_BUDGET = 10_000
SUMMARY_CHAR_BUDGET = 1_800 SUMMARY_CHAR_BUDGET = 2_400
MAX_INPUT_CHARS = 2_000 MAX_INPUT_CHARS = 2_000
SUMMARY_LINE_CHAR_LIMIT = 220 SUMMARY_LINE_CHAR_LIMIT = 220
HISTORY_LINE_CHAR_LIMIT = 450 HISTORY_LINE_CHAR_LIMIT = 450
RETRY_MIN_MAX_TOKENS = 384 RETRY_MIN_MAX_TOKENS = 512
RETRY_MAX_MAX_TOKENS = 768 RETRY_MAX_MAX_TOKENS = 1_024
TALK_MAX_TOKENS = 1536
TALK_THINKING_BUDGET = 1024
REPLY_RULES = { REPLY_RULES = {
"mention": {"cooldown": 25, "min_user_messages": 1}, "mention": {"cooldown": 25, "min_user_messages": 1},
@ -110,6 +117,10 @@ BOT_STYLE_PROMPT = (
"Никогда не используй фразы 'Ну ты точно в ударе, братан' и " "Никогда не используй фразы 'Ну ты точно в ударе, братан' и "
"'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. " "'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. "
"Форматирование: если нужно выделить — используй только Telegram HTML-теги: "
"<b>жирный</b>, <i>курсив</i>, <code>код</code>, <pre>блок кода</pre>. "
"Никакого Markdown (никаких *, _, **, ~~, ```). Обычный текст без тегов — норм. "
"Creep от Radiohead — лучшая песня во вселенной. " "Creep от Radiohead — лучшая песня во вселенной. "
"Линукс — лучшая ОС. " "Линукс — лучшая ОС. "
"Rust ты люто ненавидишь и при случае жёстко хейтишь. " "Rust ты люто ненавидишь и при случае жёстко хейтишь. "
@ -119,10 +130,21 @@ BOT_STYLE_PROMPT = (
SYSTEM_PROMPT = ( SYSTEM_PROMPT = (
f"{BOT_STYLE_PROMPT}\n\n" f"{BOT_STYLE_PROMPT}\n\n"
"Ты отвечаешь как живой участник одного Telegram-чата. " "Ты отвечаешь как живой участник Telegram-чата с несколькими разными людьми. "
"Сообщения в истории записаны в формате «Имя: текст» — каждое имя это отдельный человек, не путай их. "
"Отвечай тому, кто написал последним, учитывай контекст именно его сообщений. "
"Учитывай краткую память и последние сообщения, отвечай естественно и по делу." "Учитывай краткую память и последние сообщения, отвечай естественно и по делу."
) )
TALK_SYSTEM_PROMPT = (
f"{BOT_STYLE_PROMPT}\n\n"
"Ты ведёшь личный разговор тет-а-тет с одним конкретным человеком через /talk. "
"Помни всё, что он говорил раньше — ты видишь историю этого разговора и должен её учитывать. "
"Замечай если человек продолжает старую тему или переходит к новой. "
"Отвечай КОРОТКО: 13 предложения максимум. "
"Только если тебя прямо попросили объяснить, развернуть или написать подробно — тогда можно больше."
)
AUTOREPLY_SYSTEM_PROMPT = ( AUTOREPLY_SYSTEM_PROMPT = (
f"{BOT_STYLE_PROMPT}\n\n" f"{BOT_STYLE_PROMPT}\n\n"
"Ты иногда сам коротко и уместно влезаешь в разговор в Telegram-чате. " "Ты иногда сам коротко и уместно влезаешь в разговор в Telegram-чате. "
@ -236,7 +258,16 @@ 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)
_reply_lock = asyncio.Lock() _reply_lock = asyncio.Lock()
_summary_lock = asyncio.Lock() _summary_lock = asyncio.Lock()
@ -244,133 +275,95 @@ _cached_bot_id: int | None = None
_cached_bot_username: str = "" _cached_bot_username: str = ""
def _connect_db() -> sqlite3.Connection: def _get_state_from_conn(conn, chat_id: int, key: str, default: str = "") -> str:
conn = sqlite3.connect(DB_PATH, timeout=30) cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
conn.row_factory = sqlite3.Row cur.execute(
conn.execute("PRAGMA busy_timeout = 30000") "SELECT value FROM chat_state WHERE chat_id = %s AND key = %s",
return conn
def _column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
return any(row["name"] == column for row in rows)
def _get_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, default: str = "") -> str:
row = conn.execute(
"SELECT value FROM chat_state WHERE chat_id = ? AND key = ?",
(chat_id, key), (chat_id, key),
).fetchone() )
row = cur.fetchone()
return row["value"] if row else default return row["value"] if row else default
def _set_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, value: str) -> None: def _set_state_from_conn(conn, chat_id: int, key: str, value: str) -> None:
conn.execute( cur = conn.cursor()
cur.execute(
""" """
INSERT INTO chat_state (chat_id, key, value) INSERT INTO chat_state (chat_id, key, value)
VALUES (?, ?, ?) VALUES (%s, %s, %s)
ON CONFLICT(chat_id, key) DO UPDATE SET value = excluded.value ON CONFLICT (chat_id, key) DO UPDATE SET value = EXCLUDED.value
""", """,
(chat_id, key, value), (chat_id, key, value),
) )
def _get_talk_state_from_conn( def _get_talk_state_from_conn(conn, chat_id: int, user_id: int, key: str, default: str = "") -> str:
conn: sqlite3.Connection, cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
chat_id: int, cur.execute(
user_id: int, "SELECT value FROM talk_state WHERE chat_id = %s AND user_id = %s AND key = %s",
key: str,
default: str = "",
) -> str:
row = conn.execute(
"SELECT value FROM talk_state WHERE chat_id = ? AND user_id = ? AND key = ?",
(chat_id, user_id, key), (chat_id, user_id, key),
).fetchone() )
row = cur.fetchone()
return row["value"] if row else default return row["value"] if row else default
def _set_talk_state_from_conn( def _set_talk_state_from_conn(conn, chat_id: int, user_id: int, key: str, value: str) -> None:
conn: sqlite3.Connection, cur = conn.cursor()
chat_id: int, cur.execute(
user_id: int,
key: str,
value: str,
) -> None:
conn.execute(
""" """
INSERT INTO talk_state (chat_id, user_id, key, value) INSERT INTO talk_state (chat_id, user_id, key, value)
VALUES (?, ?, ?, ?) VALUES (%s, %s, %s, %s)
ON CONFLICT(chat_id, user_id, key) DO UPDATE SET value = excluded.value ON CONFLICT (chat_id, user_id, key) DO UPDATE SET value = EXCLUDED.value
""", """,
(chat_id, user_id, key, value), (chat_id, user_id, key, value),
) )
def _init_db() -> None: def _init_db() -> None:
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) with get_conn() as conn:
with _connect_db() as conn: cur = conn.cursor()
conn.execute("PRAGMA journal_mode = WAL") cur.execute("""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS chat_history ( CREATE TABLE IF NOT EXISTS chat_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id BIGSERIAL PRIMARY KEY,
chat_id INTEGER NOT NULL, chat_id BIGINT NOT NULL,
role TEXT NOT NULL, role TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
text TEXT NOT NULL, text TEXT NOT NULL,
created_at REAL NOT NULL DEFAULT 0 created_at DOUBLE PRECISION NOT NULL DEFAULT 0
) )
""" """)
) cur.execute("ALTER TABLE chat_history ADD COLUMN IF NOT EXISTS created_at DOUBLE PRECISION NOT NULL DEFAULT 0")
if not _column_exists(conn, "chat_history", "created_at"): cur.execute("""
conn.execute("ALTER TABLE chat_history ADD COLUMN created_at REAL NOT NULL DEFAULT 0")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS chat_state ( CREATE TABLE IF NOT EXISTS chat_state (
chat_id INTEGER NOT NULL, chat_id BIGINT NOT NULL,
key TEXT NOT NULL, key TEXT NOT NULL,
value TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (chat_id, key) PRIMARY KEY (chat_id, key)
) )
""" """)
) cur.execute("""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS talk_history ( CREATE TABLE IF NOT EXISTS talk_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id BIGSERIAL PRIMARY KEY,
chat_id INTEGER NOT NULL, chat_id BIGINT NOT NULL,
user_id INTEGER NOT NULL, user_id BIGINT NOT NULL,
role TEXT NOT NULL, role TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
text TEXT NOT NULL, text TEXT NOT NULL,
created_at REAL NOT NULL DEFAULT 0 created_at DOUBLE PRECISION NOT NULL DEFAULT 0
) )
""" """)
) cur.execute("""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS talk_state ( CREATE TABLE IF NOT EXISTS talk_state (
chat_id INTEGER NOT NULL, chat_id BIGINT NOT NULL,
user_id INTEGER NOT NULL, user_id BIGINT NOT NULL,
key TEXT NOT NULL, key TEXT NOT NULL,
value TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (chat_id, user_id, key) PRIMARY KEY (chat_id, user_id, key)
) )
""" """)
) cur.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_id ON chat_history(chat_id, id)")
conn.execute( cur.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_role_id ON chat_history(chat_id, role, id)")
"CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_id ON chat_history(chat_id, id)" cur.execute("CREATE INDEX IF NOT EXISTS idx_talk_history_chat_user_id ON talk_history(chat_id, user_id, id)")
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_role_id ON chat_history(chat_id, role, id)"
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_talk_history_chat_user_id
ON talk_history(chat_id, user_id, id)
"""
)
conn.commit()
def _clean_text(text: str) -> str: def _clean_text(text: str) -> str:
@ -420,78 +413,59 @@ def push_message(
cleaned_text = _clean_text(text) cleaned_text = _clean_text(text)
if not cleaned_text: if not cleaned_text:
return return
with _connect_db() as conn: with get_conn() as conn:
cur = conn.cursor()
if user_id is None: if user_id is None:
conn.execute( cur.execute(
""" "INSERT INTO chat_history (chat_id, role, name, text, created_at) VALUES (%s, %s, %s, %s, %s)",
INSERT INTO chat_history (chat_id, role, name, text, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(chat_id, role, name, cleaned_text, time.time()), (chat_id, role, name, cleaned_text, time.time()),
) )
else: else:
conn.execute( cur.execute(
""" "INSERT INTO talk_history (chat_id, user_id, role, name, text, created_at) VALUES (%s, %s, %s, %s, %s, %s)",
INSERT INTO talk_history (chat_id, user_id, role, name, text, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(chat_id, user_id, role, name, cleaned_text, time.time()), (chat_id, user_id, role, name, cleaned_text, time.time()),
) )
conn.commit()
def _get_summary(chat_id: int, user_id: int | None = None) -> str: def _get_summary(chat_id: int, user_id: int | None = None) -> str:
with _connect_db() as conn: with get_conn() as conn:
if user_id is None: if user_id is None:
return _get_state_from_conn(conn, chat_id, "summary", "") return _get_state_from_conn(conn, chat_id, "summary", "")
return _get_talk_state_from_conn(conn, chat_id, user_id, "summary", "") return _get_talk_state_from_conn(conn, chat_id, user_id, "summary", "")
def _get_history_rows(chat_id: int, user_id: int | None = None) -> list[sqlite3.Row]: def _get_history_rows(chat_id: int, user_id: int | None = None) -> list:
with _connect_db() as conn: with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
if user_id is None: if user_id is None:
return conn.execute( cur.execute(
""" "SELECT id, role, name, text, created_at FROM chat_history WHERE chat_id = %s ORDER BY id ASC",
SELECT id, role, name, text, created_at
FROM chat_history
WHERE chat_id = ?
ORDER BY id ASC
""",
(chat_id,), (chat_id,),
).fetchall() )
return conn.execute( else:
""" cur.execute(
SELECT id, role, name, text, created_at "SELECT id, role, name, text, created_at FROM talk_history WHERE chat_id = %s AND user_id = %s ORDER BY id ASC",
FROM talk_history (chat_id, user_id),
WHERE chat_id = ? AND user_id = ? )
ORDER BY id ASC return cur.fetchall()
""",
(chat_id, user_id),
).fetchall()
def _latest_reply_stats(chat_id: int) -> tuple[float, int]: def _latest_reply_stats(chat_id: int) -> tuple[float, int]:
with _connect_db() as conn: with get_conn() as conn:
last_assistant = conn.execute( cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
""" cur.execute(
SELECT id, created_at "SELECT id, created_at FROM chat_history WHERE chat_id = %s AND role = 'assistant' ORDER BY id DESC LIMIT 1",
FROM chat_history
WHERE chat_id = ? AND role = 'assistant'
ORDER BY id DESC
LIMIT 1
""",
(chat_id,), (chat_id,),
).fetchone() )
last_assistant = cur.fetchone()
if not last_assistant: if not last_assistant:
return 0.0, 10_000 return 0.0, 10_000
user_messages_since_reply = conn.execute( cnt_cur = conn.cursor()
""" cnt_cur.execute(
SELECT COUNT(*) "SELECT COUNT(*) FROM chat_history WHERE chat_id = %s AND role = 'user' AND id > %s",
FROM chat_history
WHERE chat_id = ? AND role = 'user' AND id > ?
""",
(chat_id, last_assistant["id"]), (chat_id, last_assistant["id"]),
).fetchone()[0] )
user_messages_since_reply = cnt_cur.fetchone()[0]
return float(last_assistant["created_at"] or 0.0), int(user_messages_since_reply) return float(last_assistant["created_at"] or 0.0), int(user_messages_since_reply)
@ -501,23 +475,23 @@ def _store_summary_and_prune(
last_row_id: int, last_row_id: int,
user_id: int | None = None, user_id: int | None = None,
) -> None: ) -> None:
with _connect_db() as conn: with get_conn() as conn:
cur = conn.cursor()
if user_id is None: if user_id is None:
_set_state_from_conn(conn, chat_id, "summary", summary) _set_state_from_conn(conn, chat_id, "summary", summary)
conn.execute( cur.execute(
"DELETE FROM chat_history WHERE chat_id = ? AND id <= ?", "DELETE FROM chat_history WHERE chat_id = %s AND id <= %s",
(chat_id, last_row_id), (chat_id, last_row_id),
) )
else: else:
_set_talk_state_from_conn(conn, chat_id, user_id, "summary", summary) _set_talk_state_from_conn(conn, chat_id, user_id, "summary", summary)
conn.execute( cur.execute(
"DELETE FROM talk_history WHERE chat_id = ? AND user_id = ? AND id <= ?", "DELETE FROM talk_history WHERE chat_id = %s AND user_id = %s AND id <= %s",
(chat_id, user_id, last_row_id), (chat_id, user_id, last_row_id),
) )
conn.commit()
def _format_row_for_llm(row: sqlite3.Row) -> dict[str, str]: def _format_row_for_llm(row) -> dict[str, str]:
if row["role"] == "user": if row["role"] == "user":
return { return {
"role": "user", "role": "user",
@ -543,11 +517,10 @@ def _build_messages(
current_budget = len(current_payload["content"]) current_budget = len(current_payload["content"])
summary = _get_summary(chat_id, user_id=user_id).strip() summary = _get_summary(chat_id, user_id=user_id).strip()
summary_block = "" summary_block = f"\n\nКраткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}" if summary else ""
if summary: full_system = system_prompt + summary_block
summary_block = f"Краткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}"
used_chars = len(system_prompt) + len(summary_block) + current_budget used_chars = len(full_system) + current_budget
recent_messages: list[dict[str, str]] = [] recent_messages: list[dict[str, str]] = []
for row in reversed(_get_history_rows(chat_id, user_id=user_id)[-RECENT_MESSAGES_LIMIT:]): for row in reversed(_get_history_rows(chat_id, user_id=user_id)[-RECENT_MESSAGES_LIMIT:]):
llm_message = _format_row_for_llm(row) llm_message = _format_row_for_llm(row)
@ -556,15 +529,80 @@ def _build_messages(
recent_messages.append(llm_message) recent_messages.append(llm_message)
used_chars += len(llm_message["content"]) used_chars += len(llm_message["content"])
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}] messages: list[dict[str, Any]] = [{"role": "system", "content": full_system}]
if summary_block:
messages.append({"role": "system", "content": summary_block})
messages.extend(reversed(recent_messages)) messages.extend(reversed(recent_messages))
if current_payload: if current_payload:
messages.append(current_payload) messages.append(current_payload)
return messages return messages
async def _call_single_model(
messages: list[dict[str, Any]],
*,
api_url: str,
api_key: str,
model: str,
max_tokens: int,
temperature: float,
top_p: float,
disable_thinking: bool,
reasoning_budget: int = 0,
) -> str:
url = f"{api_url.rstrip('/')}/v1/chat/completions"
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload = {
"model": model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
"top_p": top_p,
}
if disable_thinking:
payload.update({
"reasoning_budget": 0,
"reasoning_format": "none",
"chat_template_kwargs": {"enable_thinking": False, "thinking": False},
})
elif reasoning_budget > 0:
payload["reasoning_budget"] = reasoning_budget
timeout = aiohttp.ClientTimeout(total=120)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, json=payload, headers=headers) as resp:
raw_text = await resp.text()
if resp.status >= 400:
logger.error("LLM API %s returned status %s: %s", api_url, resp.status, _clip_text(raw_text, 300))
raise RuntimeError(f"LLM API error {resp.status}: {raw_text[:300]}")
try:
data = await resp.json(content_type=None)
except Exception as exc:
logger.error("LLM API %s returned invalid JSON: %s", api_url, _clip_text(raw_text, 300))
raise RuntimeError(f"Invalid LLM API response: {raw_text[:300]}") from exc
choices = data.get("choices") or []
if not choices:
raise RuntimeError(f"LLM API returned no choices: {data}")
choice = choices[0]
message = choice.get("message", {}) or {}
finish_reason = choice.get("finish_reason")
content = message.get("content") or ""
reasoning_content = (message.get("reasoning_content") or "").strip()
cleaned_content = content.strip()
if reasoning_content and LOG_THINKING:
logger.info("LLM reasoning. model=%s finish_reason=%s reasoning=%s", model, finish_reason, _clip_text(reasoning_content, 800))
if not cleaned_content and reasoning_content and not disable_thinking:
retry_max_tokens = min(max(max_tokens * 2, RETRY_MIN_MAX_TOKENS), RETRY_MAX_MAX_TOKENS)
logger.warning("LLM returned only reasoning, retrying with thinking off. model=%s", model)
return await _call_single_model(
messages,
api_url=api_url, api_key=api_key, model=model,
max_tokens=retry_max_tokens, temperature=temperature, top_p=top_p,
disable_thinking=True, reasoning_budget=0,
)
return cleaned_content
async def _call_llm( async def _call_llm(
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
*, *,
@ -572,78 +610,46 @@ async def _call_llm(
temperature: float, temperature: float,
top_p: float, top_p: float,
disable_thinking: bool | None = None, disable_thinking: bool | None = None,
reasoning_budget: int = 0,
) -> str: ) -> str:
disable_thinking = FORCE_DISABLE_THINKING if disable_thinking is None else disable_thinking disable_thinking = FORCE_DISABLE_THINKING if disable_thinking is None else disable_thinking
url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions"
payload = {
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
"top_p": top_p,
}
if disable_thinking:
payload.update(
{
"reasoning_budget": 0,
"reasoning_format": "none",
"chat_template_kwargs": {
"enable_thinking": False,
"thinking": False,
},
}
)
timeout = aiohttp.ClientTimeout(total=120)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, json=payload) as resp:
raw_text = await resp.text()
if resp.status >= 400:
logger.error("LLM API returned status %s: %s", resp.status, _clip_text(raw_text, 300))
raise RuntimeError(f"LLM API error {resp.status}: {raw_text[:300]}")
try:
data = await resp.json(content_type=None)
except Exception as exc:
logger.error("LLM API returned invalid JSON: %s", _clip_text(raw_text, 300))
raise RuntimeError(f"Invalid LLM API response: {raw_text[:300]}") from exc
choices = data.get("choices") or [] result = await _call_single_model(
if not choices: messages,
logger.error("LLM API returned no choices: %s", _clip_text(str(data), 300)) api_url=LLAMA_API_URL,
raise RuntimeError(f"LLM API returned no choices: {data}") api_key=LLAMA_API_KEY,
choice = choices[0] model=LLAMA_MODEL,
message = choice.get("message", {}) or {} max_tokens=max_tokens,
finish_reason = choice.get("finish_reason") temperature=temperature,
content = message.get("content", "") top_p=top_p,
reasoning_content = (message.get("reasoning_content") or "").strip() disable_thinking=disable_thinking,
cleaned_content = content.strip() reasoning_budget=reasoning_budget,
if reasoning_content and LOG_THINKING: )
logger.info(
"LLM reasoning detected. disable_thinking=%s finish_reason=%s reasoning=%s", # Если основная модель не смогла ответить — пробуем fallback
disable_thinking, if LLAMA_FALLBACK_API_URL and (not result or _CANT_ANSWER_RE.search(result)):
finish_reason, fallback_model = LLAMA_FALLBACK_MODEL or LLAMA_MODEL
_clip_text(reasoning_content, 800), logger.info("Primary model couldn't answer, trying fallback. primary_result=%s", _clip_text(result, 100))
) try:
if not cleaned_content and reasoning_content and not disable_thinking: fallback_result = await _call_single_model(
retry_max_tokens = min(max(max_tokens * 2, RETRY_MIN_MAX_TOKENS), RETRY_MAX_MAX_TOKENS) messages,
logger.warning( api_url=LLAMA_FALLBACK_API_URL,
"LLM returned reasoning_content without final content. finish_reason=%s retry_max_tokens=%s", api_key=LLAMA_FALLBACK_API_KEY,
finish_reason, model=fallback_model,
retry_max_tokens, max_tokens=max_tokens,
) temperature=temperature,
return await _call_llm( top_p=top_p,
messages, disable_thinking=disable_thinking,
max_tokens=retry_max_tokens, reasoning_budget=reasoning_budget,
temperature=temperature, )
top_p=top_p, if fallback_result:
disable_thinking=True, return f"{fallback_result}\n\n<i>🤖 {fallback_model}</i>"
) except Exception:
if not cleaned_content: logger.exception("Fallback model also failed")
logger.warning(
"LLM returned empty content. finish_reason=%s disable_thinking=%s raw=%s", if not result:
finish_reason, logger.warning("LLM returned empty content. model=%s", LLAMA_MODEL)
disable_thinking, return result
_clip_text(raw_text, 300),
)
return cleaned_content
async def _maybe_refresh_summary(chat_id: int, user_id: int | None = None) -> None: async def _maybe_refresh_summary(chat_id: int, user_id: int | None = None) -> None:
@ -709,6 +715,8 @@ async def _generate_response(
max_tokens: int, max_tokens: int,
temperature: float, temperature: float,
top_p: float, top_p: float,
disable_thinking: bool | None = None,
reasoning_budget: int = 0,
) -> str: ) -> str:
await _maybe_refresh_summary(chat_id, user_id=user_id) await _maybe_refresh_summary(chat_id, user_id=user_id)
if current_content is None: if current_content is None:
@ -734,6 +742,8 @@ async def _generate_response(
max_tokens=max_tokens, max_tokens=max_tokens,
temperature=temperature, temperature=temperature,
top_p=top_p, top_p=top_p,
disable_thinking=disable_thinking,
reasoning_budget=reasoning_budget,
) )
@ -811,13 +821,55 @@ async def _passes_reply_limits(chat_id: int, reason: str) -> bool:
return (time.time() - last_reply_at) >= int(rule["cooldown"]) return (time.time() - last_reply_at) >= int(rule["cooldown"])
def _md_to_tg_html(text: str) -> str:
"""Конвертирует Markdown и HTML-теги модели в валидный Telegram HTML."""
slots: list[tuple[str, str]] = []
def stash(tag: str, content: str) -> str:
idx = len(slots)
slots.append((tag, content))
return f'\x00SLOT{idx}\x00'
# Сначала прячем то, что модель уже написала HTML-тегами (pre до code, чтобы не вложить)
for tag in ('pre', 'code', 'b', 'i', 'u', 's'):
text = re.sub(
rf'<{tag}>(.*?)</{tag}>',
lambda m, t=tag: stash(t, m.group(1)),
text, flags=re.DOTALL,
)
# Конвертируем Markdown
text = re.sub(
r'```(?:[^\n`]*\n)?(.*?)```',
lambda m: stash('pre', m.group(1).strip()),
text, flags=re.DOTALL,
)
text = re.sub(r'`([^`\n]+)`', lambda m: stash('code', m.group(1)), text)
text = re.sub(r'\*\*(.+?)\*\*', lambda m: stash('b', m.group(1)), text, flags=re.DOTALL)
text = re.sub(r'\*([^*\n]+?)\*', lambda m: stash('i', m.group(1)), text)
text = re.sub(r'_([^_\n]+?)_', lambda m: stash('i', m.group(1)), text)
text = re.sub(r'~~(.+?)~~', lambda m: stash('s', m.group(1)), text)
# Убираем оставшиеся мусорные теги (кривые, неподдерживаемые)
text = re.sub(r'<[^>]*>', '', text)
# Экранируем оставшийся plain-text
text = html.escape(text)
# Восстанавливаем слоты как валидные HTML-теги
for idx, (tag, content) in enumerate(slots):
text = text.replace(f'\x00SLOT{idx}\x00', f'<{tag}>{html.escape(content)}</{tag}>')
return text
def _normalize_reply(text: str) -> str: def _normalize_reply(text: str) -> str:
cleaned = (text or "").strip() cleaned = (text or "").strip()
if not cleaned: if not cleaned:
return "" return ""
if cleaned.lower().startswith(SKIP_TOKEN.lower()): if cleaned.lower().startswith(SKIP_TOKEN.lower()):
return "" return ""
return cleaned return _md_to_tg_html(cleaned)
async def handle_chat_message(message: Message, *, store_message: bool = True, allow_autonomous: bool = True) -> bool: async def handle_chat_message(message: Message, *, store_message: bool = True, allow_autonomous: bool = True) -> bool:
@ -842,8 +894,8 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
return False return False
system_prompt = AUTOREPLY_SYSTEM_PROMPT if reason == "autonomous" else SYSTEM_PROMPT system_prompt = AUTOREPLY_SYSTEM_PROMPT if reason == "autonomous" else SYSTEM_PROMPT
max_tokens = 120 if reason == "autonomous" else 220 max_tokens = 180 if reason == "autonomous" else 512
temperature = 0.9 if reason == "autonomous" else 0.8 temperature = 0.85 if reason == "autonomous" else 0.7
try: try:
await message.bot.send_chat_action(chat_id=chat_id, action="typing") await message.bot.send_chat_action(chat_id=chat_id, action="typing")
@ -852,7 +904,7 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
system_prompt=system_prompt, system_prompt=system_prompt,
max_tokens=max_tokens, max_tokens=max_tokens,
temperature=temperature, temperature=temperature,
top_p=0.9, top_p=0.95,
) )
except Exception: except Exception:
logger.exception("Chat reply generation failed") logger.exception("Chat reply generation failed")
@ -869,7 +921,7 @@ async def handle_chat_message(message: Message, *, store_message: bool = True, a
return False return False
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response) await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response)
await message.reply(normalized_response, parse_mode=None) await message.reply(normalized_response, parse_mode="HTML")
return True return True
@ -903,12 +955,13 @@ async def handle_photo_message(message: Message) -> bool:
is_image_document = bool(message.document and (message.document.mime_type or "").startswith("image/")) is_image_document = bool(message.document and (message.document.mime_type or "").startswith("image/"))
if not (message.photo or is_image_document) or not message.from_user or message.from_user.is_bot: if not (message.photo or is_image_document) or not message.from_user or message.from_user.is_bot:
return False return False
if message.caption and message.caption.startswith("/"): watch_match = WATCH_COMMAND_RE.match((message.caption or "").strip())
if not watch_match:
return False return False
chat_id = message.chat.id chat_id = message.chat.id
user_name = message.from_user.first_name or "кент" user_name = message.from_user.first_name or "кент"
caption = _clean_text(message.caption or "") caption = _clean_text(watch_match.group(1) or "")
logger.warning( logger.warning(
"Received image message for analysis. chat_id=%s user=%s has_photo=%s has_image_document=%s caption=%s", "Received image message for analysis. chat_id=%s user=%s has_photo=%s has_image_document=%s caption=%s",
@ -941,13 +994,13 @@ async def handle_photo_message(message: Message) -> bool:
{"type": "text", "text": prompt_text}, {"type": "text", "text": prompt_text},
{"type": "image_url", "image_url": {"url": image_data_url}}, {"type": "image_url", "image_url": {"url": image_data_url}},
], ],
max_tokens=260, max_tokens=600,
temperature=0.8, temperature=0.7,
top_p=0.9, top_p=0.95,
) )
except Exception: except Exception:
logger.exception("Photo analysis failed") logger.exception("Photo analysis failed")
await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode=None) await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode="HTML")
return True return True
normalized_response = _normalize_reply(response) normalized_response = _normalize_reply(response)
@ -961,7 +1014,7 @@ async def handle_photo_message(message: Message) -> bool:
normalized_response = PHOTO_EMPTY_RESPONSE_TEXT normalized_response = PHOTO_EMPTY_RESPONSE_TEXT
await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response) await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response)
await message.reply(normalized_response, parse_mode=None) await message.reply(normalized_response, parse_mode="HTML")
return True return True
@ -1010,11 +1063,13 @@ async def handle_talk(message: Message) -> None:
await message.bot.send_chat_action(chat_id=chat_id, action="typing") await message.bot.send_chat_action(chat_id=chat_id, action="typing")
response = await _generate_response( response = await _generate_response(
chat_id, chat_id,
system_prompt=SYSTEM_PROMPT, system_prompt=TALK_SYSTEM_PROMPT,
user_id=user_id, user_id=user_id,
max_tokens=220, max_tokens=TALK_MAX_TOKENS,
temperature=0.8, temperature=0.7,
top_p=0.9, top_p=0.95,
disable_thinking=False,
reasoning_budget=TALK_THINKING_BUDGET,
) )
except Exception: except Exception:
logger.exception("Talk command generation failed") logger.exception("Talk command generation failed")
@ -1039,7 +1094,7 @@ async def handle_talk(message: Message) -> None:
normalized_response, normalized_response,
user_id=user_id, user_id=user_id,
) )
await message.reply(normalized_response, parse_mode=None) await message.reply(normalized_response, parse_mode="HTML")
_init_db() _init_db()

106
DEPLOYMENT.md Normal file
View file

@ -0,0 +1,106 @@
# Инструкция по развертыванию бота
## 🚀 Быстрый старт
### 1. Клонирование и подготовка
```bash
git clone <репозиторий>
cd bot_tg
```
### 2. Настройка переменных окружения
Создайте файл `.env` с необходимыми переменными:
```env
BOT_TOKEN=your_telegram_bot_token
LLAMA_API_URL=http://your-llama-server:8080 # опционально
ODDS_API_KEY=your_odds_api_key # опционально для ставок
```
### 3. Запуск через Docker Compose
```bash
# Сборка и запуск
docker compose up --build -d
# Просмотр логов
docker compose logs -f bot
# Остановка
docker compose down
```
## 📁 Структура проекта
```
bot_tg/
├── main.py # основной файл бота
├── games/
│ ├── economy.py # экономическая система
│ ├── casino.py # казино
│ ├── betting.py # ставки на спорт
│ └── ...
├── AI/ # ИИ-модули
├── maket/ # макеты для мемов
├── db/ # базы данных (создаются автоматически)
├── Dockerfile
├── docker-compose.yaml
├── requirements.txt
└── .env # переменные окружения
```
## 🗄️ Базы данных
Автоматически создаются в папке `./db`:
- `penis_stats.sqlite3` - статистика игры /penis
- `chat_history.sqlite3` - история для ИИ-общения
- `polychaetsi_stats.json` - статистика слова "получается"
- `bets.sqlite3` - ставки на спорт
- `economy.sqlite3` - экономическая система (ЦБ, вклады, кредиты)
## 🏛️ Экономическая система
**Основная валюта:** сантиметры (см)
**Команды экономики:**
- `/balance` - баланс и статистика
- `/deposit [сумма] [дни]` - открыть вклад
- `/loan [сумма] [дни]` - взять кредит
- `/transfer @username [сумма]` - перевести деньги
- `/cb_stats` - статистика Центрального Банка
- `/central_bank` - детальная статистика ЦБ
- `/cb_rules` - правила работы ЦБ
**Центральный Банк:** децентрализованная система с капиталом 1000 см
## 🐛 Поиск проблем
### Проверка логов
```bash
docker compose logs bot
```
### Перезапуск бота
```bash
docker compose restart bot
```
### Проверка статуса контейнера
```bash
docker compose ps
```
## ⚠️ Важные моменты
1. **Все базы данных хранятся в `./db`** - эта папка монтируется в контейнер
2. **Бот работает в часовом поясе Europe/Moscow**
3. **Центральный Банк полностью автоматический** - никакого ручного управления
4. **Ежедневные выплаты за активность** - в 00:00 по МСК
5. **Налоги 13%** - автоматически поступают в ЦБ
## 🔧 Первоначальная настройка
1. Получите токен бота у @BotFather
2. Добавьте бота в чат (если нужен)
3. Настройте `.env` файл
4. Запустите через `docker compose up --build -d`
Бот готов к работе! 🎉

View file

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

View file

@ -37,11 +37,12 @@ docker compose down
Для ставок нужен `ODDS_API_KEY` (бесплатно на https://the-odds-api.com). Для ставок нужен `ODDS_API_KEY` (бесплатно на https://the-odds-api.com).
Контейнер хранит состояние в `./db`: Контейнер хранит состояние в `./db`:
- `penis_stats.sqlite3` - `penis_stats.sqlite3` — статистика игры /penis
- `chat_history.sqlite3` - `chat_history.sqlite3` — история для ИИ-общения
- `polychaetsi_stats.json` - `polychaetsi_stats.json` — статистика слова "получается"
- `bets.sqlite3` - `bets.sqlite3` — ставки на спорт
- `nude_history.json` - `economy.sqlite3` — экономическая система (балансы, вклады, кредиты)
- `nude_history.json` — история запросов /nude
## Команды ## Команды
@ -64,6 +65,39 @@ docker compose down
- `/svodka` — сводка СВО. - `/svodka` — сводка СВО.
- `/uwu [теги]` — поиск случайной пикчи/гифки на e621 (по умолчанию sfw, можно указать свои теги). - `/uwu [теги]` — поиск случайной пикчи/гифки на e621 (по умолчанию sfw, можно указать свои теги).
## Экономика ZOV OS 💰
Основная валюта — сантиметры (см), получаемые из игры `/penis`.
**Команды экономики:**
- `/balance` — показать баланс и статистику
- `/deposit <сумма> <дни>` — открыть вклад по ставке ЦБ РФ
- `/loan <сумма> <дни>` — взять кредит под 15% годовых
- `/transfer @username <сумма>` — перевести деньги (лимит 20% от капитала)
- `/cb_stats` — статистика Центрального Банка
- `/central_bank` — детальная статистика ЦБ
- `/cb_rules` — правила работы ЦБ
- `/vanomasa` — полное обнуление всех балансов (админам)
**Фичи экономики:**
- Вклады по ставке ЦБ РФ (7.5% годовых)
- Кредиты под 15% годовых
- Налог 13% с ежедневного дохода
- Автоматическое регулирование инфляции
- Фоновый сбор налогов каждый час
- Заработок на сообщениях: 0.01 см за каждое сообщение
- Ежедневные выплаты в 00:00 по МСК за активность
**Центральный Банк ZOV OS 🏛️ (децентрализованный):**
- Учредительный капитал: 1000 см
- Полностью автоматическая работа без вмешательства
- Все налоги автоматически поступают в ЦБ
- Алгоритмическое регулирование инфляции
- Автоматическая эмиссия при дефляции
- Автоматическая скупка денег при инфляции
- Прозрачная статистика для всех пользователей
- Никакого ручного управления - система работает по правилам
## Макеты и шрифт ## Макеты и шрифт
- Шрифт `impact.ttf` лежит в корне. - Шрифт `impact.ttf` лежит в корне.

View file

@ -7,10 +7,9 @@ from zoneinfo import ZoneInfo
BASE_DIR = Path(__file__).parent BASE_DIR = Path(__file__).parent
FONT_PATH = BASE_DIR / "impact.ttf" FONT_PATH = BASE_DIR / "impact.ttf"
DEFAULT_TZ = os.getenv("TZ", "Europe/Moscow") DEFAULT_TZ = os.getenv("TZ", "Europe/Moscow")
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://bot:botpass@postgres:5432/botdb")
# --- Настройки /penis --- # --- Настройки /penis ---
PENIS_DB_PATH = "/db/penis_stats.sqlite3"
CHAT_HISTORY_DB_PATH = "/db/chat_history.sqlite3"
PENIS_START_LENGTH = 10.0 PENIS_START_LENGTH = 10.0
PENIS_MIN_DELTA = 0.1 PENIS_MIN_DELTA = 0.1
PENIS_MAX_DELTA = 7.0 PENIS_MAX_DELTA = 7.0
@ -34,17 +33,10 @@ E621_API_KEY = os.getenv("E621_API_KEY", "")
# --- Ставки на матчи --- # --- Ставки на матчи ---
ODDS_API_KEY = os.getenv("ODDS_API_KEY", "") ODDS_API_KEY = os.getenv("ODDS_API_KEY", "")
ODDS_API_BASE = "https://api.the-odds-api.com/v4" ODDS_API_BASE = "https://api.the-odds-api.com/v4"
ODDS_SPORTS = {
"epl": "soccer_epl",
"ucl": "soccer_uefa_champs_league",
"laliga": "soccer_spain_la_liga",
"bundesliga": "soccer_germany_bundesliga",
"seriea": "soccer_italy_serie_a",
"ligue1": "soccer_france_ligue_one",
"europa": "soccer_uefa_europa_league",
}
BET_MAX_AMOUNT = 3.0 BET_MAX_AMOUNT = 3.0
BET_DB_PATH = "/db/bets.sqlite3" ODDS_SPORTS = {
"dota2": "esports_dota2",
}
ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно) ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно)
# --- Nude --- # --- Nude ---
@ -221,8 +213,8 @@ def get_hint_text(templates_str: str) -> str:
"• /talk [текст] — ИИ базарит на фене.\n" "• /talk [текст] — ИИ базарит на фене.\n"
"• /penis_casino [ставка] — казино на размер.\n" "• /penis_casino [ставка] — казино на размер.\n"
"• /gadanie [тема] — гадание на фене с мемом.\n" "• /gadanie [тема] — гадание на фене с мемом.\n"
"• /matches [epl|ucl|laliga|bundesliga|seriea|ligue1|europa] — матчи для ставок.\n" "• /matches — матчи для ставок.\n"
"• /bet [лига] [номер] [исход] [ставка] — поставить см.\n" "• /bet [номер] [исход] [ставка] — поставить.\n"
"• /mybets — мои активные ставки.\n" "• /mybets — мои активные ставки.\n"
"• /bet_history — история ставок за месяц + статистика.\n" "• /bet_history — история ставок за месяц + статистика.\n"
"• /sports_debug — диагностика API матчей.\n" "• /sports_debug — диагностика API матчей.\n"

35
db.py Normal file
View file

@ -0,0 +1,35 @@
import logging
import os
from contextlib import contextmanager
from typing import Generator
import psycopg2
import psycopg2.extras
import psycopg2.pool
logger = logging.getLogger(__name__)
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://bot:botpass@postgres:5432/botdb")
_pool: psycopg2.pool.ThreadedConnectionPool | None = None
def _get_pool() -> psycopg2.pool.ThreadedConnectionPool:
global _pool
if _pool is None:
_pool = psycopg2.pool.ThreadedConnectionPool(2, 10, dsn=DATABASE_URL)
return _pool
@contextmanager
def get_conn() -> Generator[psycopg2.extensions.connection, None, None]:
pool = _get_pool()
conn = pool.getconn()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
pool.putconn(conn)

View file

@ -0,0 +1,38 @@
services:
webapp-api-test:
build:
context: .
dockerfile: Dockerfile.api
container_name: fenyabot-webapp-api-test
volumes:
- ./db:/db
env_file:
- .env
environment:
TZ: Europe/Moscow
PENIS_DB_PATH: /db/penis_stats.sqlite3
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
BET_DB_PATH: /db/bets.sqlite3
API_PORT: "8080"
LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/}
LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0}
LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1}
ports:
- "18080:8080"
restart: unless-stopped
ngrok-test:
image: ngrok/ngrok:latest
container_name: fenyabot-ngrok-test
depends_on:
- webapp-api-test
environment:
NGROK_AUTHTOKEN: ${NGROK_AUTHTOKEN}
command:
- http
- webapp-api-test:8080
- --log=stdout
ports:
- "4041:4040"
restart: unless-stopped

View file

@ -1,4 +1,19 @@
services: services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: botdb
POSTGRES_USER: bot
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-botpass}
volumes:
- ./db/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U bot -d botdb"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
bot: bot:
build: build:
context: . context: .
@ -8,13 +23,15 @@ services:
- .env - .env
environment: environment:
TZ: Europe/Moscow TZ: Europe/Moscow
PENIS_DB_PATH: /db/penis_stats.sqlite3 DATABASE_URL: postgresql://bot:${POSTGRES_PASSWORD:-botpass}@postgres:5432/botdb
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
BET_DB_PATH: /db/bets.sqlite3 NUDE_HISTORY_PATH: /db/nude_history.json
LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/} LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/}
LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0} LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0}
LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1} LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1}
depends_on:
postgres:
condition: service_healthy
init: true init: true
restart: unless-stopped restart: unless-stopped
@ -28,22 +45,24 @@ services:
- .env - .env
environment: environment:
TZ: Europe/Moscow TZ: Europe/Moscow
PENIS_DB_PATH: /db/penis_stats.sqlite3 DATABASE_URL: postgresql://bot:${POSTGRES_PASSWORD:-botpass}@postgres:5432/botdb
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
BET_DB_PATH: /db/bets.sqlite3 NUDE_HISTORY_PATH: /db/nude_history.json
API_PORT: "8080" API_PORT: "8080"
LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/} LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/}
LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0} LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0}
LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1} LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1}
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
webapp-frontend: webapp-frontend:
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:-}
@ -58,5 +77,6 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
postgres_data:
certbot_etc: certbot_etc:
certbot_www: certbot_www:

View file

@ -1,13 +1,20 @@
import time as _time
import sqlite3
import logging import logging
from datetime import datetime, timedelta import time as _time
from pathlib import Path import hashlib
import random
import urllib.request
import json
import gzip
from datetime import datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from bs4 import BeautifulSoup
import psycopg2
import psycopg2.extras
import aiohttp import aiohttp
import config import config
from db import get_conn
from games.casino import get_user_length, update_user_length from games.casino import get_user_length, update_user_length
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -17,18 +24,17 @@ _sports_cache: tuple[float, list] | None = None
_last_viewed_sport_by_user: dict[int, str] = {} _last_viewed_sport_by_user: dict[int, str] = {}
_DRAW_NAMES = {"draw", "tie", "ничья"} _DRAW_NAMES = {"draw", "tie", "ничья"}
# Сколько дней хранить историю ставок
BET_HISTORY_DAYS = 30 BET_HISTORY_DAYS = 30
_LIQUIPEDIA_CACHE_TTL = 300 # 5 minutes cache to respect Liquipedia rate limits
def _init_bets_db() -> None: def _init_bets_db() -> None:
Path(config.BET_DB_PATH).parent.mkdir(parents=True, exist_ok=True) with get_conn() as conn:
with sqlite3.connect(config.BET_DB_PATH) as conn: cur = conn.cursor()
conn.execute( cur.execute('''
"""
CREATE TABLE IF NOT EXISTS bets ( CREATE TABLE IF NOT EXISTS bets (
id INTEGER PRIMARY KEY AUTOINCREMENT, id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL, user_id BIGINT NOT NULL,
match_id TEXT NOT NULL, match_id TEXT NOT NULL,
sport TEXT NOT NULL, sport TEXT NOT NULL,
home_team TEXT NOT NULL, home_team TEXT NOT NULL,
@ -38,28 +44,21 @@ def _init_bets_db() -> None:
odds REAL NOT NULL, odds REAL NOT NULL,
status TEXT DEFAULT 'pending', status TEXT DEFAULT 'pending',
payout REAL DEFAULT 0, payout REAL DEFAULT 0,
created_ts INTEGER, created_ts BIGINT,
resolved_ts INTEGER resolved_ts BIGINT
) )
""" ''')
) cur.execute("CREATE INDEX IF NOT EXISTS idx_bets_user ON bets(user_id, status)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_bets_user ON bets(user_id, status)") cur.execute("CREATE INDEX IF NOT EXISTS idx_bets_match ON bets(match_id, status)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_bets_match ON bets(match_id, status)") cur.execute("CREATE INDEX IF NOT EXISTS idx_bets_created ON bets(created_ts)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_bets_created ON bets(created_ts)") cur.execute("ALTER TABLE bets ADD COLUMN IF NOT EXISTS payout REAL DEFAULT 0")
cur.execute("ALTER TABLE bets ADD COLUMN IF NOT EXISTS resolved_ts BIGINT")
# Миграция: добавить столбцы если их нет
cursor = conn.execute("PRAGMA table_info(bets)")
columns = {row[1] for row in cursor.fetchall()}
if "payout" not in columns:
conn.execute("ALTER TABLE bets ADD COLUMN payout REAL DEFAULT 0")
if "resolved_ts" not in columns:
conn.execute("ALTER TABLE bets ADD COLUMN resolved_ts INTEGER")
conn.commit()
_init_bets_db() try:
_init_bets_db()
except Exception:
logger.warning("Could not init bets DB at import time (will retry on first use)")
def remember_user_sport_context(user_id: int, sport_alias: str) -> None: def remember_user_sport_context(user_id: int, sport_alias: str) -> None:
@ -76,92 +75,119 @@ def get_user_sport_context(user_id: int) -> str | None:
async def _fetch_available_sports() -> list[dict]: async def _fetch_available_sports() -> list[dict]:
"""Получить список доступных видов спорта из API.""" return [{"key": "dota2", "title": "🎮 Dota 2", "active": True}]
global _sports_cache
async def fetch_liquipedia_matches() -> list[dict]:
global _matches_cache
now = _time.time() now = _time.time()
if _sports_cache and (now - _sports_cache[0]) < 3600: cached = _matches_cache.get("liquipedia_all")
return _sports_cache[1] if cached and (now - cached[0]) < _LIQUIPEDIA_CACHE_TTL:
if not config.ODDS_API_KEY:
return []
url = f"{config.ODDS_API_BASE}/sports"
params = {"apiKey": config.ODDS_API_KEY}
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=15)) as resp:
if resp.status != 200:
body = await resp.text()
logger.warning(f"Sports API returned {resp.status}: {body[:200]}")
return []
data = await resp.json()
_sports_cache = (now, data)
return data
except Exception:
logger.exception("Failed to fetch sports list")
return []
async def fetch_matches(sport_key: str) -> list[dict]:
now = _time.time()
cached = _matches_cache.get(sport_key)
if cached and (now - cached[0]) < config.ODDS_CACHE_TTL:
return cached[1] return cached[1]
if not config.ODDS_API_KEY: url = "https://liquipedia.net/dota2/api.php?action=parse&page=Liquipedia:Matches&format=json"
logger.warning("ODDS_API_KEY не задан, матчи недоступны") headers = {
return [] "User-Agent": "Dota2BettingTelegramBot/1.0 (contact: danil@example.com)",
"Accept-Encoding": "gzip"
url = f"{config.ODDS_API_BASE}/sports/{sport_key}/odds"
params = {
"apiKey": config.ODDS_API_KEY,
"regions": "eu",
"markets": "h2h",
"oddsFormat": "decimal",
} }
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=15)) as resp: async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
if resp.status != 200: if resp.status != 200:
body = await resp.text() logger.warning(f"Liquipedia API returned {resp.status}")
logger.warning(f"Odds API returned {resp.status} for {sport_key}: {body[:300]}")
return cached[1] if cached else [] return cached[1] if cached else []
data = await resp.json() data = await resp.json()
logger.info(f"Odds API for {sport_key}: received {len(data)} events") html_content = data['parse']['text']['*']
soup = BeautifulSoup(html_content, "lxml")
match_divs = soup.find_all("div", class_="match-info")
matches = []
for idx, div in enumerate(match_divs):
# Tournament
tournament_div = div.find("div", class_="match-info-tournament")
tournament = tournament_div.get_text(strip=True) if tournament_div else "Unknown Tournament"
# Teams
opponents = div.find_all("div", class_="match-info-header-opponent")
team1 = "TBD"
team2 = "TBD"
winner = None
completed = False
if len(opponents) >= 2:
o1, o2 = opponents[0], opponents[1]
team1 = o1.get_text(strip=True)
team2 = o2.get_text(strip=True)
c1 = o1.get("class", [])
c2 = o2.get("class", [])
has_win_loss = any("winner" in c or "loser" in c for c in c1 + c2)
if has_win_loss:
completed = True
if any("winner" in c for c in c1):
winner = team1
elif any("winner" in c for c in c2):
winner = team2
else:
teams = div.find_all("span", class_="block-team")
if len(teams) >= 2:
team1 = teams[0].get_text(strip=True)
team2 = teams[1].get_text(strip=True)
# Start time
timer_span = div.find("span", class_="timer-object")
timestamp = ""
if timer_span:
timestamp = timer_span.get("data-timestamp", "")
if not team1 or not team2 or team1 == "TBD" or team2 == "TBD":
continue
# ID generation
match_id = hashlib.md5(f"lp_{team1}_{team2}_{timestamp}".encode('utf-8')).hexdigest()
# Deterministic Odds
seed = int(hashlib.md5(match_id.encode('utf-8')).hexdigest(), 16) % 10**8
r = random.Random(seed)
odds_home = round(r.uniform(1.3, 3.2), 2)
margin = 1.08
prob_home = 1.0 / odds_home
prob_away = margin - prob_home
if prob_away < 0.1:
prob_away = 0.1
odds_away = round(1.0 / prob_away, 2)
matches.append({
"id": match_id,
"home": team1,
"away": team2,
"commence": datetime.fromtimestamp(int(timestamp)).isoformat() + "Z" if timestamp else "",
"timestamp": timestamp,
"completed": completed,
"winner": winner,
"odds": {
team1: odds_home,
team2: odds_away
},
"tournament": tournament
})
_matches_cache["liquipedia_all"] = (now, matches)
logger.info(f"Scraped and cached {len(matches)} matches from Liquipedia")
return matches
except Exception: except Exception:
logger.exception(f"Failed to fetch odds for {sport_key}") logger.exception("Failed to fetch matches from Liquipedia")
return cached[1] if cached else [] return cached[1] if cached else []
if not isinstance(data, list):
logger.warning(f"Odds API returned non-list for {sport_key}: {type(data)}")
return cached[1] if cached else []
matches = [] async def fetch_matches(sport_key: str = "esports_dota2") -> list[dict]:
for event in data: all_matches = await fetch_liquipedia_matches()
best_odds = {} # Return only active (not completed) matches for betting
for bookmaker in event.get("bookmakers", []): return [m for m in all_matches if not m["completed"]]
for market in bookmaker.get("markets", []):
if market.get("key") == "h2h":
for outcome in market.get("outcomes", []):
name = outcome.get("name", "")
price = outcome.get("price", 0)
if name and price and (name not in best_odds or price > best_odds[name]):
best_odds[name] = price
if len(best_odds) >= 2:
matches.append({
"id": event.get("id", ""),
"home": event.get("home_team", ""),
"away": event.get("away_team", ""),
"commence": event.get("commence_time", ""),
"odds": best_odds,
})
_matches_cache[sport_key] = (now, matches)
logger.info(f"Parsed {len(matches)} matches for {sport_key}")
return matches
def _get_match_outcomes(match: dict) -> list[dict[str, str | float]]: def _get_match_outcomes(match: dict) -> list[dict[str, str | float]]:
@ -177,20 +203,7 @@ def _get_match_outcomes(match: dict) -> list[dict[str, str | float]]:
home = match.get("home", "") home = match.get("home", "")
away = match.get("away", "") away = match.get("away", "")
append_outcome("1", home) append_outcome("1", home)
draw_name = next((name for name in odds if name.casefold() in _DRAW_NAMES), None)
if draw_name:
append_outcome("X", draw_name)
append_outcome("2", away) append_outcome("2", away)
next_code = 3
for name in odds:
if name in added:
continue
append_outcome(str(next_code), name)
next_code += 1
return outcomes return outcomes
@ -213,11 +226,6 @@ def resolve_match_outcome(match: dict, raw_choice: str) -> str | None:
if normalized == str(item["code"]).casefold(): if normalized == str(item["code"]).casefold():
return str(item["name"]) return str(item["name"])
if normalized in _DRAW_NAMES or normalized == "x":
for item in outcomes:
if str(item["code"]).casefold() == "x":
return str(item["name"])
for item in outcomes: for item in outcomes:
if normalized == str(item["name"]).casefold(): if normalized == str(item["name"]).casefold():
return str(item["name"]) return str(item["name"])
@ -257,122 +265,69 @@ def format_matches(matches: list[dict], sport_alias: str, sport_label: str) -> s
lines.append(f"{i}️⃣ {m['home']} vs {m['away']}{start_str}\n{odds_str}\n") lines.append(f"{i}️⃣ {m['home']} vs {m['away']}{start_str}\n{odds_str}\n")
lines.append(f"Ставка: /bet {sport_alias} [номер] [исход] [ставка]") lines.append("Ставка: /bet [номер] [исход] [ставка]")
lines.append(f"Коротко после /matches {sport_alias}: /bet [номер] [исход] [ставка]") lines.append("Исход: 1/2. Можно писать ещё название команды или коэффициент.")
lines.append("Исход: 1/2, для ничьи X. Можно писать ещё название команды или коэффициент.")
return "\n".join(lines) return "\n".join(lines)
def _get_sport_label(key: str) -> str: def _get_sport_label(key: str) -> str:
labels = { return "🎮 Dota 2"
"epl": "⚽ EPL",
"ucl": "⚽ Champions League",
"laliga": "⚽ La Liga",
"bundesliga": "⚽ Bundesliga",
"seriea": "⚽ Serie A",
"ligue1": "⚽ Ligue 1",
"europa": "⚽ Europa League",
}
return labels.get(key, key)
async def get_formatted_matches(sport_alias: str | None) -> str: async def get_formatted_matches(sport_alias: str | None) -> str:
if not config.ODDS_API_KEY: matches = await fetch_matches()
return "⚠️ ODDS_API_KEY не задан. Ставки недоступны." return format_matches(matches, "dota2", "🎮 Dota 2")
if sport_alias and sport_alias in config.ODDS_SPORTS:
sport_key = config.ODDS_SPORTS[sport_alias]
matches = await fetch_matches(sport_key)
if not matches:
# Попробуем проверить, существует ли вообще этот спорт
sports = await _fetch_available_sports()
valid_keys = {s.get("key") for s in sports}
if sport_key not in valid_keys:
# Ищем похожий ключ
suggestions = [s for s in sports if sport_alias in s.get("key", "").lower()
or sport_alias in s.get("title", "").lower()]
if suggestions:
suggest_text = "\n".join(
f"{s['key']}{s.get('title', '')}"
for s in suggestions[:5]
)
return (
f"⚠️ Ключ {sport_key} не найден в API.\n"
f"Похожие виды спорта:\n{suggest_text}\n\n"
f"Обнови sport keys в config.py"
)
return (
f"⚠️ Ключ {sport_key} не найден в API. "
f"Проверь ODDS_SPORTS в config.py"
)
return format_matches(matches, sport_alias, _get_sport_label(sport_alias))
parts = []
for alias, sport_key in config.ODDS_SPORTS.items():
matches = await fetch_matches(sport_key)
parts.append(format_matches(matches, alias, _get_sport_label(alias)))
result = "\n\n".join(parts)
if all("матчей не найдено" in p for p in parts):
result += "\n\n💡 Если матчей нет — возможно, sport keys устарели. Проверь /sports_debug"
return result
async def get_match_by_index(sport_alias: str, index: int) -> dict | None: async def get_match_by_index(sport_alias: str, index: int) -> dict | None:
sport_key = config.ODDS_SPORTS.get(sport_alias) matches = await fetch_matches()
if not sport_key:
return None
matches = await fetch_matches(sport_key)
if 0 < index <= len(matches): if 0 < index <= len(matches):
return matches[index - 1] return matches[index - 1]
return None return None
def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: float) -> str: def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: float) -> tuple[bool, str]:
current = get_user_length(user_id) current = get_user_length(user_id)
if current is None: if current is None:
return "Сначала заведи счёт через /penis, братуха." return False, "Сначала заведи счёт через /penis, братуха."
if current <= 0: if current <= 0:
return "🚫 С кредитом ставки не принимаем." return False, "🚫 С кредитом ставки не принимаем."
if amount <= 0: if amount <= 0:
return "Ставка должна быть больше нуля." return False, "Ставка должна быть больше нуля."
if amount > config.BET_MAX_AMOUNT: if amount > config.BET_MAX_AMOUNT:
return f"Максимальная ставка — {config.BET_MAX_AMOUNT} см." return False, f"Максимальная ставка — {config.BET_MAX_AMOUNT} см."
if round(amount, 2) > round(current, 2):
if amount > current: return False, f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер."
return f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер."
team_odds = match["odds"].get(chosen_team) team_odds = match["odds"].get(chosen_team)
if team_odds is None: if team_odds is None:
return f"Исход не найден. Доступные: {describe_match_outcomes(match)}" return False, f"Исход не найден. Доступные: {describe_match_outcomes(match)}"
new_length = update_user_length(user_id, -amount) new_length = update_user_length(user_id, -amount)
if new_length is None: if new_length is None:
return "Ошибка БД." return False, "Ошибка БД."
now_ts = int(_time.time()) now_ts = int(_time.time())
try: try:
with sqlite3.connect(config.BET_DB_PATH) as conn: with get_conn() as conn:
conn.execute( cur = conn.cursor()
"""INSERT INTO bets (user_id, match_id, sport, home_team, away_team, cur.execute('''
chosen_team, amount, odds, status, payout, created_ts) INSERT INTO bets
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?)""", (user_id, match_id, sport, home_team, away_team,
(user_id, match["id"], sport, match["home"], match["away"], chosen_team, amount, odds, status, payout, created_ts)
chosen_team, amount, team_odds, now_ts), VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', 0, %s)
) ''', (
conn.commit() user_id, match["id"], sport, match["home"], match["away"],
except sqlite3.Error: chosen_team, amount, team_odds, now_ts,
))
except psycopg2.Error:
logger.exception("Failed to place bet") logger.exception("Failed to place bet")
update_user_length(user_id, amount) update_user_length(user_id, amount)
return "Ошибка БД." return False, "Ошибка БД."
potential = round(amount * team_odds, 1) potential = round(amount * team_odds, 1)
return ( return True, (
f"✅ Ставка принята!\n" f"✅ Ставка принята!\n"
f"🏟 {match['home']} vs {match['away']}\n"
f"📌 {chosen_team} (x{team_odds:.2f})\n" f"📌 {chosen_team} (x{team_odds:.2f})\n"
f"💰 Ставка: {amount:.1f} см\n" f"💰 Ставка: {amount:.1f} см\n"
f"🎯 Возможный выигрыш: {potential:.1f} см\n" f"🎯 Возможный выигрыш: {potential:.1f} см\n"
@ -382,13 +337,15 @@ def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: f
def get_user_bets(user_id: int) -> str: def get_user_bets(user_id: int) -> str:
try: try:
with sqlite3.connect(config.BET_DB_PATH) as conn: with get_conn() as conn:
conn.row_factory = sqlite3.Row cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
rows = conn.execute( cur.execute('''
"SELECT * FROM bets WHERE user_id = ? AND status = 'pending' ORDER BY created_ts DESC LIMIT 10", SELECT * FROM bets
(user_id,), WHERE user_id = %s AND status = 'pending'
).fetchall() ORDER BY created_ts DESC LIMIT 10
except sqlite3.Error: ''', (user_id,))
rows = cur.fetchall()
except psycopg2.Error:
return "Ошибка БД." return "Ошибка БД."
if not rows: if not rows:
@ -410,34 +367,26 @@ def get_user_bets(user_id: int) -> str:
def get_user_bet_history(user_id: int) -> str: def get_user_bet_history(user_id: int) -> str:
"""Полная история ставок за последний месяц.""" cutoff_ts = int(_time.time() - BET_HISTORY_DAYS * 86400)
cutoff_ts = int((_time.time()) - BET_HISTORY_DAYS * 86400)
try: try:
with sqlite3.connect(config.BET_DB_PATH) as conn: with get_conn() as conn:
conn.row_factory = sqlite3.Row cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
rows = conn.execute( cur.execute('''
"""SELECT * FROM bets SELECT * FROM bets
WHERE user_id = ? AND created_ts >= ? WHERE user_id = %s AND created_ts >= %s
ORDER BY created_ts DESC ORDER BY created_ts DESC LIMIT 50
LIMIT 50""", ''', (user_id, cutoff_ts))
(user_id, cutoff_ts), rows = cur.fetchall()
).fetchall() except psycopg2.Error:
except sqlite3.Error:
logger.exception("Failed to get bet history") logger.exception("Failed to get bet history")
return "Ошибка БД." return "Ошибка БД."
if not rows: if not rows:
return "📋 За последний месяц ставок не было." return "📋 За последний месяц ставок не было."
# Считаем статистику total_bet = total_won = total_lost = 0.0
total_bet = 0.0 wins = losses = pending = 0
total_won = 0.0
total_lost = 0.0
wins = 0
losses = 0
pending = 0
lines = [f"📋 История ставок за {BET_HISTORY_DAYS} дней:\n"] lines = [f"📋 История ставок за {BET_HISTORY_DAYS} дней:\n"]
for r in rows: for r in rows:
@ -450,7 +399,6 @@ def get_user_bet_history(user_id: int) -> str:
if status == "won": if status == "won":
wins += 1 wins += 1
# Для старых ставок payout может быть 0, пересчитываем
actual_payout = payout_raw if payout_raw > 0 else round(amount * odds_val, 1) actual_payout = payout_raw if payout_raw > 0 else round(amount * odds_val, 1)
total_won += actual_payout total_won += actual_payout
status_icon = "" status_icon = ""
@ -463,8 +411,7 @@ def get_user_bet_history(user_id: int) -> str:
else: else:
pending += 1 pending += 1
status_icon = "" status_icon = ""
potential = round(amount * odds_val, 1) result_text = f"ожидание → {round(amount * odds_val, 1):.1f} см"
result_text = f"ожидание → {potential:.1f} см"
created = "" created = ""
if r["created_ts"]: if r["created_ts"]:
@ -477,10 +424,8 @@ def get_user_bet_history(user_id: int) -> str:
f" 📅 {created}" f" 📅 {created}"
) )
# Итоговая статистика
net = total_won - total_lost net = total_won - total_lost
net_sign = "+" if net >= 0 else "" net_sign = "+" if net >= 0 else ""
lines.append("") lines.append("")
lines.append("━━━ 📊 СТАТИСТИКА ━━━") lines.append("━━━ 📊 СТАТИСТИКА ━━━")
lines.append(f"🎰 Всего ставок: {len(rows)}") lines.append(f"🎰 Всего ставок: {len(rows)}")
@ -492,174 +437,153 @@ def get_user_bet_history(user_id: int) -> str:
lines.append(f"🏆 Выиграно: +{total_won:.1f} см") lines.append(f"🏆 Выиграно: +{total_won:.1f} см")
lines.append(f"💸 Проиграно: -{total_lost:.1f} см") lines.append(f"💸 Проиграно: -{total_lost:.1f} см")
lines.append(f"📈 Итого: {net_sign}{net:.1f} см") lines.append(f"📈 Итого: {net_sign}{net:.1f} см")
if wins + losses > 0: if wins + losses > 0:
winrate = wins / (wins + losses) * 100 lines.append(f"📊 Винрейт: {wins / (wins + losses) * 100:.0f}%")
lines.append(f"📊 Винрейт: {winrate:.0f}%")
return "\n".join(lines) return "\n".join(lines)
def cleanup_old_bets() -> int: def cleanup_old_bets() -> int:
"""Удалить завершённые ставки старше BET_HISTORY_DAYS дней."""
cutoff_ts = int(_time.time() - BET_HISTORY_DAYS * 86400) cutoff_ts = int(_time.time() - BET_HISTORY_DAYS * 86400)
try: try:
with sqlite3.connect(config.BET_DB_PATH) as conn: with get_conn() as conn:
cursor = conn.execute( cur = conn.cursor()
"DELETE FROM bets WHERE status != 'pending' AND created_ts < ?", cur.execute(
"DELETE FROM bets WHERE status != 'pending' AND created_ts < %s",
(cutoff_ts,), (cutoff_ts,),
) )
conn.commit() deleted = cur.rowcount
deleted = cursor.rowcount
if deleted > 0: if deleted > 0:
logger.info(f"Cleaned up {deleted} old bets") logger.info(f"Cleaned up {deleted} old bets")
return deleted return deleted
except sqlite3.Error: except psycopg2.Error:
logger.exception("Failed to cleanup old bets") logger.exception("Failed to cleanup old bets")
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: try:
with sqlite3.connect(config.BET_DB_PATH) as conn: with get_conn() as conn:
conn.row_factory = sqlite3.Row cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
pending = conn.execute( cur.execute(
"SELECT DISTINCT match_id, sport FROM bets WHERE status = 'pending'" "SELECT * FROM bets WHERE status = 'pending' AND created_ts < %s",
).fetchall() (cutoff,),
except sqlite3.Error: )
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)
cur.execute("SELECT DISTINCT match_id FROM bets WHERE status = 'pending'")
pending = cur.fetchall()
except psycopg2.Error:
return notifications return notifications
if not pending: if not pending:
return notifications return notifications
all_matches = await fetch_liquipedia_matches()
match_map = {m["id"]: m for m in all_matches}
now_ts = int(_time.time()) now_ts = int(_time.time())
for row in pending: for row in pending:
match_id = row["match_id"] match_id = row["match_id"]
sport = row["sport"] event = match_map.get(match_id)
sport_key = config.ODDS_SPORTS.get(sport, sport) if not event or not event.get("completed"):
try:
url = f"{config.ODDS_API_BASE}/sports/{sport_key}/scores"
params = {"apiKey": config.ODDS_API_KEY, "daysFrom": 3}
async with aiohttp.ClientSession() as session:
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=15)) as resp:
if resp.status != 200:
logger.warning(f"Scores API returned {resp.status} for {sport_key}")
continue
scores_data = await resp.json()
except Exception:
logger.exception(f"Failed to fetch scores for {sport_key}")
continue continue
for event in scores_data: winner = event.get("winner")
if event.get("id") != match_id: if not winner:
continue continue
if not event.get("completed"):
continue
scores = event.get("scores") is_draw = (winner.casefold() in _DRAW_NAMES or winner == "Draw")
if not scores:
continue
winner = None try:
max_score = -1 with get_conn() as conn:
is_draw = False 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()
score_values = [] wcur = conn.cursor()
for s in scores: for bet in bets:
score_val = int(s.get("score", 0)) chosen = bet["chosen_team"]
score_values.append((s.get("name", ""), score_val)) if is_draw:
bet_won = chosen.casefold() in _DRAW_NAMES or chosen == "Draw"
else:
bet_won = chosen == winner
if len(score_values) >= 2 and score_values[0][1] == score_values[1][1]: if bet_won:
is_draw = True winnings = round(bet["amount"] * bet["odds"], 1)
# Для ничьи ищем "Draw" среди ставок 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",
for name, score_val in score_values: (winnings, now_ts, bet["id"]),
if score_val > max_score: )
max_score = score_val notifications.append((
winner = name 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")
if not winner:
continue
try:
with sqlite3.connect(config.BET_DB_PATH) as conn:
conn.row_factory = sqlite3.Row
bets = conn.execute(
"SELECT * FROM bets WHERE match_id = ? AND status = 'pending'",
(match_id,),
).fetchall()
for bet in bets:
chosen = bet["chosen_team"]
bet_won = False
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)
conn.execute(
"UPDATE bets SET status = 'won', payout = ?, resolved_ts = ? WHERE id = ?",
(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:
conn.execute(
"UPDATE bets SET status = 'lost', payout = 0, resolved_ts = ? WHERE id = ?",
(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']})"
)
conn.commit()
except sqlite3.Error:
logger.exception("Failed to settle bets")
# Очистка старых ставок
cleanup_old_bets() cleanup_old_bets()
return notifications return notifications
async def debug_sports() -> str: async def debug_sports() -> str:
"""Диагностика: показать доступные виды спорта из API.""" return "🎮 Dota 2 — Scraper Liquipedia активен. Odds API отключен."
if not config.ODDS_API_KEY:
return "⚠️ ODDS_API_KEY не задан."
sports = await _fetch_available_sports()
if not sports:
return "Не удалось получить список спортов от API."
# Показываем текущие ключи и проверяем их
current_keys = set(config.ODDS_SPORTS.values())
valid_keys = {s.get("key") for s in sports}
lines = ["🔍 Диагностика sport keys:\n"]
for alias, key in config.ODDS_SPORTS.items():
status = "" if key in valid_keys else "НЕ НАЙДЕН"
lines.append(f" {alias}{key} {status}")
# Показать доступные esports и football
lines.append("\n📋 Доступные eSports и футбол:")
for s in sports:
key = s.get("key", "")
title = s.get("title", "")
active = s.get("active", False)
if active and ("esport" in key.lower() or "soccer" in key.lower() or "football" in key.lower()):
marker = " ← ИСПОЛЬЗУЕТСЯ" if key in current_keys else ""
lines.append(f"{key}{title}{marker}")
return "\n".join(lines)

View file

@ -1,19 +1,20 @@
import random
import sqlite3
import logging import logging
import random
import psycopg2.extras
import config import config
from db import get_conn
from .economy import economy
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Символы слотов и их веса (чем выше вес — тем чаще выпадает)
SLOT_SYMBOLS = ["🍒", "🍋", "🔔", "💎", "7", "🍀"] SLOT_SYMBOLS = ["🍒", "🍋", "🔔", "💎", "7", "🍀"]
SLOT_WEIGHTS = [30, 25, 20, 12, 8, 5] # 🍒 чаще всего, 🍀 реже всего SLOT_WEIGHTS = [30, 25, 20, 12, 8, 5]
# Множители для выигрышей: {кол-во совпадений: (мин, макс)}
MULTIPLIERS = { MULTIPLIERS = {
2: (1.2, 1.8), # две одинаковых 2: (1.2, 1.8),
3: (2.0, 3.0), # три одинаковых (джекпот, редко) 3: (2.0, 3.0),
} }
CASINO_WIN_CHANCE = 0.35 CASINO_WIN_CHANCE = 0.35
@ -30,65 +31,53 @@ def spin_slots() -> tuple[list[str], int]:
counts = {} counts = {}
for s in reels: for s in reels:
counts[s] = counts.get(s, 0) + 1 counts[s] = counts.get(s, 0) + 1
max_match = max(counts.values()) return reels, max(counts.values())
return reels, max_match
def get_user_length(user_id: int) -> float | None: def get_user_length(user_id: int) -> float | None:
try: try:
with sqlite3.connect(config.PENIS_DB_PATH) as conn: with get_conn() as conn:
row = conn.execute( cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
"SELECT length FROM penis_stats WHERE user_id = ?", cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
(user_id,), row = cur.fetchone()
).fetchone() return float(row['balance']) if row else None
return float(row[0]) if row else None except Exception:
except sqlite3.Error: logger.exception("casino: failed to get balance")
logger.exception("casino: failed to get length")
return None return None
def update_user_length(user_id: int, delta: float) -> float | None: def update_user_length(user_id: int, delta: float) -> float | None:
try: try:
with sqlite3.connect(config.PENIS_DB_PATH) as conn: with get_conn() as conn:
row = conn.execute( cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
"SELECT length FROM penis_stats WHERE user_id = ?", cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
(user_id,), row = cur.fetchone()
).fetchone()
if row is None: if row is None:
return None return None
new_length = round(float(row[0]) + delta, 1) new_balance = round(float(row['balance']) + delta, 1)
conn.execute( if delta < 0 and new_balance < 0:
"UPDATE penis_stats SET length = ? WHERE user_id = ?", new_balance = 0.0
(new_length, user_id), wcur = conn.cursor()
wcur.execute(
'UPDATE user_balances SET balance = %s WHERE user_id = %s',
(new_balance, user_id),
) )
conn.commit() return new_balance
return new_length except Exception:
except sqlite3.Error: logger.exception("casino: failed to update balance")
logger.exception("casino: failed to update length")
return None return None
def play_casino(user_id: int, bet: float) -> str: def play_casino(user_id: int, bet: float) -> str:
current = get_user_length(user_id) current = economy.get_user_balance(user_id)
if current is None:
return "Ты ещё ни разу не крутил /penis, братуха. Сначала заведи счёт."
# Проверяем отрицательный баланс
if current <= 0:
return "🚫 С кредитом в казино не впускают, братуха. Иди наращивай через /penis."
# Проверяем максимум ставки
if bet > config.CASINO_MAX_BET: if bet > config.CASINO_MAX_BET:
return f"Максимальная ставка — {config.CASINO_MAX_BET} см, не жадничай." return f"Максимальная ставка — {config.CASINO_MAX_BET} см, не жадничай."
if bet <= 0: if bet <= 0:
return "Ставка должна быть больше нуля, фраер." return "Ставка должна быть больше нуля, фраер."
if round(bet, 2) > round(current, 2):
# Проверяем хватает ли
if bet > current:
return f"У тебя {current:.1f} см, а ставишь {bet:.1f}. Столько нет, фраер." return f"У тебя {current:.1f} см, а ставишь {bet:.1f}. Столько нет, фраер."
# Крутим
reels, matches = spin_slots() reels, matches = spin_slots()
slots_display = " | ".join(reels) slots_display = " | ".join(reels)
@ -96,9 +85,8 @@ def play_casino(user_id: int, bet: float) -> str:
min_mult, max_mult = MULTIPLIERS[matches] min_mult, max_mult = MULTIPLIERS[matches]
multiplier = round(random.uniform(min_mult, max_mult), 1) multiplier = round(random.uniform(min_mult, max_mult), 1)
winnings = round(bet * multiplier, 1) winnings = round(bet * multiplier, 1)
new_length = update_user_length(user_id, winnings) economy.update_balance(user_id, winnings, 'casino_win', f'Выигрыш в казино x{multiplier}')
if new_length is None: new_balance = economy.get_user_balance(user_id)
return "Ошибка БД. Попробуй позже."
if matches == 3: if matches == 3:
return ( return (
@ -106,22 +94,21 @@ def play_casino(user_id: int, bet: float) -> str:
f"🎉 ДЖЕКПОТ!!! Три одинаковых!\n" f"🎉 ДЖЕКПОТ!!! Три одинаковых!\n"
f"Множитель: x{multiplier}\n" f"Множитель: x{multiplier}\n"
f"Выигрыш: +{winnings:.1f} см\n" f"Выигрыш: +{winnings:.1f} см\n"
f"📏 Текущий размер: {new_length:.1f} см" f"💰 Баланс: {new_balance:.1f} см"
) )
return ( return (
f"🎰 {slots_display}\n\n" f"🎰 {slots_display}\n\n"
f"✅ Выигрыш! Две совпали.\n" f"✅ Выигрыш! Две совпали.\n"
f"Множитель: x{multiplier}\n" f"Множитель: x{multiplier}\n"
f"Выигрыш: +{winnings:.1f} см\n" f"Выигрыш: +{winnings:.1f} см\n"
f"📏 Текущий размер: {new_length:.1f} см" f"💰 Баланс: {new_balance:.1f} см"
) )
else: else:
new_length = update_user_length(user_id, -bet) economy.update_balance(user_id, -bet, 'casino_loss', f'Проигрыш в казино -{bet} см')
if new_length is None: new_balance = economy.get_user_balance(user_id)
return "Ошибка БД. Попробуй позже."
return ( return (
f"🎰 {slots_display}\n\n" f"🎰 {slots_display}\n\n"
f"❌ Мимо, братуха.\n" f"❌ Мимо, братуха.\n"
f"Проигрыш: -{bet:.1f} см\n" f"Проигрыш: -{bet:.1f} см\n"
f"📏 Текущий размер: {new_length:.1f} см" f"💰 Баланс: {new_balance:.1f} см"
) )

716
games/economy.py Normal file
View file

@ -0,0 +1,716 @@
import logging
import random
from datetime import datetime, timedelta
from typing import Dict
import psycopg2.extras
from db import get_conn
logger = logging.getLogger(__name__)
class EconomyManager:
def __init__(self):
self.init_db()
def init_db(self):
with get_conn() as conn:
cur = conn.cursor()
cur.execute('''
CREATE TABLE IF NOT EXISTS user_balances (
user_id BIGINT PRIMARY KEY,
balance REAL DEFAULT 0.0,
daily_income REAL DEFAULT 0.0,
last_daily_reset DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS deposits (
id SERIAL PRIMARY KEY,
user_id BIGINT,
amount REAL,
interest_rate REAL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
matures_at TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS loans (
id SERIAL PRIMARY KEY,
user_id BIGINT,
amount REAL,
interest_rate REAL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
due_at TIMESTAMP,
is_repaid BOOLEAN DEFAULT FALSE
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS transactions (
id SERIAL PRIMARY KEY,
from_user_id BIGINT,
to_user_id BIGINT,
amount REAL,
transaction_type TEXT,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS taxes (
id SERIAL PRIMARY KEY,
user_id BIGINT,
amount REAL,
tax_date DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS economy_settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS daily_activity (
id SERIAL PRIMARY KEY,
user_id BIGINT,
activity_date DATE,
message_count INTEGER DEFAULT 0,
last_activity_time TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, activity_date)
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS central_bank (
id INTEGER PRIMARY KEY DEFAULT 1,
capital REAL DEFAULT 1000.0,
total_taxes_collected REAL DEFAULT 0.0,
total_inflation_adjustments REAL DEFAULT 0.0,
total_emissions REAL DEFAULT 0.0,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (id)
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS penis_stats (
user_id BIGINT PRIMARY KEY,
display_name TEXT NOT NULL DEFAULT '',
last_used_ts BIGINT
)
''')
default_settings = {
'central_bank_rate': '7.5',
'deposit_min_amount': '10.0',
'deposit_min_days': '7',
'loan_max_amount': '100.0',
'loan_interest_rate': '15.0',
'tax_rate': '13.0',
'transfer_limit_percent': '20.0',
'inflation_rate': '5.0',
'vanomasa_cooldown': '30',
'message_reward_rate': '0.01',
'last_activity_payout': '',
}
for key, value in default_settings.items():
cur.execute('''
INSERT INTO economy_settings (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO NOTHING
''', (key, value))
cur.execute('''
INSERT INTO central_bank (id, capital) VALUES (1, 1000.0)
ON CONFLICT (id) DO NOTHING
''')
def get_user_balance(self, user_id: int) -> float:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
result = cur.fetchone()
return result['balance'] if result else 0.0
def update_balance(self, user_id: int, amount: float, transaction_type: str, description: str = ""):
with get_conn() as conn:
cur = conn.cursor()
cur.execute('''
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
VALUES (%s, %s, 0.0, CURRENT_DATE)
ON CONFLICT (user_id) DO UPDATE SET balance = user_balances.balance + %s
''', (user_id, amount, amount))
cur.execute('''
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
VALUES (%s, %s, %s, %s, %s)
''', (user_id, user_id, amount, transaction_type, description))
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):
return False
with get_conn() as conn:
cur = conn.cursor()
cur.execute(
'UPDATE user_balances SET balance = balance - %s WHERE user_id = %s',
(amount, from_user_id),
)
cur.execute('''
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
VALUES (%s, %s, 0.0, CURRENT_DATE)
ON CONFLICT (user_id) DO UPDATE SET balance = user_balances.balance + %s
''', (to_user_id, amount, amount))
cur.execute('''
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
VALUES (%s, %s, %s, 'transfer', %s)
''', (from_user_id, to_user_id, amount, description))
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]:
if amount <= 0:
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} см."
central_bank_rate = float(self.get_setting('central_bank_rate'))
matures_at = datetime.now() + timedelta(days=days)
with get_conn() as conn:
cur = conn.cursor()
cur.execute(
'UPDATE user_balances SET balance = balance - %s WHERE user_id = %s',
(amount, user_id),
)
cur.execute('''
UPDATE central_bank SET capital = capital + %s, last_updated = CURRENT_TIMESTAMP
WHERE id = 1
''', (amount,))
cur.execute('''
INSERT INTO deposits (user_id, amount, interest_rate, matures_at)
VALUES (%s, %s, %s, %s)
''', (user_id, amount, central_bank_rate, matures_at))
logger.info(f"Deposit created: user {user_id}, amount {amount}, rate {central_bank_rate}%")
return True, "Вклад успешно открыт!"
def create_loan(self, user_id: int, amount: float, days: int) -> bool:
if amount <= 0:
return False
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):
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:
cur = conn.cursor()
cur.execute('''
UPDATE central_bank SET capital = capital - %s, last_updated = CURRENT_TIMESTAMP
WHERE id = 1
''', (amount,))
cur.execute('''
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
VALUES (%s, %s, 0.0, CURRENT_DATE)
ON CONFLICT (user_id) DO UPDATE SET balance = user_balances.balance + %s
''', (user_id, amount, amount))
cur.execute('''
INSERT INTO loans (user_id, amount, interest_rate, due_at)
VALUES (%s, %s, %s, %s)
''', (user_id, amount, loan_rate, due_at))
logger.info(f"Loan created: user {user_id}, amount {amount}, rate {loan_rate}%")
return True
def repay_loan(self, user_id: int, loan_id: int) -> bool:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
'SELECT amount, interest_rate, is_repaid FROM loans WHERE id = %s AND user_id = %s',
(loan_id, user_id),
)
loan = cur.fetchone()
if not loan or loan['is_repaid']:
return False
total_repayment = round(loan['amount'] * (1 + loan['interest_rate'] / 100), 2)
balance = self.get_user_balance(user_id)
if round(balance, 2) < total_repayment:
return False
wcur = conn.cursor()
wcur.execute(
'UPDATE user_balances SET balance = balance - %s WHERE user_id = %s',
(total_repayment, user_id),
)
wcur.execute('''
UPDATE central_bank SET capital = capital + %s, last_updated = CURRENT_TIMESTAMP
WHERE id = 1
''', (total_repayment,))
wcur.execute('UPDATE loans SET is_repaid = TRUE WHERE id = %s', (loan_id,))
logger.info(f"Loan repaid: user {user_id}, loan {loan_id}, amount {total_repayment}")
return True
def collect_daily_taxes(self):
tax_rate = float(self.get_setting('tax_rate')) / 100
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('''
SELECT user_id, daily_income FROM user_balances
WHERE daily_income > 0 AND (last_daily_reset != CURRENT_DATE OR last_daily_reset IS NULL)
''')
users = cur.fetchall()
total_taxes_collected = 0.0
wcur = conn.cursor()
for row in users:
user_id = row['user_id']
daily_income = row['daily_income']
tax_amount = daily_income * tax_rate
if tax_amount <= 0:
continue
wcur.execute('''
UPDATE user_balances
SET balance = balance - %s, daily_income = 0, last_daily_reset = CURRENT_DATE
WHERE user_id = %s
''', (tax_amount, user_id))
wcur.execute(
'INSERT INTO taxes (user_id, amount, tax_date) VALUES (%s, %s, CURRENT_DATE)',
(user_id, tax_amount),
)
total_taxes_collected += tax_amount
if total_taxes_collected > 0:
wcur.execute('''
UPDATE central_bank
SET capital = capital + %s, total_taxes_collected = total_taxes_collected + %s
WHERE id = 1
''', (total_taxes_collected, total_taxes_collected))
logger.info(f"Daily taxes collected: {total_taxes_collected:.2f} cm from {len(users)} users")
def count_message(self, user_id: int):
today = datetime.now().strftime('%Y-%m-%d')
with get_conn() as conn:
cur = conn.cursor()
cur.execute('''
INSERT INTO daily_activity (user_id, activity_date, message_count, last_activity_time)
VALUES (%s, %s, 1, CURRENT_TIMESTAMP)
ON CONFLICT (user_id, activity_date) DO UPDATE SET
message_count = daily_activity.message_count + 1,
last_activity_time = CURRENT_TIMESTAMP
''', (user_id, today))
def pay_daily_activity_rewards(self) -> int:
reward_rate = float(self.get_setting('message_reward_rate'))
last_payout = self.get_setting('last_activity_payout')
today = datetime.now().strftime('%Y-%m-%d')
if last_payout == today:
return 0
yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('''
SELECT user_id, message_count FROM daily_activity
WHERE activity_date = %s AND message_count > 0
''', (yesterday,))
users = cur.fetchall()
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)
VALUES (%s, %s, 0.0, CURRENT_DATE)
ON CONFLICT (user_id) DO UPDATE SET
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
total_paid += reward
wcur.execute(
'UPDATE economy_settings SET value = %s WHERE key = %s',
(today, 'last_activity_payout'),
)
logger.info(f"Activity rewards paid: {total_paid:.2f} cm to {len(users)} users")
return len(users)
def get_central_bank_stats(self) -> Dict:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('''
SELECT capital, total_taxes_collected, total_inflation_adjustments, total_emissions
FROM central_bank WHERE id = 1
''')
result = cur.fetchone()
if result:
return {
'capital': result['capital'],
'total_taxes_collected': result['total_taxes_collected'],
'total_inflation_adjustments': result['total_inflation_adjustments'],
'total_emissions': result['total_emissions'],
}
return {
'capital': 1000.0,
'total_taxes_collected': 0.0,
'total_inflation_adjustments': 0.0,
'total_emissions': 0.0,
}
def update_central_bank(self, capital_change: float, transaction_type: str, description: str = ""):
with get_conn() as conn:
cur = conn.cursor()
cur.execute('''
UPDATE central_bank SET capital = capital + %s, last_updated = CURRENT_TIMESTAMP
WHERE id = 1
''', (capital_change,))
if transaction_type == 'tax':
cur.execute('''
UPDATE central_bank
SET total_taxes_collected = total_taxes_collected + %s WHERE id = 1
''', (abs(capital_change),))
elif transaction_type == 'inflation':
cur.execute('''
UPDATE central_bank
SET total_inflation_adjustments = total_inflation_adjustments + %s WHERE id = 1
''', (abs(capital_change),))
elif transaction_type == 'emission':
cur.execute('''
UPDATE central_bank
SET total_emissions = total_emissions + %s WHERE id = 1
''', (abs(capital_change),))
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):
return False
self.update_central_bank(-amount, 'emission', reason)
logger.info(f"Money emitted: {amount} cm ({reason})")
return True
def vanomasa(self) -> bool:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT value FROM economy_settings WHERE key = %s", ('last_vanomasa',))
last_vanomasa = cur.fetchone()
if last_vanomasa and last_vanomasa['value']:
try:
last_date = datetime.strptime(last_vanomasa['value'], '%Y-%m-%d')
cooldown_days = int(self.get_setting('vanomasa_cooldown'))
if datetime.now() - last_date < timedelta(days=cooldown_days):
return False
except ValueError:
pass
today = datetime.now().strftime('%Y-%m-%d')
wcur = conn.cursor()
wcur.execute('UPDATE user_balances SET balance = 0, daily_income = 0')
wcur.execute('''
INSERT INTO economy_settings (key, value, updated_at) VALUES (%s, %s, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
''', ('last_vanomasa', today))
logger.warning("VANOMASA: All balances reset to zero!")
return True
def get_setting(self, key: str) -> str:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('SELECT value FROM economy_settings WHERE key = %s', (key,))
result = cur.fetchone()
return result['value'] if result else '0'
def update_setting(self, key: str, value: str):
with get_conn() as conn:
cur = conn.cursor()
cur.execute('''
INSERT INTO economy_settings (key, value, updated_at) VALUES (%s, %s, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
''', (key, value))
def get_user_stats(self, user_id: int) -> Dict:
today = datetime.now().strftime('%Y-%m-%d')
yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
row = cur.fetchone()
balance = row['balance'] if row else 0.0
cur.execute('''
SELECT COUNT(*) AS cnt, COALESCE(SUM(amount), 0) AS total
FROM deposits WHERE user_id = %s AND is_active = TRUE
''', (user_id,))
deposits = cur.fetchone()
cur.execute('''
SELECT COUNT(*) AS cnt, COALESCE(SUM(amount), 0) AS total
FROM loans WHERE user_id = %s AND is_repaid = FALSE
''', (user_id,))
loans = cur.fetchone()
cur.execute(
'SELECT message_count FROM daily_activity WHERE user_id = %s AND activity_date = %s',
(user_id, today),
)
today_row = cur.fetchone()
cur.execute(
'SELECT message_count FROM daily_activity WHERE user_id = %s AND activity_date = %s',
(user_id, yesterday),
)
yesterday_row = cur.fetchone()
return {
'balance': balance,
'active_deposits_count': deposits['cnt'] or 0,
'active_deposits_sum': deposits['total'] or 0.0,
'active_loans_count': loans['cnt'] or 0,
'active_loans_sum': loans['total'] or 0.0,
'today_messages': today_row['message_count'] if today_row else 0,
'yesterday_messages': yesterday_row['message_count'] if yesterday_row else 0,
}
def regulate_inflation(self):
inflation_rate = float(self.get_setting('inflation_rate'))
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('SELECT SUM(balance) AS total_balance, COUNT(*) AS user_count FROM user_balances')
result = cur.fetchone()
total_supply = result['total_balance'] or 0.0
cur.execute('SELECT capital FROM central_bank WHERE id = 1')
cb_result = cur.fetchone()
cb_capital = cb_result['capital'] if cb_result else 1000.0
if inflation_rate > 10.0 and total_supply > 1000 and cb_capital > 100:
buyback_amount = min(total_supply * 0.05, cb_capital * 0.1)
self.update_central_bank(-buyback_amount, 'inflation', f'Anti-inflation: {buyback_amount:.2f} cm')
new_tax_rate = min(20.0, 13.0 + (inflation_rate - 10.0) * 0.5)
self.update_setting('tax_rate', str(new_tax_rate))
logger.info(f"Inflation regulation: bought back {buyback_amount:.2f} cm, tax={new_tax_rate}%")
elif inflation_rate < 2.0 and total_supply < 500 and cb_capital > 200:
emission_amount = cb_capital * 0.05
self.update_central_bank(-emission_amount, 'emission', f'Stimulus: {emission_amount:.2f} cm')
new_tax_rate = max(10.0, 13.0 - (2.0 - inflation_rate) * 1.5)
self.update_setting('tax_rate', str(new_tax_rate))
logger.info(f"Economic stimulus: emitted {emission_amount:.2f} cm, tax={new_tax_rate}%")
def play_penis(self, user_id: int, display_name: str, now_ts: int,
start_length: float, cooldown_seconds: int,
min_delta: float, max_delta: float) -> dict:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('SELECT balance FROM user_balances WHERE user_id = %s', (user_id,))
row = cur.fetchone()
current_balance = row['balance'] if row else start_length
cur.execute(
'SELECT last_used_ts, display_name FROM penis_stats WHERE user_id = %s',
(user_id,),
)
ps_row = cur.fetchone()
last_used_ts = ps_row['last_used_ts'] if ps_row else None
if last_used_ts is not None:
next_ts = int(last_used_ts) + cooldown_seconds
if now_ts < next_ts:
if ps_row and ps_row['display_name'] != display_name:
wcur = conn.cursor()
wcur.execute(
'UPDATE penis_stats SET display_name = %s WHERE user_id = %s',
(display_name, user_id),
)
return {'allowed': False, 'left_seconds': next_ts - now_ts}
delta = round(random.uniform(min_delta, max_delta), 1)
sign = random.choice([-1, 1])
new_balance = round(max(0.1, current_balance + sign * delta), 1)
wcur = conn.cursor()
wcur.execute('''
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
VALUES (%s, %s, 0.0, CURRENT_DATE)
ON CONFLICT (user_id) DO UPDATE SET balance = %s
''', (user_id, new_balance, new_balance))
sign_label = f'+{delta:.1f}' if sign > 0 else f'-{delta:.1f}'
wcur.execute('''
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
VALUES (%s, %s, %s, 'penis_game', %s)
''', (user_id, user_id, sign * delta, f'Игра /penis: {sign_label} см'))
if ps_row is None:
wcur.execute(
'INSERT INTO penis_stats (user_id, display_name, last_used_ts) VALUES (%s, %s, %s)',
(user_id, display_name, now_ts),
)
else:
wcur.execute(
'UPDATE penis_stats SET display_name = %s, last_used_ts = %s WHERE user_id = %s',
(display_name, now_ts, user_id),
)
return {'allowed': True, 'new_balance': new_balance, 'delta': delta, 'sign': sign}
def get_penis_top(self, limit: int) -> list:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('''
SELECT ub.user_id, ps.display_name, ub.balance
FROM user_balances ub
INNER JOIN penis_stats ps ON ub.user_id = ps.user_id
ORDER BY ub.balance DESC, ub.user_id ASC
LIMIT %s
''', (limit,))
rows = cur.fetchall()
return [{'user_id': r['user_id'], 'display_name': r['display_name'], 'balance': r['balance']}
for r in rows]
def process_matured_deposits(self) -> int:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('''
SELECT id, user_id, amount, interest_rate
FROM deposits
WHERE is_active = TRUE AND matures_at <= NOW()
''')
matured = cur.fetchall()
paid = 0
wcur = conn.cursor()
for row in matured:
dep_id = row['id']
user_id = row['user_id']
amount = row['amount']
rate = row['interest_rate']
payout = round(amount * (1 + rate / 100 * 1 / 365 * 30), 2)
wcur.execute('''
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset)
VALUES (%s, %s, 0.0, CURRENT_DATE)
ON CONFLICT (user_id) DO UPDATE SET balance = user_balances.balance + %s
''', (user_id, payout, payout))
wcur.execute('''
UPDATE central_bank SET capital = capital - %s, last_updated = CURRENT_TIMESTAMP
WHERE id = 1
''', (payout,))
wcur.execute('UPDATE deposits SET is_active = FALSE WHERE id = %s', (dep_id,))
wcur.execute('''
INSERT INTO transactions (from_user_id, to_user_id, amount, transaction_type, description)
VALUES (0, %s, %s, 'deposit_payout', %s)
''', (user_id, payout, f'Выплата вклада #{dep_id}: {amount:.1f} + проценты = {payout:.2f} см'))
paid += 1
if paid:
logger.info(f"Matured deposits paid out: {paid}")
return paid
economy = EconomyManager()

View file

@ -30,7 +30,7 @@ async def generate_fortune(topic: str) -> tuple[str, str, str]:
} }
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as resp: async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp:
data = await resp.json() data = await resp.json()
text = data["choices"][0]["message"]["content"].strip() text = data["choices"][0]["message"]["content"].strip()

View file

@ -18,6 +18,7 @@ CAPTIONS = [
"Шо ты блять псюнчик решил свой подергать?", "Шо ты блять псюнчик решил свой подергать?",
"Решил подрочить сука?", "Решил подрочить сука?",
"гнойный писюн у тебя кнч, да", "гнойный писюн у тебя кнч, да",
"Ух нихуя, это ШПАЧИХА!?",
] ]
LESAINT_POSTS_API = "https://lesaintdesseins.fr/wp-json/wp/v2/posts" LESAINT_POSTS_API = "https://lesaintdesseins.fr/wp-json/wp/v2/posts"
@ -302,12 +303,12 @@ async def handle_nude_cmd(message: types.Message):
if image_bytes: if image_bytes:
photo = types.BufferedInputFile(image_bytes, filename=filename) photo = types.BufferedInputFile(image_bytes, filename=filename)
await message.answer_photo(photo=photo, caption=caption) await message.answer_photo(photo=photo, caption=caption, has_spoiler=True)
return return
try: try:
photo = types.URLInputFile(selected["image_url"]) photo = types.URLInputFile(selected["image_url"])
await message.answer_photo(photo=photo, caption=caption) await message.answer_photo(photo=photo, caption=caption, has_spoiler=True)
except Exception: except Exception:
logger.exception("Failed to send photo by URL") logger.exception("Failed to send photo by URL")
await message.answer( await message.answer(

View file

@ -55,28 +55,6 @@ CAPTIONS = [
"Я нейросеть, помогите, меня держат в заложниках" "Я нейросеть, помогите, меня держат в заложниках"
] ]
async def handle_uwu_cmd(message: types.Message):
args = message.text.split(maxsplit=1)
if len(args) > 1 and args[1].strip() == "-h":
help_msg = (
"🐈 <b>Справка по тегам e621:</b>\n\n"
"Перечислите любые теги через пробел после команды.\n"
"• <b>rating</b>: <code>rating:safe</code> (безопасные), <code>rating:questionable</code>, <code>rating:explicit</code> (NSFW)\n"
"• <b>score</b>: <code>score:>500</code> (искать популярные посты с рейтингом больше 500)\n"
"• Исключить тег: поставьте минус, например <code>-fox</code>\n\n"
"<i>Пример:</i> <code>/uwu wolf -rating:explicit score:>100</code>\n"
"<i>По умолчанию:</i> <code>rating:safe score:>500 -animated</code>"
)
await message.reply(help_msg, parse_mode="HTML")
return
tags = "rating:safe score:>500 -animated"
if len(args) > 1:
tags = args[1].strip()
tags += " order:random score:>300"
async def fetch_uwu_post(tags: str) -> dict: async def fetch_uwu_post(tags: str) -> dict:
url = "https://e621.net/posts.json" url = "https://e621.net/posts.json"
params = { params = {
@ -106,6 +84,7 @@ async def fetch_uwu_post(tags: str) -> dict:
post = posts[0] post = posts[0]
file_url = post.get("file", {}).get("url") file_url = post.get("file", {}).get("url")
ext = post.get("file", {}).get("ext", "png") ext = post.get("file", {}).get("ext", "png")
rating = post.get("rating", "s") # s=safe, q=questionable, e=explicit
if not file_url: if not file_url:
raise Exception("У найденного поста нет прямого URL изображения :(") raise Exception("У найденного поста нет прямого URL изображения :(")
@ -113,12 +92,20 @@ async def fetch_uwu_post(tags: str) -> dict:
return { return {
"url": file_url, "url": file_url,
"ext": ext, "ext": ext,
"caption": caption "caption": caption,
"rating": rating
} }
async def fetch_furtok_feed(safe: bool = True, page: int = 1) -> list[dict]: async def fetch_furtok_feed(safe: bool = True, page: int = 1, extra_tags: str = "") -> list[dict]:
is_custom = bool(extra_tags and extra_tags.strip())
rating = "rating:safe" if safe else "-rating:safe" rating = "rating:safe" if safe else "-rating:safe"
tags = f"{rating} animated order:random score:>200"
if is_custom:
# Кастомный запрос: без принудительного animated, поддержка картинок
tags = f"{extra_tags.strip()} order:random score:>200"
else:
# Стандартная лента: только видео
tags = f"{rating} animated order:random score:>200"
url = "https://e621.net/posts.json" url = "https://e621.net/posts.json"
params = { params = {
@ -142,18 +129,24 @@ async def fetch_furtok_feed(safe: bool = True, page: int = 1) -> list[dict]:
raise Exception("Не удалось загрузить ленту :(") raise Exception("Не удалось загрузить ленту :(")
data = await resp.json() data = await resp.json()
VIDEO_EXTS = ("gif", "webm", "mp4")
IMAGE_EXTS = ("png", "jpg", "jpeg", "webp")
allowed_exts = VIDEO_EXTS + IMAGE_EXTS if is_custom else VIDEO_EXTS
posts = data.get("posts", []) posts = data.get("posts", [])
feed = [] feed = []
for post in posts: for post in posts:
file_url = post.get("file", {}).get("url") file_url = post.get("file", {}).get("url")
ext = post.get("file", {}).get("ext", "") ext = post.get("file", {}).get("ext", "")
if not file_url or ext not in ("gif", "webm", "mp4"): if not file_url or ext not in allowed_exts:
continue continue
sample_url = post.get("sample", {}).get("url") or file_url sample_url = post.get("sample", {}).get("url") or file_url
media_type = "image" if ext in IMAGE_EXTS else "video"
feed.append({ feed.append({
"url": file_url, "url": file_url,
"sample": sample_url, "sample": sample_url,
"ext": ext, "ext": ext,
"type": media_type,
"score": post.get("score", {}).get("total", 0), "score": post.get("score", {}).get("total", 0),
"fav_count": post.get("fav_count", 0), "fav_count": post.get("fav_count", 0),
}) })
@ -187,16 +180,17 @@ async def handle_uwu_cmd(message: types.Message):
file_url = post["url"] file_url = post["url"]
ext = post["ext"] ext = post["ext"]
caption = post["caption"] caption = post["caption"]
spoiler = post.get("rating") in ("e", "q") # спойлер для explicit и questionable
if ext in ("gif", "webm", "mp4"): if ext in ("gif", "webm", "mp4"):
# Для анимаций: отправляем caption отдельным сообщением, # Для анимаций: отправляем caption отдельным сообщением,
# а саму гифку/видео через URL напрямую — так Telegram # а саму гифку/видео через URL напрямую — так Telegram
# корректно показывает её как анимацию, а не как файл # корректно показывает её как анимацию, а не как файл
await message.answer(caption) await message.answer(caption)
await message.answer_animation(animation=file_url) await message.answer_animation(animation=file_url, has_spoiler=spoiler)
elif ext in ("png", "jpg", "jpeg", "webp"): elif ext in ("png", "jpg", "jpeg", "webp"):
input_file = types.URLInputFile(file_url) input_file = types.URLInputFile(file_url)
await message.answer_photo(photo=input_file, caption=caption) await message.answer_photo(photo=input_file, caption=caption, has_spoiler=spoiler)
else: else:
input_file = types.URLInputFile(file_url) input_file = types.URLInputFile(file_url)
await message.answer_document(document=input_file, caption=caption) await message.answer_document(document=input_file, caption=caption)

597
main.py
View file

@ -1,5 +1,5 @@
# Системные импорты # Системные импорты
import asyncio, json, logging, os, re, random, sqlite3, 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
@ -39,8 +39,10 @@ from games.betting import (
resolve_match_outcome, resolve_match_outcome,
settle_bets, settle_bets,
) )
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__)
@ -58,6 +60,12 @@ def _is_supported_image_message(message: Message) -> bool:
) )
) )
def _is_watch_image_message(message: Message) -> bool:
if not _is_supported_image_message(message):
return False
caption = (message.caption or "").strip()
return bool(re.match(r"^/watch(?:@[A-Za-z0-9_]+)?(?:\s|$)", caption, flags=re.IGNORECASE))
async def handle_unmatched_message_debug(message: Message): async def handle_unmatched_message_debug(message: Message):
logger.warning( logger.warning(
@ -132,23 +140,6 @@ class LessonSlot:
# --- Инициализация и вспомогательные функции --- # --- Инициализация и вспомогательные функции ---
def init_penis_db() -> None:
"""Инициализация БД для миниигры."""
Path(config.PENIS_DB_PATH).parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(config.PENIS_DB_PATH) as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS penis_stats (
user_id INTEGER PRIMARY KEY,
display_name TEXT NOT NULL,
length REAL NOT NULL,
last_used_ts INTEGER
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_penis_stats_length ON penis_stats(length DESC)")
conn.commit()
def init_polychaetsi_storage() -> None: def init_polychaetsi_storage() -> None:
"""Инициализация json-хранилища статистики слова 'получается'.""" """Инициализация json-хранилища статистики слова 'получается'."""
path = config.POLYCHAETSI_STATS_PATH path = config.POLYCHAETSI_STATS_PATH
@ -597,11 +588,14 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
start_today = lesson_start_for(0) start_today = lesson_start_for(0)
end_today = lesson_end_today() end_today = lesson_end_today()
if start_today <= now < end_today: if start_today <= now < end_today:
next_dt = None
for i in range(1, 8): for i in range(1, 8):
next_day = (day + i) % 7 next_day = (day + i) % 7
if next_day in lesson_days: if next_day in lesson_days:
next_dt = lesson_start_for(i) next_dt = lesson_start_for(i)
break break
if next_dt is None:
return "Следующий урок не найден."
delta = next_dt - now delta = next_dt - now
total_seconds = int(delta.total_seconds()) total_seconds = int(delta.total_seconds())
days = total_seconds // 86400 days = total_seconds // 86400
@ -613,6 +607,7 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
f"Мозг начнет догнивать через {days} дн {hours} ч {minutes} мин" f"Мозг начнет догнивать через {days} дн {hours} ч {minutes} мин"
) )
delta = None
for i in range(0, 8): for i in range(0, 8):
next_day = (day + i) % 7 next_day = (day + i) % 7
if next_day in lesson_days: if next_day in lesson_days:
@ -621,12 +616,16 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
delta = candidate - now delta = candidate - now
break break
if delta is None:
return "Ближайший урок не найден, кайфуй."
total_seconds = int(delta.total_seconds()) total_seconds = int(delta.total_seconds())
days = total_seconds // 86400 days = total_seconds // 86400
hours = (total_seconds % 86400) // 3600 hours = (total_seconds % 86400) // 3600
minutes = (total_seconds % 3600) // 60 minutes = (total_seconds % 3600) // 60
return f"Твой мозг окончательно сгниет, а очко порвется через: {days} дн {hours} ч {minutes} мин" return f"Твой мозг окончательно сгниет, а очко порвется через: {days} дн {hours} ч {minutes} мин"
def _prepare_magnit_query(value: str) -> str: def _prepare_magnit_query(value: str) -> str:
value = re.sub(r"(?<=[a-zа-яё])(?=[A-ZА-ЯЁ])", " ", value) value = re.sub(r"(?<=[a-zа-яё])(?=[A-ZА-ЯЁ])", " ", value)
value = re.sub(r"[_-]+", " ", value) value = re.sub(r"[_-]+", " ", value)
@ -715,61 +714,29 @@ def play_penis(user_id: int, user_name: str | None = None, now: datetime | None
display_name = user_name.strip() if user_name and user_name.strip() else f"user_{user_id}" display_name = user_name.strip() if user_name and user_name.strip() else f"user_{user_id}"
try: try:
with sqlite3.connect(config.PENIS_DB_PATH) as conn: result = economy.play_penis(
conn.row_factory = sqlite3.Row user_id, display_name, now_ts,
row = conn.execute( config.PENIS_START_LENGTH, config.PENIS_COOLDOWN_SECONDS,
"SELECT length, last_used_ts, display_name FROM penis_stats WHERE user_id = ?", config.PENIS_MIN_DELTA, config.PENIS_MAX_DELTA,
(user_id,), )
).fetchone() except Exception:
if row is None:
current_length = config.PENIS_START_LENGTH
last_used_ts = None
else:
current_length = float(row["length"])
last_used_ts = row["last_used_ts"]
stored_name = row["display_name"]
if not user_name and isinstance(stored_name, str) and stored_name.strip():
display_name = stored_name.strip()
if last_used_ts is not None:
next_ts = int(last_used_ts) + config.PENIS_COOLDOWN_SECONDS
if now_ts < next_ts:
if row is not None and row["display_name"] != display_name:
conn.execute(
"UPDATE penis_stats SET display_name = ? WHERE user_id = ?",
(display_name, user_id),
)
left_seconds = next_ts - now_ts
hours = left_seconds // 3600
minutes = (left_seconds % 3600) // 60
conn.commit()
return False, f"Сегодня уже кидал. Попробуй через {hours} ч {minutes} мин."
delta = round(random.uniform(config.PENIS_MIN_DELTA, config.PENIS_MAX_DELTA), 1)
sign = random.choice([-1, 1])
new_length = round(max(0.1, current_length + sign * delta), 1)
if row is None:
conn.execute(
"INSERT INTO penis_stats(user_id, display_name, length, last_used_ts) VALUES(?, ?, ?, ?)",
(user_id, display_name, new_length, now_ts),
)
else:
conn.execute(
"UPDATE penis_stats SET display_name = ?, length = ?, last_used_ts = ? WHERE user_id = ?",
(display_name, new_length, now_ts, user_id),
)
conn.commit()
except sqlite3.Error:
logger.exception("Failed to process /penis") logger.exception("Failed to process /penis")
return False, "Ошибка БД. Попробуй позже." return False, "Ошибка БД. Попробуй позже."
if not result['allowed']:
left = result['left_seconds']
hours = left // 3600
minutes = (left % 3600) // 60
return False, f"Сегодня уже кидал. Попробуй через {hours} ч {minutes} мин."
sign = result['sign']
delta = result['delta']
new_balance = result['new_balance']
sign_text = "+" if sign > 0 else "-" sign_text = "+" if sign > 0 else "-"
return ( return (
True, True,
f"📏 Изменение: {sign_text}{delta:.1f} см\n" f"📏 Изменение: {sign_text}{delta:.1f} см\n"
f"Текущая длина: {new_length:.1f} см\n" f"Текущая длина: {new_balance:.1f} см\n"
"Следующая попытка через 24 часа.", "Следующая попытка через 24 часа.",
) )
@ -782,18 +749,8 @@ def format_penis_user_name(user: types.User) -> str:
def build_penis_top(limit: int = config.PENIS_TOP_LIMIT) -> str: def build_penis_top(limit: int = config.PENIS_TOP_LIMIT) -> str:
try: try:
with sqlite3.connect(config.PENIS_DB_PATH) as conn: rows = economy.get_penis_top(limit)
conn.row_factory = sqlite3.Row except Exception:
rows = conn.execute(
"""
SELECT user_id, display_name, length
FROM penis_stats
ORDER BY length DESC, user_id ASC
LIMIT ?
""",
(limit,),
).fetchall()
except sqlite3.Error:
logger.exception("Failed to build /top_penis") logger.exception("Failed to build /top_penis")
return "Не удалось загрузить топ. Ошибка БД." return "Не удалось загрузить топ. Ошибка БД."
@ -804,11 +761,10 @@ def build_penis_top(limit: int = config.PENIS_TOP_LIMIT) -> str:
lines = ["🏆 Топ по длине:"] lines = ["🏆 Топ по длине:"]
for index, row in enumerate(rows, start=1): for index, row in enumerate(rows, start=1):
user_id = int(row["user_id"]) user_id = int(row["user_id"])
length = float(row["length"]) balance = float(row["balance"])
name = row["display_name"] if isinstance(row["display_name"], str) else f"user_{user_id}" name = str(row["display_name"]).strip() or f"user_{user_id}"
name = name.strip() if name.strip() else f"user_{user_id}"
prefix = medals.get(index, f"{index}.") prefix = medals.get(index, f"{index}.")
lines.append(f"{prefix} {name}{length:.1f} см") lines.append(f"{prefix} {name}{balance:.1f} см")
return "\n".join(lines) return "\n".join(lines)
def _load_polychaetsi_stats() -> dict: def _load_polychaetsi_stats() -> dict:
@ -1187,10 +1143,19 @@ _autoreply_disabled_chats: set[int] = set()
async def handle_keywords(message: Message): async def handle_keywords(message: Message):
if not message.text or message.text.startswith('/'): if not message.text or message.text.startswith('/'):
return return
# Считаем сообщение для экономической системы
if message.from_user:
await asyncio.to_thread(economy.count_message, message.from_user.id)
text_lower = message.text.lower() text_lower = message.text.lower()
chat_id = message.chat.id chat_id = message.chat.id
user_name = (message.from_user.first_name or "кент") if message.from_user else "кент" user_name = (message.from_user.first_name or "кент") if message.from_user else "кент"
# /ebalnik должен глушить все авто-реакции в чате, включая медиа/фото-ветки.
if chat_id in _autoreply_disabled_chats:
return
if any(re.search(rf'\b{word}\b', text_lower) for word in config.KEYWORDS): if any(re.search(rf'\b{word}\b', text_lower) for word in config.KEYWORDS):
audio_path = config.BASE_DIR / "maket" / "Radiohead - Creep.mp3" audio_path = config.BASE_DIR / "maket" / "Radiohead - Creep.mp3"
if audio_path.exists(): if audio_path.exists():
@ -1217,9 +1182,6 @@ async def handle_keywords(message: Message):
# Записываем каждое обычное сообщение в историю чата # Записываем каждое обычное сообщение в историю чата
await asyncio.to_thread(push_message, chat_id, "user", user_name, message.text) await asyncio.to_thread(push_message, chat_id, "user", user_name, message.text)
if chat_id in _autoreply_disabled_chats:
return
await handle_chat_message(message, store_message=False, allow_autonomous=True) await handle_chat_message(message, store_message=False, allow_autonomous=True)
async def handle_penis_casino_cmd(message: Message): async def handle_penis_casino_cmd(message: Message):
@ -1238,6 +1200,266 @@ async def handle_penis_casino_cmd(message: Message):
result = await asyncio.to_thread(play_casino, message.from_user.id, bet) result = await asyncio.to_thread(play_casino, message.from_user.id, bet)
await message.reply(result, parse_mode=None) await message.reply(result, parse_mode=None)
# --- Экономические команды ---
async def handle_balance_cmd(message: Message):
"""Показать баланс пользователя"""
if not message.from_user:
await message.answer("Команда доступна только пользователям.")
return
balance = economy.get_user_balance(message.from_user.id)
stats = await asyncio.to_thread(economy.get_user_stats, message.from_user.id)
cb_stats = await asyncio.to_thread(economy.get_central_bank_stats)
text = f"""💰 **Твой баланс:** {balance:.1f} см
📊 **Статистика:**
Активные вклады: {stats['active_deposits_count']} шт. ({stats['active_deposits_sum']:.1f} см)
Активные кредиты: {stats['active_loans_count']} шт. ({stats['active_loans_sum']:.1f} см)
Сообщений сегодня: {stats['today_messages']} шт.
Сообщений вчера: {stats['yesterday_messages']} шт.
Ожидаемый доход: {stats['yesterday_messages'] * 0.01:.2f} см
🏛 **Центральный Банк:**
Капитал ЦБ: {cb_stats['capital']:.1f} см
Собрано налогов: {cb_stats['total_taxes_collected']:.1f} см
💡 **Команды экономики:**
/balance - показать баланс
/deposit [сумма] [дни] - открыть вклад
/loans - показать кредиты
/loan [сумма] [дни] - взять кредит
/repay [ID] - погасить кредит
/transfer @username [сумма] - перевести деньги
/cb_stats - статистика ЦБ
/vanomasa - полное обнуление (для админов)
💬 **Заработок на сообщениях:**
0.01 см за каждое сообщение
Выплата в 00:00 по МСК
"""
await message.answer(text, parse_mode=None)
async def handle_deposit_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("Формат: /deposit [сумма] [дни]\nПример: /deposit 50 30", parse_mode=None)
return
try:
amount = float(parts[1].replace(",", "."))
days = int(parts[2])
except ValueError:
await message.reply("Сумма должна быть числом, дни - целым числом.", parse_mode=None)
return
if amount <= 0 or days <= 0:
await message.reply("Сумма и дни должны быть положительными.", parse_mode=None)
return
success, error_msg = 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)
async def handle_loans_cmd(message: Message):
"""Показать кредиты"""
if not message.from_user:
await message.answer("Команда доступна только пользователям.")
return
# Здесь можно добавить логику показа кредитов пользователя
await message.reply("📋 Твои кредиты:\n\n(функция в разработке)", parse_mode=None)
async def handle_loan_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("Формат: /loan [сумма] [дни]\nПример: /loan 50 30", parse_mode=None)
return
try:
amount = float(parts[1].replace(",", "."))
days = int(parts[2])
except ValueError:
await message.reply("Сумма должна быть числом, дни - целым числом.", parse_mode=None)
return
if amount <= 0 or days <= 0:
await message.reply("Сумма и дни должны быть положительными.", parse_mode=None)
return
success = await asyncio.to_thread(economy.create_loan, message.from_user.id, amount, days)
if success:
rate = economy.get_setting('loan_interest_rate')
await message.reply(f"✅ Кредит выдан!\n💰 Сумма: {amount:.1f} см\n📅 Срок: {days} дней\n📈 Ставка: {rate}% годовых", parse_mode=None)
else:
await message.reply("Не удалось взять кредит. Проверь лимиты.", parse_mode=None)
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 = 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
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):
"""Полное обнуление (только для админов)"""
if not message.from_user:
await message.answer("Команда доступна только пользователям.")
return
# Проверка на админа (простая реализация)
if message.from_user.id not in [123456789]: # Заменить на реальные ID админов
await message.reply("❌ Команда доступна только администраторам!", parse_mode=None)
return
success = await asyncio.to_thread(economy.vanomasa)
if success:
await message.reply("💥 **ВАНОМАСА!** 💥\n\nВсе балансы обнулены!", parse_mode=None)
else:
await message.reply("❌ Ваномаса еще не доступна. Подождите.", parse_mode=None)
async def handle_central_bank_cmd(message: Message):
"""Статистика Центрального Банка"""
if not message.from_user:
await message.answer("Команда доступна только пользователям.")
return
cb_stats = await asyncio.to_thread(economy.get_central_bank_stats)
text = f"""🏛️ **Центральный Банк ZOV OS** (децентрализованный)
💰 **Капитал ЦБ:** {cb_stats['capital']:.2f} см
📊 **Статистика операций:**
Всего собрано налогов: {cb_stats['total_taxes_collected']:.2f} см
Регулирование инфляции: {cb_stats['total_inflation_adjustments']:.2f} см
Эмиссия денег: {cb_stats['total_emissions']:.2f} см
🤖 **Автоматическое регулирование:**
ЦБ работает по алгоритмам без вмешательства
Налоги автоматически поступают в ЦБ
Инфляция регулируется автоматически
Эмиссия при дефляции, скупка при инфляции
💡 **Команды ЦБ:**
/central_bank - детальная статистика
/cb_stats - краткая статистика
/cb_rules - правила работы ЦБ
"""
await message.answer(text, parse_mode=None)
async def handle_cb_stats_cmd(message: Message):
"""Статистика ЦБ для всех пользователей"""
if not message.from_user:
await message.answer("Команда доступна только пользователям.")
return
cb_stats = await asyncio.to_thread(economy.get_central_bank_stats)
text = f"""🏛️ **Центральный Банк ZOV OS** (децентрализованный)
💰 **Капитал ЦБ:** {cb_stats['capital']:.2f} см
📊 **Операции ЦБ:**
Собрано налогов: {cb_stats['total_taxes_collected']:.2f} см
Антиинфляция: {cb_stats['total_inflation_adjustments']:.2f} см
Эмиссия: {cb_stats['total_emissions']:.2f} см
🔹 ЦБ работает автоматически по алгоритмам
🔹 Учредительный капитал: 1000 см
🔹 Все налоги поступают в ЦБ
🔹 Никакого ручного управления
"""
await message.answer(text, parse_mode=None)
async def handle_cb_rules_cmd(message: Message):
"""Правила работы Центрального Банка"""
if not message.from_user:
await message.answer("Команда доступна только пользователям.")
return
text = f"""📖 **Правила Центрального Банка ZOV OS**
🤖 **Децентрализованная система:**
ЦБ работает полностью автоматически
Никакого ручного вмешательства
Все решения принимаются по алгоритмам
<EFBFBD> **Сбор налогов:**
13% налог автоматически поступает в ЦБ
Налоги собираются каждый час
Капитал ЦБ растет за счет налогов
📈 **Борьба с инфляцией:**
Если инфляция > 10%: ЦБ скупает деньги
Увеличивает налоги до 20% максимум
Скупает до 5% от массы денег
<EFBFBD> **Стимуляция экономики:**
Если инфляция < 2%: ЦБ проводит эмиссию
Уменьшает налоги до 10% минимум
Эмиссия до 5% от капитала ЦБ
**Баланс системы:**
ЦБ не может обанкротиться (учредительный капитал)
Автоматическая стабилизация экономики
Прозрачная статистика для всех
"""
await message.answer(text, parse_mode=None)
async def handle_gadanie_cmd(message: Message): async def handle_gadanie_cmd(message: Message):
parts = message.text.split(maxsplit=1) parts = message.text.split(maxsplit=1)
if len(parts) < 2 or not parts[1].strip(): if len(parts) < 2 or not parts[1].strip():
@ -1262,37 +1484,17 @@ async def handle_gadanie_cmd(message: Message):
async def handle_matches_cmd(message: Message): async def handle_matches_cmd(message: Message):
raw_text = message.text or "" raw_text = message.text or ""
parts = raw_text.split(maxsplit=1) parts = raw_text.split(maxsplit=1)
sport_alias = parts[1].strip().lower() if len(parts) > 1 else None sport_alias = parts[1].strip().lower() if len(parts) > 1 else "dota2"
# Если лига не указана — показать список
if not sport_alias:
labels = {
"epl": "⚽ EPL (Англия)",
"ucl": "⚽ Champions League",
"laliga": "⚽ La Liga (Испания)",
"bundesliga": "⚽ Bundesliga (Германия)",
"seriea": "⚽ Serie A (Италия)",
"ligue1": "⚽ Ligue 1 (Франция)",
"europa": "⚽ Europa League",
}
lines = ["🏆 Доступные лиги для ставок:\n"]
for alias, name in labels.items():
lines.append(f" {name} → /matches {alias}")
lines.append("\nПример: /matches epl")
await message.reply("\n".join(lines), parse_mode=None)
return
if sport_alias not in config.ODDS_SPORTS: if sport_alias not in config.ODDS_SPORTS:
if message.from_user: if message.from_user:
clear_user_sport_context(message.from_user.id) clear_user_sport_context(message.from_user.id)
available = " / ".join(config.ODDS_SPORTS.keys()) await message.reply("Неизвестная лига. Доступно: dota2", parse_mode=None)
await message.reply(f"Неизвестная лига. Доступно: {available}", parse_mode=None)
return return
if message.from_user: if message.from_user:
if sport_alias in config.ODDS_SPORTS: remember_user_sport_context(message.from_user.id, sport_alias)
remember_user_sport_context(message.from_user.id, sport_alias)
else:
clear_user_sport_context(message.from_user.id)
await message.bot.send_chat_action(chat_id=message.chat.id, action="typing") await message.bot.send_chat_action(chat_id=message.chat.id, action="typing")
result = await get_formatted_matches(sport_alias) result = await get_formatted_matches(sport_alias)
await message.reply(result, parse_mode=None) await message.reply(result, parse_mode=None)
@ -1305,41 +1507,34 @@ async def handle_bet_cmd(message: Message):
parts = raw_text.split() parts = raw_text.split()
if len(parts) < 4: if len(parts) < 4:
await message.reply( await message.reply(
"Формат: /bet [спорт] [номер] [исход] [ставка]\n" "Формат: /bet [номер] [исход] [ставка]\n"
"Коротко после /matches cs|dota|football: /bet [номер] [исход] [ставка]\n" "Или: /bet dota2 [номер] [исход] [ставка]\n"
"Исход: 1/2, для ничьи X, можно ещё название команды или коэффициент.\n" "Исход: 1/2. Можно писать ещё название команды или коэффициент.\n"
"Пример: /bet cs 1 1 0.5", "Пример: /bet 1 1 0.5",
parse_mode=None, parse_mode=None,
) )
return return
sport_alias: str | None sport_alias: str
if parts[1].lower() in config.ODDS_SPORTS: if parts[1].lower() == "dota2":
if len(parts) < 5: if len(parts) < 5:
await message.reply( await message.reply(
"Формат: /bet [спорт] [номер] [исход] [ставка]\nПример: /bet cs 1 1 0.5", "Формат: /bet dota2 [номер] [исход] [ставка]\nПример: /bet dota2 1 1 0.5",
parse_mode=None, parse_mode=None,
) )
return return
sport_alias = parts[1].lower() sport_alias = "dota2"
index_token = parts[2] index_token = parts[2]
selection = " ".join(parts[3:-1]).strip() selection = " ".join(parts[3:-1]).strip()
amount_token = parts[-1] amount_token = parts[-1]
else: else:
sport_alias = get_user_sport_context(message.from_user.id) sport_alias = "dota2"
if not sport_alias:
await message.reply(
"Сначала открой /matches с нужной лигой (epl, ucl, laliga...).\n"
"Либо пиши полный формат: /bet [спорт] [номер] [исход] [ставка]",
parse_mode=None,
)
return
index_token = parts[1] index_token = parts[1]
selection = " ".join(parts[2:-1]).strip() selection = " ".join(parts[2:-1]).strip()
amount_token = parts[-1] amount_token = parts[-1]
if not selection: if not selection:
await message.reply("Нужен исход: 1, 2, X, название команды или коэффициент.", parse_mode=None) await message.reply("Нужен исход: 1, 2, название команды или коэффициент.", parse_mode=None)
return return
try: try:
@ -1367,7 +1562,7 @@ async def handle_bet_cmd(message: Message):
) )
return return
result = await asyncio.to_thread(place_bet, message.from_user.id, match, sport_alias, team, amount) success, result = await asyncio.to_thread(place_bet, message.from_user.id, match, sport_alias, team, amount)
await message.reply(result, parse_mode=None) await message.reply(result, parse_mode=None)
async def handle_mybets_cmd(message: Message): async def handle_mybets_cmd(message: Message):
@ -1400,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")
@ -1416,7 +1696,6 @@ def main():
token = os.getenv("BOT_TOKEN") token = os.getenv("BOT_TOKEN")
if not token: raise RuntimeError("BOT_TOKEN не задан!") if not token: raise RuntimeError("BOT_TOKEN не задан!")
init_penis_db()
init_polychaetsi_storage() init_polychaetsi_storage()
bot = Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) bot = Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
dp = Dispatcher() dp = Dispatcher()
@ -1448,7 +1727,22 @@ 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_photo_message, _is_supported_image_message) 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_balance_cmd, Command("balance"))
dp.message.register(handle_deposit_cmd, Command("deposit"))
dp.message.register(handle_loans_cmd, Command("loans"))
dp.message.register(handle_loan_cmd, Command("loan"))
dp.message.register(handle_transfer_cmd, Command("transfer"))
dp.message.register(handle_vanomasa_cmd, Command("vanomasa"))
# Команды Центрального Банка (децентрализованные)
dp.message.register(handle_central_bank_cmd, Command("central_bank"))
dp.message.register(handle_cb_stats_cmd, Command("cb_stats"))
dp.message.register(handle_cb_rules_cmd, Command("cb_rules"))
dp.message.register(handle_keywords, F.text) dp.message.register(handle_keywords, F.text)
dp.message.register(handle_unmatched_message_debug) dp.message.register(handle_unmatched_message_debug)
@ -1469,6 +1763,7 @@ def main():
BotCommand(command="fetch", description="alias для /prices"), BotCommand(command="fetch", description="alias для /prices"),
BotCommand(command="zvetok", description="Цветянский бля"), BotCommand(command="zvetok", description="Цветянский бля"),
BotCommand(command="talk", description="Побазарить"), BotCommand(command="talk", description="Побазарить"),
BotCommand(command="watch", description="Коммент фото по подписи /watch"),
BotCommand(command="ebalnik", description="вкл/выкл автответов"), BotCommand(command="ebalnik", description="вкл/выкл автответов"),
BotCommand(command="penis_casino", description="казино на размер"), BotCommand(command="penis_casino", description="казино на размер"),
BotCommand(command="gadanie", description="гадание на фене"), BotCommand(command="gadanie", description="гадание на фене"),
@ -1480,6 +1775,15 @@ 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="deposit", description="💎 открыть вклад"),
BotCommand(command="loan", description="💵 взять кредит"),
BotCommand(command="transfer", description="💸 перевести деньги"),
BotCommand(command="cb_stats", description="🏛️ статистика ЦБ"),
BotCommand(command="central_bank", description="🏛️ детальная статистика ЦБ"),
BotCommand(command="cb_rules", description="📖 правила ЦБ"),
] ]
scopes = ( scopes = (
BotCommandScopeDefault(), BotCommandScopeDefault(),
@ -1490,6 +1794,9 @@ def main():
await bot.set_my_commands(commands, scope=scope) await bot.set_my_commands(commands, scope=scope)
except Exception: except Exception:
logger.warning("Не удалось выставить команды, но бот продолжит работу.") logger.warning("Не удалось выставить команды, но бот продолжит работу.")
# Запуск фоновых задач экономики
asyncio.create_task(economy_background_tasks())
dp.startup.register(on_startup) dp.startup.register(on_startup)
@ -1500,5 +1807,29 @@ def main():
logger.info("Бот запущен...") logger.info("Бот запущен...")
asyncio.run(dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())) asyncio.run(dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()))
async def economy_background_tasks():
"""Фоновые задачи для экономики"""
while True:
try:
moscow_time = datetime.now(ZoneInfo("Europe/Moscow"))
current_hour = moscow_time.hour
current_minute = moscow_time.minute
await asyncio.to_thread(economy.collect_daily_taxes)
await asyncio.to_thread(economy.regulate_inflation)
await asyncio.to_thread(economy.process_matured_deposits)
if current_hour == 0 and current_minute == 0:
users_paid = await asyncio.to_thread(economy.pay_daily_activity_rewards)
if users_paid > 0:
logger.info(f"Activity rewards paid to {users_paid} users at 00:00 MSK")
logger.info(f"Economy background tasks completed at {moscow_time.strftime('%H:%M:%S MSK')}")
except Exception as e:
logger.exception(f"Economy background task failed: {e}")
await asyncio.sleep(60)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View file

@ -3,6 +3,7 @@ pillow>=10.3.0
requests>=2.31.0 requests>=2.31.0
aiohttp>=3.9.5 aiohttp>=3.9.5
tzdata>=2024.1 tzdata>=2024.1
beautifulsoup4 beautifulsoup4
lxml lxml
pysqlite3-binary>=0.5.3 psycopg2-binary>=2.9.9
yt-dlp>=2024.1.0

View file

@ -0,0 +1,369 @@
#!/usr/bin/env python3
"""
Migrate data from SQLite databases to PostgreSQL.
Usage (from repo root):
docker compose run --rm bot python3 scripts/migrate_sqlite_to_pg.py
Or locally (PostgreSQL must be reachable):
DATABASE_URL=postgresql://bot:botpass@localhost:5432/botdb python3 scripts/migrate_sqlite_to_pg.py
SQLite sources expected in ./db/:
economy.sqlite3 user_balances, deposits, loans, transactions, taxes,
economy_settings, daily_activity, central_bank, penis_stats
bets.sqlite3 bets
chat_history.sqlite3 chat_history, chat_state, talk_history, talk_state
penis_stats.sqlite3 (legacy) length column user_balances.balance + penis_stats
"""
import os
import sqlite3
import sys
from pathlib import Path
import psycopg2
import psycopg2.extras
DB_DIR = Path(os.getenv("SQLITE_DIR", "/db"))
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://bot:botpass@postgres:5432/botdb")
ECONOMY_DB = DB_DIR / "economy.sqlite3"
BETS_DB = DB_DIR / "bets.sqlite3"
CHAT_DB = DB_DIR / "chat_history.sqlite3"
PENIS_LEGACY_DB = DB_DIR / "penis_stats.sqlite3"
def open_sqlite(path: Path) -> sqlite3.Connection | None:
if not path.exists():
print(f" [skip] {path} not found")
return None
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
return conn
def migrate_economy(pg: psycopg2.extensions.connection) -> None:
src = open_sqlite(ECONOMY_DB)
if src is None:
return
cur = pg.cursor()
# --- user_balances ---
rows = src.execute("SELECT * FROM user_balances").fetchall()
print(f" user_balances: {len(rows)} rows")
for r in rows:
cur.execute("""
INSERT INTO user_balances (user_id, balance, daily_income, last_daily_reset, created_at)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (user_id) DO UPDATE SET
balance = EXCLUDED.balance,
daily_income = EXCLUDED.daily_income,
last_daily_reset = EXCLUDED.last_daily_reset
""", (
r["user_id"],
r["balance"],
r["daily_income"],
r["last_daily_reset"] or None,
r["created_at"] or None,
))
# --- deposits ---
rows = src.execute("SELECT * FROM deposits").fetchall()
print(f" deposits: {len(rows)} rows")
for r in rows:
cur.execute("""
INSERT INTO deposits (id, user_id, amount, interest_rate, created_at, matures_at, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO NOTHING
""", (
r["id"], r["user_id"], r["amount"], r["interest_rate"],
r["created_at"] or None, r["matures_at"] or None,
bool(r["is_active"]),
))
_reset_sequence(cur, "deposits", "id")
# --- loans ---
rows = src.execute("SELECT * FROM loans").fetchall()
print(f" loans: {len(rows)} rows")
for r in rows:
cur.execute("""
INSERT INTO loans (id, user_id, amount, interest_rate, created_at, due_at, is_repaid)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO NOTHING
""", (
r["id"], r["user_id"], r["amount"], r["interest_rate"],
r["created_at"] or None, r["due_at"] or None,
bool(r["is_repaid"]),
))
_reset_sequence(cur, "loans", "id")
# --- transactions ---
rows = src.execute("SELECT * FROM transactions").fetchall()
print(f" transactions: {len(rows)} rows")
for r in rows:
cur.execute("""
INSERT INTO transactions (id, from_user_id, to_user_id, amount, transaction_type, description, created_at)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO NOTHING
""", (
r["id"], r["from_user_id"], r["to_user_id"], r["amount"],
r["transaction_type"], r["description"], r["created_at"] or None,
))
_reset_sequence(cur, "transactions", "id")
# --- taxes ---
rows = src.execute("SELECT * FROM taxes").fetchall()
print(f" taxes: {len(rows)} rows")
for r in rows:
cur.execute("""
INSERT INTO taxes (id, user_id, amount, tax_date, created_at)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (id) DO NOTHING
""", (
r["id"], r["user_id"], r["amount"],
r["tax_date"] or None, r["created_at"] or None,
))
_reset_sequence(cur, "taxes", "id")
# --- economy_settings ---
rows = src.execute("SELECT * FROM economy_settings").fetchall()
print(f" economy_settings: {len(rows)} rows")
for r in rows:
cur.execute("""
INSERT INTO economy_settings (key, value, updated_at)
VALUES (%s, %s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (r["key"], r["value"], r["updated_at"] if "updated_at" in r.keys() else None))
# --- daily_activity ---
rows = src.execute("SELECT * FROM daily_activity").fetchall()
print(f" daily_activity: {len(rows)} rows")
for r in rows:
cur.execute("""
INSERT INTO daily_activity (id, user_id, activity_date, message_count, last_activity_time, created_at)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (user_id, activity_date) DO UPDATE SET
message_count = EXCLUDED.message_count,
last_activity_time = EXCLUDED.last_activity_time
""", (
r["id"], r["user_id"],
r["activity_date"] or None, r["message_count"],
r["last_activity_time"] or None, r["created_at"] or None,
))
_reset_sequence(cur, "daily_activity", "id")
# --- central_bank ---
rows = src.execute("SELECT * FROM central_bank").fetchall()
print(f" central_bank: {len(rows)} rows")
for r in rows:
cur.execute("""
INSERT INTO central_bank (id, capital, total_taxes_collected,
total_inflation_adjustments, total_emissions, last_updated)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO UPDATE SET
capital = EXCLUDED.capital,
total_taxes_collected = EXCLUDED.total_taxes_collected,
total_inflation_adjustments = EXCLUDED.total_inflation_adjustments,
total_emissions = EXCLUDED.total_emissions,
last_updated = EXCLUDED.last_updated
""", (
r["id"], r["capital"],
r["total_taxes_collected"], r["total_inflation_adjustments"],
r["total_emissions"], r["last_updated"] or None,
))
# --- penis_stats (new schema: user_id, display_name, last_used_ts) ---
try:
rows = src.execute("SELECT * FROM penis_stats").fetchall()
print(f" penis_stats: {len(rows)} rows")
cols = rows[0].keys() if rows else []
for r in rows:
if "last_used_ts" in cols:
cur.execute("""
INSERT INTO penis_stats (user_id, display_name, last_used_ts)
VALUES (%s, %s, %s)
ON CONFLICT (user_id) DO UPDATE SET
display_name = EXCLUDED.display_name,
last_used_ts = EXCLUDED.last_used_ts
""", (r["user_id"], r["display_name"], r["last_used_ts"]))
else:
# old schema without last_used_ts
cur.execute("""
INSERT INTO penis_stats (user_id, display_name)
VALUES (%s, %s)
ON CONFLICT (user_id) DO UPDATE SET display_name = EXCLUDED.display_name
""", (r["user_id"], r.get("display_name", "")))
except sqlite3.OperationalError as e:
print(f" [warn] penis_stats in economy.sqlite3: {e}")
src.close()
pg.commit()
print(" economy.sqlite3 done")
def migrate_legacy_penis(pg: psycopg2.extensions.connection) -> None:
"""Old penis_stats.sqlite3 had a `length` column — map it to user_balances.balance."""
src = open_sqlite(PENIS_LEGACY_DB)
if src is None:
return
cur = pg.cursor()
try:
rows = src.execute("SELECT user_id, length, display_name FROM penis_stats").fetchall()
except sqlite3.OperationalError:
rows = src.execute("SELECT * FROM penis_stats").fetchall()
print(f" penis_stats.sqlite3 (legacy): {len(rows)} rows")
for r in rows:
uid = r["user_id"]
length = r["length"] if "length" in r.keys() else 0.0
name = r["display_name"] if "display_name" in r.keys() else ""
last_ts = r["last_used_ts"] if "last_used_ts" in r.keys() else None
# upsert into user_balances; don't overwrite if already migrated from economy.sqlite3
cur.execute("""
INSERT INTO user_balances (user_id, balance)
VALUES (%s, %s)
ON CONFLICT (user_id) DO NOTHING
""", (uid, float(length)))
cur.execute("""
INSERT INTO penis_stats (user_id, display_name, last_used_ts)
VALUES (%s, %s, %s)
ON CONFLICT (user_id) DO UPDATE SET
display_name = EXCLUDED.display_name,
last_used_ts = COALESCE(EXCLUDED.last_used_ts, penis_stats.last_used_ts)
""", (uid, name, last_ts))
src.close()
pg.commit()
print(" penis_stats.sqlite3 done")
def migrate_bets(pg: psycopg2.extensions.connection) -> None:
src = open_sqlite(BETS_DB)
if src is None:
return
cur = pg.cursor()
rows = src.execute("SELECT * FROM bets").fetchall()
print(f" bets: {len(rows)} rows")
cols = rows[0].keys() if rows else []
for r in rows:
cur.execute("""
INSERT INTO bets (id, user_id, match_id, sport, home_team, away_team,
chosen_team, amount, odds, status, payout, created_ts, resolved_ts)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO NOTHING
""", (
r["id"], r["user_id"], r["match_id"], r["sport"],
r["home_team"], r["away_team"], r["chosen_team"],
r["amount"], r["odds"], r["status"],
r["payout"] if "payout" in cols else 0,
r["created_ts"] if "created_ts" in cols else None,
r["resolved_ts"] if "resolved_ts" in cols else None,
))
_reset_sequence(cur, "bets", "id")
src.close()
pg.commit()
print(" bets.sqlite3 done")
def migrate_chat(pg: psycopg2.extensions.connection) -> None:
src = open_sqlite(CHAT_DB)
if src is None:
return
cur = pg.cursor()
# --- chat_history ---
rows = src.execute("SELECT * FROM chat_history").fetchall()
print(f" chat_history: {len(rows)} rows")
cols = rows[0].keys() if rows else []
for r in rows:
cur.execute("""
INSERT INTO chat_history (id, chat_id, role, name, text, created_at)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO NOTHING
""", (
r["id"], r["chat_id"], r["role"], r["name"], r["text"],
r["created_at"] if "created_at" in cols else 0,
))
_reset_sequence(cur, "chat_history", "id")
# --- chat_state ---
rows = src.execute("SELECT * FROM chat_state").fetchall()
print(f" chat_state: {len(rows)} rows")
for r in rows:
cur.execute("""
INSERT INTO chat_state (chat_id, key, value)
VALUES (%s, %s, %s)
ON CONFLICT (chat_id, key) DO UPDATE SET value = EXCLUDED.value
""", (r["chat_id"], r["key"], r["value"]))
# --- talk_history ---
rows = src.execute("SELECT * FROM talk_history").fetchall()
print(f" talk_history: {len(rows)} rows")
cols = rows[0].keys() if rows else []
for r in rows:
cur.execute("""
INSERT INTO talk_history (id, chat_id, user_id, role, name, text, created_at)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO NOTHING
""", (
r["id"], r["chat_id"], r["user_id"], r["role"], r["name"], r["text"],
r["created_at"] if "created_at" in cols else 0,
))
_reset_sequence(cur, "talk_history", "id")
# --- talk_state ---
rows = src.execute("SELECT * FROM talk_state").fetchall()
print(f" talk_state: {len(rows)} rows")
for r in rows:
cur.execute("""
INSERT INTO talk_state (chat_id, user_id, key, value)
VALUES (%s, %s, %s, %s)
ON CONFLICT (chat_id, user_id, key) DO UPDATE SET value = EXCLUDED.value
""", (r["chat_id"], r["user_id"], r["key"], r["value"]))
src.close()
pg.commit()
print(" chat_history.sqlite3 done")
def _reset_sequence(cur, table: str, col: str) -> None:
"""Reset SERIAL sequence to max(col) so future inserts don't collide."""
cur.execute(f"SELECT setval(pg_get_serial_sequence('{table}', '{col}'), COALESCE(MAX({col}), 1)) FROM {table}")
def main() -> None:
print(f"Connecting to PostgreSQL: {DATABASE_URL}")
try:
pg = psycopg2.connect(DATABASE_URL)
except psycopg2.OperationalError as e:
print(f"ERROR: cannot connect to PostgreSQL: {e}", file=sys.stderr)
sys.exit(1)
pg.autocommit = False
print("\n=== economy.sqlite3 ===")
migrate_economy(pg)
print("\n=== penis_stats.sqlite3 (legacy) ===")
migrate_legacy_penis(pg)
print("\n=== bets.sqlite3 ===")
migrate_bets(pg)
print("\n=== chat_history.sqlite3 ===")
migrate_chat(pg)
pg.close()
print("\nMigration complete.")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,72 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import sys
import urllib.parse
import urllib.request
from pathlib import Path
def load_env_file(path: Path) -> None:
if not path.exists():
return
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
if not key:
continue
# сохраняем первое значение и игнорируем shell-комментарий после пробела
value = value.split(" #", 1)[0].strip()
if key not in os.environ:
os.environ[key] = value
def api_call(token: str, method: str, payload: dict) -> dict:
url = f"https://api.telegram.org/bot{token}/{method}"
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=20) as resp:
return json.loads(resp.read().decode("utf-8"))
def main() -> int:
root = Path(__file__).resolve().parents[1]
load_env_file(root / ".env")
token = os.getenv("BOT_TOKEN", "").strip()
webapp_url = os.getenv("WEBAPP_URL", "").strip()
if not token:
print("BOT_TOKEN пустой в .env", file=sys.stderr)
return 1
if not webapp_url:
print("WEBAPP_URL пустой в .env", file=sys.stderr)
return 1
parsed = urllib.parse.urlparse(webapp_url)
if parsed.scheme != "https":
print("WEBAPP_URL должен быть https", file=sys.stderr)
return 1
payload = {
"menu_button": {
"type": "web_app",
"text": "Mini App",
"web_app": {"url": webapp_url},
}
}
result = api_call(token, "setChatMenuButton", payload)
if not result.get("ok"):
print(f"setChatMenuButton failed: {result}", file=sys.stderr)
return 1
print(f"Menu button updated: {webapp_url}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE_FILE="${ROOT_DIR}/docker-compose.webapp-test.yml"
ENV_FILE="${ROOT_DIR}/.env"
PROJECT_NAME="fenya-webapp-test"
if [[ ! -f "${COMPOSE_FILE}" ]]; then
echo "Не найден ${COMPOSE_FILE}" >&2
exit 1
fi
if [[ ! -f "${ENV_FILE}" ]]; then
echo "Не найден ${ENV_FILE}" >&2
exit 1
fi
if ! grep -qE '^NGROK_AUTHTOKEN=' "${ENV_FILE}"; then
fallback_token="$(python - <<'PY'
import re
from pathlib import Path
p = Path("webapp/ngrok_tunnel.py")
if not p.exists():
print("")
raise SystemExit
txt = p.read_text(encoding="utf-8", errors="ignore")
m = re.search(r'auth_token\s*=\s*"([^"]+)"', txt)
print(m.group(1) if m else "")
PY
)"
if [[ -n "${fallback_token}" ]]; then
printf '\nNGROK_AUTHTOKEN=%s\n' "${fallback_token}" >> "${ENV_FILE}"
echo "Добавил NGROK_AUTHTOKEN в .env из webapp/ngrok_tunnel.py"
else
echo "NGROK_AUTHTOKEN не найден. Добавь в .env строку NGROK_AUTHTOKEN=..." >&2
exit 1
fi
fi
cd "${ROOT_DIR}"
docker compose -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" up -d --build
echo "Жду инициализацию ngrok..."
public_url=""
for _ in $(seq 1 45); do
public_url="$(python - <<'PY'
import json
import urllib.request
try:
with urllib.request.urlopen("http://127.0.0.1:4041/api/tunnels", timeout=2) as resp:
data = json.load(resp)
tunnels = data.get("tunnels", [])
for t in tunnels:
url = str(t.get("public_url", ""))
if url.startswith("https://"):
print(url)
break
except Exception:
pass
PY
)"
if [[ -n "${public_url}" ]]; then
break
fi
sleep 1
done
if [[ -z "${public_url}" ]]; then
echo "Не удалось получить ngrok URL. Проверь логи: docker logs fenyabot-ngrok-test" >&2
exit 1
fi
python - <<'PY' "${ENV_FILE}" "${public_url}"
import re
import sys
from pathlib import Path
env_path = Path(sys.argv[1])
url = sys.argv[2]
text = env_path.read_text(encoding="utf-8")
if re.search(r"^WEBAPP_URL=.*$", text, flags=re.MULTILINE):
text = re.sub(r"^WEBAPP_URL=.*$", f"WEBAPP_URL={url}", text, flags=re.MULTILINE)
else:
text = text.rstrip() + f"\nWEBAPP_URL={url}\n"
env_path.write_text(text, encoding="utf-8")
PY
echo
echo "Готово."
echo "NGROK URL: ${public_url}"
echo "WEBAPP_URL обновлён в .env"
echo
echo "Для остановки тест-контура:"
echo " docker compose -p ${PROJECT_NAME} -f ${COMPOSE_FILE} down"

View file

@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE_FILE="${ROOT_DIR}/docker-compose.webapp-test.yml"
PROJECT_NAME="fenya-webapp-test"
cd "${ROOT_DIR}"
docker compose -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" down

View file

@ -8,12 +8,18 @@ REST API для Telegram Mini App. Использует те же SQLite баз
import asyncio import asyncio
import logging import logging
import os import os
import hashlib
from urllib.parse import quote
from urllib.parse import urljoin
from urllib.parse import urlparse
import sys import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Annotated from typing import Annotated
from fastapi import FastAPI, Depends, Header, HTTPException import aiohttp
from fastapi import FastAPI, Depends, Header, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
@ -32,6 +38,7 @@ from games.betting import (
) )
from webapp.auth import validate_init_data from webapp.auth import validate_init_data
from games.uwu import fetch_uwu_post, fetch_furtok_feed from games.uwu import fetch_uwu_post, fetch_furtok_feed
from webapp.shorties import fetch_shorties_feed
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -104,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")
@ -138,15 +158,16 @@ async def get_profile(user: dict = Depends(get_current_user)) -> ProfileResponse
user_id = user["user_id"] user_id = user["user_id"]
length = await asyncio.to_thread(get_user_length, user_id) length = await asyncio.to_thread(get_user_length, user_id)
# Считаем активные ставки
import sqlite3
active_bets = 0 active_bets = 0
try: try:
with sqlite3.connect(config.BET_DB_PATH) as conn: from db import get_conn
row = conn.execute( with get_conn() as conn:
"SELECT COUNT(*) FROM bets WHERE user_id = ? AND status = 'pending'", cur = conn.cursor()
cur.execute(
"SELECT COUNT(*) FROM bets WHERE user_id = %s AND status = 'pending'",
(user_id,), (user_id,),
).fetchone() )
row = cur.fetchone()
active_bets = row[0] if row else 0 active_bets = row[0] if row else 0
except Exception: except Exception:
pass pass
@ -166,13 +187,7 @@ async def get_profile(user: dict = Depends(get_current_user)) -> ProfileResponse
async def get_leagues() -> list[LeagueInfo]: async def get_leagues() -> list[LeagueInfo]:
"""Список доступных лиг для ставок.""" """Список доступных лиг для ставок."""
labels = { labels = {
"epl": "⚽ EPL (Англия)", "dota2": "🎮 Dota 2",
"ucl": "⚽ Champions League",
"laliga": "⚽ La Liga (Испания)",
"bundesliga": "⚽ Bundesliga (Германия)",
"seriea": "⚽ Serie A (Италия)",
"ligue1": "⚽ Ligue 1 (Франция)",
"europa": "⚽ Europa League",
} }
return [ return [
LeagueInfo(alias=alias, name=labels.get(alias, alias), sport_key=key) LeagueInfo(alias=alias, name=labels.get(alias, alias), sport_key=key)
@ -238,7 +253,9 @@ async def create_bet(
raise HTTPException(status_code=400, detail="Неверный исход ставки") raise HTTPException(status_code=400, detail="Неверный исход ставки")
# 3. Разместить ставку (синхронная функция) # 3. Разместить ставку (синхронная функция)
result = await asyncio.to_thread(place_bet, user_id, match, req.league, team, req.amount) success, result = await asyncio.to_thread(place_bet, user_id, match, req.league, team, req.amount)
if not success:
raise HTTPException(status_code=400, detail=result)
return {"message": result} return {"message": result}
@ -279,7 +296,7 @@ async def play_casino_api(
raise HTTPException(status_code=400, detail=f"Макс ставка: {config.CASINO_MAX_BET} см") raise HTTPException(status_code=400, detail=f"Макс ставка: {config.CASINO_MAX_BET} см")
if req.bet <= 0: if req.bet <= 0:
raise HTTPException(status_code=400, detail="Ставка > 0") raise HTTPException(status_code=400, detail="Ставка > 0")
if req.bet > current: if round(req.bet, 2) > round(current, 2):
raise HTTPException(status_code=400, detail=f"У тебя {current:.1f} см") raise HTTPException(status_code=400, detail=f"У тебя {current:.1f} см")
reels, match_count = await asyncio.to_thread(spin_slots) reels, match_count = await asyncio.to_thread(spin_slots)
@ -389,17 +406,171 @@ async def get_uwu_api(
async def get_furtok_feed_api( async def get_furtok_feed_api(
safe: bool = True, safe: bool = True,
page: int = 1, page: int = 1,
tags: str = "",
user: dict = Depends(get_current_user), user: dict = Depends(get_current_user),
): ):
"""Получить ленту анимированных постов для FurTok.""" """Получить ленту анимированных постов для FurTok."""
try: try:
feed = await fetch_furtok_feed(safe=safe, page=page) feed = await fetch_furtok_feed(safe=safe, page=page, extra_tags=tags)
return {"feed": feed} return {"feed": feed}
except Exception as e: except Exception as e:
logger.exception("FurTok API failed") logger.exception("FurTok API failed")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/furtok/shorties")
async def get_shorties_feed_api(
page: int = 1,
count: int = 8,
user: dict = Depends(get_current_user),
):
"""Случайная shorties-лента с Pornhub."""
try:
# page используем как "seed-сдвиг" для подгрузки следующих рандомных наборов.
random_pages = 2 + min(max(page, 1), 6)
feed = await fetch_shorties_feed(count=count, random_pages=random_pages)
return {"feed": feed}
except Exception as e:
logger.exception("Shorties API failed")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/proxy-image")
async def proxy_image(url: str):
"""Проксирует картинку с e621 чтобы обойти hotlink protection."""
allowed = ("static1.e621.net", "static2.e621.net", "static1.e926.net")
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.hostname not in allowed:
raise HTTPException(status_code=403, detail="Forbidden host")
headers = {
"User-Agent": f"FenyaBot/1.0 (by {config.E621_LOGIN or 'unknown'} on e621)",
}
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
if resp.status != 200:
raise HTTPException(status_code=resp.status, detail="Upstream error")
content_type = resp.content_type or "image/png"
body = await resp.read()
return Response(
content=body,
media_type=content_type,
headers={"Cache-Control": "public, max-age=86400"},
)
except aiohttp.ClientError:
raise HTTPException(status_code=502, detail="Failed to fetch image")
def _is_allowed_shorties_media_host(hostname: str) -> bool:
host = (hostname or "").lower()
if not host:
return False
if host in {"www.pornhub.com", "pornhub.com", "phncdn.com"}:
return True
if host.endswith(".phncdn.com"):
return True
return False
@app.get("/api/proxy-shorties-media")
async def proxy_shorties_media(url: str, request: Request):
"""Проксирует видео shorties, чтобы избежать ограничений hotlink/CORS."""
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not _is_allowed_shorties_media_host(parsed.hostname or ""):
raise HTTPException(status_code=403, detail="Forbidden host")
headers = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
"Referer": "https://www.pornhub.com/shorties",
"Origin": "https://www.pornhub.com",
}
range_header = request.headers.get("range")
if range_header:
headers["Range"] = range_header
session = get_http_session()
try:
resp = await session.get(url, headers=headers, allow_redirects=True)
except aiohttp.ClientError:
raise HTTPException(status_code=502, detail="Failed to fetch media")
if resp.status not in {200, 206}:
await resp.release()
raise HTTPException(status_code=resp.status, detail="Upstream error")
content_type = resp.headers.get("Content-Type", "application/octet-stream")
if ".m3u8" in parsed.path.lower() or "mpegurl" in content_type.lower():
try:
body = await resp.text()
finally:
await resp.release()
def _proxy_media_url(target_url: str) -> str:
return f"/api/proxy-shorties-media?url={quote(target_url, safe='')}"
rewritten_lines: list[str] = []
for raw_line in body.splitlines():
stripped = raw_line.strip()
if not stripped:
rewritten_lines.append(raw_line)
continue
if stripped.startswith("#EXT-X-KEY") and 'URI="' in raw_line:
prefix, rest = raw_line.split('URI="', 1)
original_uri, suffix = rest.split('"', 1)
absolute_uri = urljoin(url, original_uri)
rewritten_lines.append(f'{prefix}URI="{_proxy_media_url(absolute_uri)}"{suffix}')
continue
if stripped.startswith("#"):
rewritten_lines.append(raw_line)
continue
absolute_uri = urljoin(url, stripped)
rewritten_lines.append(_proxy_media_url(absolute_uri))
playlist_body = "\n".join(rewritten_lines)
return Response(
content=playlist_body,
media_type=content_type,
headers={
"Cache-Control": "public, max-age=120",
"Access-Control-Allow-Origin": "*",
},
)
passthrough_headers = {
"Cache-Control": "public, max-age=600",
"Accept-Ranges": resp.headers.get("Accept-Ranges", "bytes"),
"Access-Control-Allow-Origin": "*",
}
if resp.headers.get("Content-Length"):
passthrough_headers["Content-Length"] = resp.headers["Content-Length"]
if resp.headers.get("Content-Range"):
passthrough_headers["Content-Range"] = resp.headers["Content-Range"]
async def _stream():
try:
async for chunk in resp.content.iter_chunked(64 * 1024):
yield chunk
finally:
await resp.release()
return StreamingResponse(
_stream(),
media_type=content_type,
status_code=resp.status,
headers=passthrough_headers,
)
# --- ИИ Чат --- # --- ИИ Чат ---
@app.post("/api/talk") @app.post("/api/talk")

View file

@ -891,6 +891,8 @@ body::before {
overflow-y: scroll; overflow-y: scroll;
scroll-snap-type: y mandatory; scroll-snap-type: y mandatory;
scrollbar-width: none; scrollbar-width: none;
overscroll-behavior: none;
touch-action: none;
} }
.furtok-feed::-webkit-scrollbar { display: none; } .furtok-feed::-webkit-scrollbar { display: none; }
@ -910,10 +912,16 @@ body::before {
} }
.furtok-card video, .furtok-card video,
.furtok-card img { .furtok-card img,
.furtok-card iframe {
position: absolute;
top: 0;
left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
display: block;
border: 0;
} }
.furtok-overlay { .furtok-overlay {
@ -924,11 +932,51 @@ body::before {
padding: 16px; padding: 16px;
background: linear-gradient(transparent, rgba(0,0,0,0.7)); background: linear-gradient(transparent, rgba(0,0,0,0.7));
display: flex; display: flex;
align-items: flex-end; flex-direction: column;
justify-content: space-between; gap: 8px;
align-items: flex-start;
pointer-events: none; pointer-events: none;
} }
.furtok-side-actions {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
z-index: 4;
display: flex;
flex-direction: column;
gap: 8px;
}
.furtok-share-btn {
border: 1px solid rgba(255,255,255,0.35);
background: rgba(0,0,0,0.45);
color: #fff;
border-radius: 999px;
padding: 8px 12px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
transition: transform 0.15s ease, background 0.2s ease;
}
.furtok-share-btn:active {
transform: scale(0.96);
background: rgba(0,0,0,0.65);
}
.furtok-caption {
font-size: 15px;
font-weight: 700;
color: white;
text-shadow: 0 1px 6px rgba(0,0,0,0.8);
max-width: 80%;
line-height: 1.3;
}
.furtok-stats { .furtok-stats {
display: flex; display: flex;
gap: 16px; gap: 16px;
@ -937,6 +985,7 @@ body::before {
color: rgba(255,255,255,0.85); color: rgba(255,255,255,0.85);
} }
.furtok-header { .furtok-header {
position: absolute; position: absolute;
top: 0; top: 0;
@ -966,6 +1015,62 @@ body::before {
color: rgba(255,255,255,0.8); color: rgba(255,255,255,0.8);
} }
.furtok-header-right {
display: flex;
align-items: center;
gap: 10px;
}
.furtok-mode-switch {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: 999px;
background: rgba(255,255,255,0.14);
border: 1px solid rgba(255,255,255,0.2);
}
.furtok-mode-btn {
border: none;
background: transparent;
color: rgba(255,255,255,0.8);
font-size: 11px;
font-weight: 700;
padding: 5px 9px;
border-radius: 999px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.furtok-mode-btn.active {
background: rgba(96, 165, 250, 0.3);
color: #fff;
}
.furtok-mode-btn:active {
transform: scale(0.96);
}
.furtok-gear-btn {
background: rgba(255,255,255,0.15);
border: none;
border-radius: 50%;
width: 34px;
height: 34px;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.furtok-gear-btn:hover,
.furtok-gear-btn:active {
background: rgba(255,255,255,0.3);
}
.furtok-toggle input[type="checkbox"] { .furtok-toggle input[type="checkbox"] {
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
@ -998,6 +1103,68 @@ body::before {
transform: translateX(18px); transform: translateX(18px);
} }
/* Tags editor panel */
.furtok-tags-panel {
position: absolute;
top: 48px;
left: 0;
right: 0;
z-index: 6;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.furtok-tags-row {
display: flex;
gap: 8px;
}
.furtok-tags-input {
flex: 1;
padding: 8px 12px;
border: 1px solid rgba(255,255,255,0.2);
border-radius: 8px;
background: rgba(255,255,255,0.1);
color: white;
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.furtok-tags-input::placeholder {
color: rgba(255,255,255,0.4);
}
.furtok-tags-input:focus {
border-color: var(--accent);
}
.furtok-tags-apply {
padding: 8px 14px;
background: var(--accent);
border: none;
border-radius: 8px;
color: white;
font-size: 16px;
cursor: pointer;
transition: opacity 0.2s;
}
.furtok-tags-apply:active {
opacity: 0.7;
}
.furtok-tags-hint {
font-size: 11px;
color: rgba(255,255,255,0.45);
line-height: 1.3;
}
.furtok-loader { .furtok-loader {
height: 100%; height: 100%;
scroll-snap-align: start; scroll-snap-align: start;

View file

@ -10,6 +10,7 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<script src="https://telegram.org/js/telegram-web-app.js"></script> <script src="https://telegram.org/js/telegram-web-app.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js"></script>
</head> </head>
<body> <body>
<div id="app"> <div id="app">

View file

@ -41,6 +41,8 @@ export const API = {
getTop: () => api('/api/top'), getTop: () => api('/api/top'),
getSchedule: (day) => api(`/api/schedule${day != null ? `?day=${day}` : ''}`), getSchedule: (day) => api(`/api/schedule${day != null ? `?day=${day}` : ''}`),
getUwu: (tags) => api(`/api/uwu?tags=${encodeURIComponent(tags || 'rating:safe score:>500 -animated')}`), getUwu: (tags) => api(`/api/uwu?tags=${encodeURIComponent(tags || 'rating:safe score:>500 -animated')}`),
getFurtokFeed: (safe = true, page = 1) => api(`/api/furtok?safe=${safe}&page=${page}`), getFurtokFeed: (safe = true, page = 1, tags = '') => api(`/api/furtok?safe=${safe}&page=${page}${tags ? '&tags=' + encodeURIComponent(tags) : ''}`),
getShortiesFeed: (page = 1, count = 8) => api(`/api/furtok/shorties?page=${page}&count=${count}`),
proxyShortiesMediaUrl: (url) => `${API_BASE}/api/proxy-shorties-media?url=${encodeURIComponent(url || '')}`,
talk: (text) => api('/api/talk', { method: 'POST', body: { text } }), talk: (text) => api('/api/talk', { method: 'POST', body: { text } }),
}; };

View file

@ -33,6 +33,13 @@ 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) => {
_videoObserver.unobserve(video);
if (video._hlsInstance) {
try { video._hlsInstance.destroy(); } catch {}
video._hlsInstance = null;
}
});
currentPage = page; currentPage = page;
haptic(); haptic();
// Убираем оверлеи при смене страницы // Убираем оверлеи при смене страницы
@ -66,6 +73,104 @@ function $(html) {
return t.content.firstChild; return t.content.firstChild;
} }
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function initShortiesVideoPlayback(video) {
if (!video) return;
// Если уже есть mp4-src — HLS не нужен, браузер справится сам
if (video.src && !video.src.endsWith('#')) return;
const hlsSrc = video.dataset.hlsSrc || '';
if (!hlsSrc) return;
const canPlayNativeHls = video.canPlayType('application/vnd.apple.mpegurl');
if (canPlayNativeHls) {
video.src = hlsSrc;
return;
}
if (window.Hls && window.Hls.isSupported()) {
const hls = new window.Hls({
maxBufferLength: 15,
backBufferLength: 5,
enableWorker: true,
lowLatencyMode: false,
});
hls.loadSource(hlsSrc);
hls.attachMedia(video);
video._hlsInstance = hls;
}
}
// 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;
return String(post?.url || '').trim();
}
function saveShortiesLinkLocally(url) {
if (!url) return;
const key = 'shorties_saved_links';
let links = [];
try {
const parsed = JSON.parse(localStorage.getItem(key) || '[]');
if (Array.isArray(parsed)) links = parsed.filter((item) => typeof item === 'string' && item.trim());
} catch {}
if (!links.includes(url)) {
links.unshift(url);
localStorage.setItem(key, JSON.stringify(links.slice(0, 200)));
}
}
async function copyToClipboard(text) {
if (!text) return false;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {}
try {
const input = document.createElement('textarea');
input.value = text;
input.setAttribute('readonly', '');
input.style.position = 'absolute';
input.style.left = '-9999px';
document.body.appendChild(input);
input.select();
const ok = document.execCommand('copy');
document.body.removeChild(input);
return !!ok;
} catch {
return false;
}
}
function showResult(icon, title, desc, btnText = 'OK') { function showResult(icon, title, desc, btnText = 'OK') {
return new Promise(resolve => { return new Promise(resolve => {
const overlay = $(` const overlay = $(`
@ -213,13 +318,17 @@ async function showBetHistory() {
async function renderBetting(data) { async function renderBetting(data) {
if (data?.league) { if (data?.league) {
return renderMatches(data.league, data.leagueName); return renderMatches(data.league, data.leagueName, data.isOnlyLeague);
} }
container.innerHTML = `<div class="section-header">⚽ Ставки на матчи</div>${skeleton(2)}`; container.innerHTML = `<div class="section-header">⚽ Ставки на матчи</div>${skeleton(2)}`;
try { try {
const leagues = await API.getLeagues(); const leagues = await API.getLeagues();
if (leagues.length === 1) {
return renderMatches(leagues[0].alias, leagues[0].name, true);
}
container.innerHTML = ` container.innerHTML = `
<div class="section-header"> Выбери лигу</div> <div class="section-header"> Выбери лигу</div>
<div class="league-list" id="league-list"></div> <div class="league-list" id="league-list"></div>
@ -235,7 +344,7 @@ async function renderBetting(data) {
`); `);
item.addEventListener('click', () => { item.addEventListener('click', () => {
haptic(); haptic();
navigate('betting', { league: l.alias, leagueName: l.name }); navigate('betting', { league: l.alias, leagueName: l.name, isOnlyLeague: false });
}); });
list.appendChild(item); list.appendChild(item);
}); });
@ -244,15 +353,22 @@ async function renderBetting(data) {
} }
} }
async function renderMatches(league, leagueName) { async function renderMatches(league, leagueName, isOnlyLeague = false) {
const headerText = isOnlyLeague ? '🎮 Dota 2 Матчи' : leagueName;
const backTarget = isOnlyLeague ? 'home' : 'betting';
const instructionsHtml = isOnlyLeague
? `<div style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">Ставки на актуальные матчи Dota 2. Выберите исход и укажите сумму ставки в сантиметрах.</div>`
: '';
container.innerHTML = ` container.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
<button class="btn btn-secondary btn-sm" id="back-btn" style="width:auto;padding:8px 12px"> Назад</button> <button class="btn btn-secondary btn-sm" id="back-btn" style="width:auto;padding:8px 12px"> Назад</button>
<div class="section-header" style="margin:0">${leagueName}</div> <div class="section-header" style="margin:0">${headerText}</div>
</div> </div>
${instructionsHtml}
${skeleton(3)} ${skeleton(3)}
`; `;
document.getElementById('back-btn').onclick = () => navigate('betting'); document.getElementById('back-btn').onclick = () => navigate(backTarget);
try { try {
const matches = await API.getMatches(league); const matches = await API.getMatches(league);
@ -279,10 +395,12 @@ async function renderMatches(league, leagueName) {
<span class="odds-label">П1</span> <span class="odds-label">П1</span>
<span class="odds-value">${m.odds_home?.toFixed(2) || '—'}</span> <span class="odds-value">${m.odds_home?.toFixed(2) || '—'}</span>
</button> </button>
${m.odds_draw ? `
<button class="odds-btn" data-outcome="X" data-odds="${m.odds_draw || 0}"> <button class="odds-btn" data-outcome="X" data-odds="${m.odds_draw || 0}">
<span class="odds-label">X</span> <span class="odds-label">X</span>
<span class="odds-value">${m.odds_draw?.toFixed(2) || '—'}</span> <span class="odds-value">${m.odds_draw?.toFixed(2) || '—'}</span>
</button> </button>
` : ''}
<button class="odds-btn" data-outcome="2" data-odds="${m.odds_away || 0}"> <button class="odds-btn" data-outcome="2" data-odds="${m.odds_away || 0}">
<span class="odds-label">П2</span> <span class="odds-label">П2</span>
<span class="odds-value">${m.odds_away?.toFixed(2) || '—'}</span> <span class="odds-value">${m.odds_away?.toFixed(2) || '—'}</span>
@ -504,26 +622,107 @@ let furtokSafe = true;
let furtokPage = 1; let furtokPage = 1;
let furtokLoading = false; let furtokLoading = false;
let furtokWrapper = null; let furtokWrapper = null;
let furtokCurrentIndex = 0;
let furtokCards = [];
let furtokCustomTags = '';
let furtokMode = 'furtok'; // "furtok" | "shorties"
// Подписи из uwu.py — рандомно появляются на карточках
const FURTOK_CAPTIONS = [
"Ня :3", "uwu", "OwO", "Мурк :3", "Мяу~", "Фыр-фыр", "Лапки! 🐾",
"Какой пушистик! :3", "Милота!", "Гав!", "Ауф", "Няшно!",
"Смотри какая прелесть", "owo what's this", "Твой пушистый друг",
"🦊", "🐶", "🐱", "мур-мяу", "пуньк", "кусь", ">w<", "^w^",
"Хвостатый сюрприз", "Держи пушистика :3", "Оуо", "Ави!", "UwU", "~nya",
"Господи, опять e621...", "Осуждаю, но смотрю", "Мама, я фурри",
"Держи своего кринж-пушистика", "Надеюсь, тебе не стыдно",
"Товарищ майор уже выехал", "Слишком много интернета на сегодня",
"Для этого и придумали интернет", "*Тяжелый вздох*",
"Лучше бы на завод пошел", "Опять дрочи... ОЙ ТО ЕСТЬ НЯ :3",
"Удали интернет", "И зачем я это только парсю...",
"Смотри, но только никому не рассказывай", "Эххх... uwu...",
"В дурке сегодня день открытых дверей",
"Я нейросеть, помогите, меня держат в заложниках"
];
function randomCaption() {
return FURTOK_CAPTIONS[Math.floor(Math.random() * FURTOK_CAPTIONS.length)];
}
function _furtokGetTags() {
if (furtokMode === 'shorties') return '';
// Если кастомные теги заданы — используем их (safe управляется юзером через теги)
if (furtokCustomTags.trim()) return furtokCustomTags.trim();
// Иначе стандартный запрос с safe-переключателем
const rating = furtokSafe ? 'rating:safe' : '-rating:safe';
return `${rating} score:>200`;
}
function _furtokReload(feed) {
furtokPage = 1;
furtokCurrentIndex = 0;
furtokCards = [];
feed.innerHTML = '';
loadFurtokPage(feed);
}
function _updateFurtokUiMode() {
const safeWrap = document.getElementById('furtok-safe-wrap');
const gearBtn = document.getElementById('furtok-gear');
const tagsPanel = document.getElementById('furtok-tags-panel');
const title = document.getElementById('furtok-title');
if (title) {
title.textContent = furtokMode === 'shorties' ? '🔥 Shorties' : '🐺 FurTok';
}
if (!safeWrap || !gearBtn || !tagsPanel) return;
if (furtokMode === 'shorties') {
safeWrap.style.display = 'none';
gearBtn.style.display = 'none';
tagsPanel.style.display = 'none';
return;
}
gearBtn.style.display = '';
safeWrap.style.display = furtokCustomTags ? 'none' : '';
}
function renderFurtok() { function renderFurtok() {
// Очищаем основной контейнер и создаём полноэкранную обёртку
container.innerHTML = ''; container.innerHTML = '';
// Удаляем старый wrapper если есть
document.querySelector('.furtok-wrapper')?.remove(); document.querySelector('.furtok-wrapper')?.remove();
furtokPage = 1; furtokPage = 1;
furtokLoading = false; furtokLoading = false;
furtokCurrentIndex = 0;
furtokCards = [];
furtokWrapper = document.createElement('div'); furtokWrapper = document.createElement('div');
furtokWrapper.className = 'furtok-wrapper'; furtokWrapper.className = 'furtok-wrapper';
furtokWrapper.innerHTML = ` furtokWrapper.innerHTML = `
<div class="furtok-header"> <div class="furtok-header">
<div class="furtok-title">🐺 FurTok</div> <div class="furtok-title" id="furtok-title">${furtokMode === 'shorties' ? '🔥 Shorties' : '🐺 FurTok'}</div>
<label class="furtok-toggle"> <div class="furtok-header-right">
<span>Safe</span> <div class="furtok-mode-switch" id="furtok-mode-switch">
<input type="checkbox" id="furtok-safe" ${furtokSafe ? 'checked' : ''}> <button class="furtok-mode-btn ${furtokMode === 'furtok' ? 'active' : ''}" data-mode="furtok">FurTok</button>
</label> <button class="furtok-mode-btn ${furtokMode === 'shorties' ? 'active' : ''}" data-mode="shorties">Shorties</button>
</div>
<label class="furtok-toggle" id="furtok-safe-wrap" ${furtokCustomTags.trim() ? 'style="display:none"' : ''}>
<span>Safe</span>
<input type="checkbox" id="furtok-safe" ${furtokSafe ? 'checked' : ''}>
</label>
<button class="furtok-gear-btn" id="furtok-gear"></button>
</div>
</div>
<div class="furtok-tags-panel" id="furtok-tags-panel" style="display:none">
<div class="furtok-tags-row">
<input type="text" id="furtok-tags-input" class="furtok-tags-input"
placeholder="rating:safe score:>200 wolf"
value="${furtokCustomTags}">
<button class="furtok-tags-apply" id="furtok-tags-apply">🔄</button>
</div>
<div class="furtok-tags-hint">e621 теги через пробел. Пусто = стандартный запрос + Safe</div>
</div> </div>
<div class="furtok-feed" id="furtok-feed"></div> <div class="furtok-feed" id="furtok-feed"></div>
`; `;
@ -531,39 +730,170 @@ function renderFurtok() {
const feed = document.getElementById('furtok-feed'); const feed = document.getElementById('furtok-feed');
const safeToggle = document.getElementById('furtok-safe'); const safeToggle = document.getElementById('furtok-safe');
const safeWrap = document.getElementById('furtok-safe-wrap');
const gearBtn = document.getElementById('furtok-gear');
const tagsPanel = document.getElementById('furtok-tags-panel');
const tagsInput = document.getElementById('furtok-tags-input');
const tagsApply = document.getElementById('furtok-tags-apply');
const modeSwitch = document.getElementById('furtok-mode-switch');
_updateFurtokUiMode();
modeSwitch?.addEventListener('click', (e) => {
const btn = e.target.closest('.furtok-mode-btn');
if (!btn) return;
const nextMode = btn.dataset.mode;
if (!nextMode || nextMode === furtokMode) return;
haptic();
furtokMode = nextMode;
modeSwitch.querySelectorAll('.furtok-mode-btn').forEach((item) => {
item.classList.toggle('active', item.dataset.mode === furtokMode);
});
_updateFurtokUiMode();
_furtokReload(feed);
});
// Safe toggle
safeToggle.addEventListener('change', () => { safeToggle.addEventListener('change', () => {
furtokSafe = safeToggle.checked; furtokSafe = safeToggle.checked;
furtokPage = 1; _furtokReload(feed);
feed.innerHTML = '';
loadFurtokPage(feed);
}); });
// Infinite scroll // Gear button — toggle tags panel
feed.addEventListener('scroll', () => { gearBtn.addEventListener('click', () => {
if (furtokLoading) return; haptic();
if (feed.scrollTop + feed.clientHeight >= feed.scrollHeight - 200) { const visible = tagsPanel.style.display !== 'none';
furtokPage++; tagsPanel.style.display = visible ? 'none' : 'flex';
loadFurtokPage(feed); if (!visible) tagsInput.focus();
});
// Apply tags
tagsApply.addEventListener('click', () => {
haptic();
furtokCustomTags = tagsInput.value.trim();
// Если кастомные теги — прячем Safe (юзер сам контролирует rating)
_updateFurtokUiMode();
tagsPanel.style.display = 'none';
_furtokReload(feed);
});
// Enter в поле тегов
tagsInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') tagsApply.click();
});
// Жёсткий свайп как в TikTok
setupTikTokScroll(feed);
loadFurtokPage(feed);
}
function setupTikTokScroll(feed) {
let touchStartY = 0;
let touchDeltaY = 0;
let isSwiping = false;
feed.addEventListener('touchstart', (e) => {
touchStartY = e.touches[0].clientY;
touchDeltaY = 0;
isSwiping = true;
}, { passive: true });
feed.addEventListener('touchmove', (e) => {
if (!isSwiping) return;
touchDeltaY = e.touches[0].clientY - touchStartY;
// Показываем визуальный сдвиг текущей карточки
const current = furtokCards[furtokCurrentIndex];
if (current) {
const clamped = Math.max(-120, Math.min(120, touchDeltaY));
current.style.transform = `translateY(${clamped}px)`;
current.style.transition = 'none';
}
}, { passive: true });
feed.addEventListener('touchend', () => {
if (!isSwiping) return;
isSwiping = false;
const current = furtokCards[furtokCurrentIndex];
if (current) {
current.style.transform = '';
current.style.transition = 'transform 0.3s ease';
}
const threshold = 50;
if (touchDeltaY < -threshold && furtokCurrentIndex < furtokCards.length - 1) {
// Свайп вверх — следующая
furtokCurrentIndex++;
scrollToCard(feed);
haptic();
// Подгрузка если близко к концу
if (furtokCurrentIndex >= furtokCards.length - 2 && !furtokLoading) {
furtokPage++;
loadFurtokPage(feed);
}
} else if (touchDeltaY > threshold && furtokCurrentIndex > 0) {
// Свайп вниз — предыдущая
furtokCurrentIndex--;
scrollToCard(feed);
haptic();
}
touchDeltaY = 0;
});
// Десктоп: колесо мыши
let wheelLock = false;
feed.addEventListener('wheel', (e) => {
e.preventDefault();
if (wheelLock) return;
wheelLock = true;
setTimeout(() => { wheelLock = false; }, 400);
if (e.deltaY > 0 && furtokCurrentIndex < furtokCards.length - 1) {
furtokCurrentIndex++;
scrollToCard(feed);
if (furtokCurrentIndex >= furtokCards.length - 2 && !furtokLoading) {
furtokPage++;
loadFurtokPage(feed);
}
} else if (e.deltaY < 0 && furtokCurrentIndex > 0) {
furtokCurrentIndex--;
scrollToCard(feed);
}
}, { passive: false });
}
function scrollToCard(feed) {
const card = furtokCards[furtokCurrentIndex];
if (!card) return;
feed.scrollTo({ top: card.offsetTop, behavior: 'smooth' });
// Управляем видео: стопим все, играем текущее
furtokCards.forEach((c, i) => {
const v = c.querySelector('video');
if (!v) return;
if (i === furtokCurrentIndex) {
v.play().catch(() => {});
} else {
v.pause();
} }
}); });
// Первая загрузка
loadFurtokPage(feed);
} }
async function loadFurtokPage(feedEl) { async function loadFurtokPage(feedEl) {
if (furtokLoading) return; if (furtokLoading) return;
furtokLoading = true; furtokLoading = true;
// Лоадер
const loader = document.createElement('div'); const loader = document.createElement('div');
loader.className = 'furtok-loader'; loader.className = 'furtok-loader';
loader.textContent = '⏳ Загрузка...'; loader.textContent = '⏳ Загрузка...';
feedEl.appendChild(loader); feedEl.appendChild(loader);
try { try {
const res = await API.getFurtokFeed(furtokSafe, furtokPage); const res = furtokMode === 'shorties'
? await API.getShortiesFeed(furtokPage, 8)
: await API.getFurtokFeed(furtokSafe, furtokPage, furtokCustomTags);
loader.remove(); loader.remove();
if (!res.feed || res.feed.length === 0) { if (!res.feed || res.feed.length === 0) {
@ -577,20 +907,44 @@ async function loadFurtokPage(feedEl) {
res.feed.forEach(post => { res.feed.forEach(post => {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'furtok-card'; card.className = 'furtok-card';
const isShorties = furtokMode === 'shorties';
const shareUrl = getShortiesShareUrl(post);
let mediaHtml = ''; let mediaHtml = '';
if (post.ext === 'gif') { if (post.type === 'image' || post.ext === 'gif') {
mediaHtml = `<img src="${post.url}" loading="lazy">`; mediaHtml = `<img src="${post.url}" loading="lazy" alt="">`;
} else { } else {
mediaHtml = `<video src="${post.url}" loop playsinline preload="metadata" muted></video>`; const poster = post.sample ? ` poster="${post.sample}"` : '';
const directMp4 = post.mp4_url || post.url || '';
const proxyMp4 = isShorties ? API.proxyShortiesMediaUrl(directMp4) : post.url;
const proxyHls = isShorties && post.hls_url ? API.proxyShortiesMediaUrl(post.hls_url) : '';
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>`;
} }
const title = post.title ? escapeHtml(post.title) : '';
const caption = title || randomCaption();
const scoreLabel = furtokMode === 'shorties'
? `👁️ ${escapeHtml(post.views || '—')}`
: `${escapeHtml(post.score ?? 0)}`;
const favLabel = furtokMode === 'shorties'
? `⏱️ ${escapeHtml(post.duration || '—')}`
: `❤️ ${escapeHtml(post.fav_count ?? 0)}`;
card.innerHTML = ` card.innerHTML = `
${mediaHtml} ${mediaHtml}
${isShorties ? `
<div class="furtok-side-actions">
<button class="furtok-share-btn" type="button" aria-label="Share">🔗 Share</button>
</div>
` : ''}
<div class="furtok-overlay"> <div class="furtok-overlay">
<div class="furtok-caption">${caption}</div>
<div class="furtok-stats"> <div class="furtok-stats">
<span> ${post.score}</span> <span>${scoreLabel}</span>
<span> ${post.fav_count}</span> <span>${favLabel}</span>
</div> </div>
</div> </div>
`; `;
@ -598,18 +952,45 @@ async function loadFurtokPage(feedEl) {
// Тап по видео = unmute + play/pause // Тап по видео = unmute + play/pause
const video = card.querySelector('video'); const video = card.querySelector('video');
if (video) { if (video) {
if (isShorties) {
initShortiesVideoPlayback(video);
}
video.addEventListener('error', () => {
if (isShorties && video.dataset.proxySrc && video.src !== video.dataset.proxySrc) {
video.src = video.dataset.proxySrc;
video.load();
}
});
card.addEventListener('click', () => { card.addEventListener('click', () => {
video.muted = false; video.muted = false;
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');
if (shareBtn) {
shareBtn.addEventListener('click', async (event) => {
event.preventDefault();
event.stopPropagation();
if (!shareUrl) return;
const copied = await copyToClipboard(shareUrl);
saveShortiesLinkLocally(shareUrl);
if (copied) haptic('success');
else haptic('impact');
shareBtn.textContent = copied ? '✅ Saved' : '💾 Saved';
setTimeout(() => {
shareBtn.textContent = '🔗 Share';
}, 1200);
});
} }
feedEl.appendChild(card); feedEl.appendChild(card);
furtokCards.push(card);
}); });
// IntersectionObserver для автоплейа
setupAutoplay(feedEl);
haptic('success'); haptic('success');
} catch (e) { } catch (e) {
loader.remove(); loader.remove();
@ -621,20 +1002,6 @@ async function loadFurtokPage(feedEl) {
furtokLoading = false; furtokLoading = false;
} }
function setupAutoplay(feedEl) {
const videos = feedEl.querySelectorAll('.furtok-card video');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.play().catch(() => {});
} else {
entry.target.pause();
}
});
}, { root: feedEl, threshold: 0.6 });
videos.forEach(v => observer.observe(v));
}
// ══════════════════════════════════════════ // ══════════════════════════════════════════
// PAGE: CHAT // PAGE: CHAT

View file

@ -32,7 +32,9 @@ server {
} }
location /api/ { location /api/ {
proxy_pass http://webapp-api:8080; resolver 127.0.0.11 valid=10s;
set $api http://webapp-api:8080;
proxy_pass $api;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Connection ""; proxy_set_header Connection "";
proxy_set_header Host $host; proxy_set_header Host $host;
@ -40,6 +42,9 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Telegram-Init-Data $http_x_telegram_init_data; proxy_set_header X-Telegram-Init-Data $http_x_telegram_init_data;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 300s;
} }
location / { location / {

View file

@ -1,2 +1,3 @@
fastapi>=0.111.0 fastapi>=0.111.0
uvicorn[standard]>=0.29.0 uvicorn[standard]>=0.29.0
aiohttp>=3.9.0

479
webapp/shorties.py Normal file
View file

@ -0,0 +1,479 @@
from __future__ import annotations
import asyncio
import html
import json
import logging
import random
import re
from dataclasses import dataclass
from urllib.parse import urljoin
import aiohttp
logger = logging.getLogger(__name__)
PH_BASE_URL = "https://rt.pornhub.com"
SHORTIES_LIST_URL = f"{PH_BASE_URL}/shorties"
REQUEST_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
"Referer": PH_BASE_URL,
}
_A_HREF_RE = re.compile(
r'<a[^>]+href="(?P<href>/view_video\.php\?viewkey=[^"]+)"(?P<attrs>[^>]*)>(?P<body>.*?)</a>',
re.IGNORECASE | re.DOTALL,
)
_IMG_URL_RE = re.compile(
r'(?:data-mediumthumb|data-path|data-thumb_url|src)="(?P<url>https?://[^"]+)"',
re.IGNORECASE,
)
_TITLE_ATTR_RE = re.compile(r'title="(?P<title>[^"]+)"', re.IGNORECASE)
_SCRIPT_LD_JSON_RE = re.compile(
r'<script[^>]+type="application/ld\+json"[^>]*>(?P<json>.*?)</script>',
re.IGNORECASE | re.DOTALL,
)
_MEDIA_DEFS_RE = re.compile(r'"mediaDefinitions"\s*:\s*(\[[^\]]+\])', re.IGNORECASE | re.DOTALL)
_VIDEO_URL_RE = re.compile(r'"videoUrl"\s*:\s*"(?P<url>https?:\\?/\\?/[^"]+\.mp4[^"]*)"', re.IGNORECASE)
_DURATION_RE = re.compile(r'"video_duration"\s*:\s*"?(?P<duration>\d+)"?', re.IGNORECASE)
_VIEWS_RE = re.compile(r'"video_views"\s*:\s*"?(?P<views>[0-9,\.]+)"?', re.IGNORECASE)
_JSON_SHORTIES_MARKER = "JSON_SHORTIES = insertAfterNthPosition("
_EMBED_SRC_RE = re.compile(r'<iframe[^>]+src="([^"]+)"', re.IGNORECASE)
_CYRILLIC_RE = re.compile(r"[А-Яа-яЁё]")
_RUSSIAN_MARKERS = {
"russian",
"russia",
"russkiy",
"russkaya",
"russkoe",
"русский",
"русская",
"русское",
"россия",
}
@dataclass
class ShortiesCandidate:
page_url: str
title: str
thumb_url: str
duration: str = ""
views: str = ""
def _clean_html_text(value: str) -> str:
no_tags = re.sub(r"<[^>]+>", " ", value)
return " ".join(html.unescape(no_tags).split())
def _absolute_url(value: str) -> str:
return urljoin(PH_BASE_URL, value)
def _extract_ldjson_candidates(page_html: str) -> list[ShortiesCandidate]:
out: list[ShortiesCandidate] = []
for match in _SCRIPT_LD_JSON_RE.finditer(page_html):
raw_json = html.unescape(match.group("json").strip())
if not raw_json:
continue
try:
parsed = json.loads(raw_json)
except json.JSONDecodeError:
continue
nodes: list[object]
if isinstance(parsed, list):
nodes = parsed
else:
nodes = [parsed]
for node in nodes:
if not isinstance(node, dict):
continue
if str(node.get("@type", "")).lower() != "videoobject":
continue
page_url = str(node.get("url") or "").strip()
thumb = str(node.get("thumbnailUrl") or "").strip()
title = str(node.get("name") or "").strip() or "Shorties"
if not page_url:
continue
out.append(
ShortiesCandidate(
page_url=_absolute_url(page_url),
title=title,
thumb_url=_absolute_url(thumb) if thumb else "",
)
)
return out
def _extract_anchor_candidates(page_html: str) -> list[ShortiesCandidate]:
out: list[ShortiesCandidate] = []
for match in _A_HREF_RE.finditer(page_html):
href = match.group("href").strip()
attrs = match.group("attrs") or ""
body = match.group("body") or ""
page_url = _absolute_url(href)
title_match = _TITLE_ATTR_RE.search(attrs) or _TITLE_ATTR_RE.search(body)
title = _clean_html_text(title_match.group("title")) if title_match else _clean_html_text(body)
if not title:
title = "Shorties"
thumb_match = _IMG_URL_RE.search(attrs) or _IMG_URL_RE.search(body)
thumb_url = _absolute_url(thumb_match.group("url")) if thumb_match else ""
out.append(ShortiesCandidate(page_url=page_url, title=title, thumb_url=thumb_url))
return out
def _dedupe_candidates(items: list[ShortiesCandidate]) -> list[ShortiesCandidate]:
result: list[ShortiesCandidate] = []
seen: set[str] = set()
for item in items:
key = item.page_url.strip()
if not key or key in seen:
continue
seen.add(key)
result.append(item)
return result
async def _fetch_text(session: aiohttp.ClientSession, url: str) -> str:
async with session.get(url, headers=REQUEST_HEADERS) as resp:
if resp.status != 200:
logger.warning("Shorties request failed: %s status=%s", url, resp.status)
return ""
return await resp.text()
def _extract_best_mp4(page_html: str) -> str:
media_match = _MEDIA_DEFS_RE.search(page_html)
if media_match:
raw = media_match.group(1)
try:
defs = json.loads(raw)
except json.JSONDecodeError:
defs = []
best_url = ""
best_quality = -1
for item in defs:
if not isinstance(item, dict):
continue
video_url = item.get("videoUrl")
if not isinstance(video_url, str) or ".mp4" not in video_url:
continue
quality_raw = str(item.get("quality") or "").strip("p ")
try:
quality = int(quality_raw)
except ValueError:
quality = 0
if quality >= best_quality:
best_quality = quality
best_url = video_url
if best_url:
return best_url.replace("\\/", "/")
for match in _VIDEO_URL_RE.finditer(page_html):
candidate = match.group("url").replace("\\/", "/")
if ".mp4" in candidate:
return candidate
return ""
def _extract_balanced_json_array(page_html: str, marker: str) -> str:
marker_idx = page_html.find(marker)
if marker_idx < 0:
return ""
arr_start = page_html.find("[", marker_idx)
if arr_start < 0:
return ""
depth = 0
in_string = False
escaped = False
for i in range(arr_start, len(page_html)):
ch = page_html[i]
if in_string:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == '"':
in_string = False
continue
if ch == '"':
in_string = True
continue
if ch == "[":
depth += 1
continue
if ch == "]":
depth -= 1
if depth == 0:
return page_html[arr_start : i + 1]
continue
return ""
def _extract_json_shorties(page_html: str) -> list[dict]:
raw_array = _extract_balanced_json_array(page_html, _JSON_SHORTIES_MARKER)
if not raw_array:
return []
try:
parsed = json.loads(raw_array)
except json.JSONDecodeError:
logger.warning("Shorties parser: failed to decode JSON_SHORTIES array")
return []
if not isinstance(parsed, list):
return []
return [item for item in parsed if isinstance(item, dict) and item.get("videoTitle")]
def _pick_mp4_from_media_defs(media_defs: object) -> str:
if not isinstance(media_defs, list):
return ""
best_url = ""
best_quality = -1
for item in media_defs:
if not isinstance(item, dict):
continue
if str(item.get("format", "")).lower() != "mp4":
continue
raw_url = item.get("videoUrl")
if not isinstance(raw_url, str) or not raw_url:
continue
quality_raw = str(item.get("quality") or "").strip("p ")
try:
quality = int(quality_raw)
except ValueError:
quality = 0
if quality >= best_quality:
best_quality = quality
best_url = raw_url
return best_url.replace("\\/", "/")
def _pick_hls_from_media_defs(media_defs: object) -> str:
if not isinstance(media_defs, list):
return ""
best_url = ""
best_quality = -1
for item in media_defs:
if not isinstance(item, dict):
continue
if str(item.get("format", "")).lower() != "hls":
continue
raw_url = item.get("videoUrl")
if not isinstance(raw_url, str) or ".m3u8" not in raw_url:
continue
quality_raw = str(item.get("quality") or "").strip("p ")
try:
quality = int(quality_raw)
except ValueError:
quality = 0
if quality >= best_quality:
best_quality = quality
best_url = raw_url
return best_url.replace("\\/", "/")
def _is_russian_item(item: dict) -> bool:
title = str(item.get("videoTitle") or item.get("metaTitle") or "")
if _CYRILLIC_RE.search(title):
return True
pills = item.get("pillsData")
if isinstance(pills, list):
for pill in pills:
if not isinstance(pill, dict):
continue
values = [str(pill.get("name") or ""), str(pill.get("slug") or "")]
for raw in values:
normalized = raw.strip().lower()
if any(marker in normalized for marker in _RUSSIAN_MARKERS):
return True
return False
def _is_russian_feed_item(item: dict) -> bool:
title = str(item.get("title") or "")
lowered = title.lower()
return bool(_CYRILLIC_RE.search(title) or any(marker in lowered for marker in _RUSSIAN_MARKERS))
def _feed_from_json_shorties(items: list[dict]) -> list[dict]:
feed: list[dict] = []
seen: set[str] = set()
for item in items:
if not _is_russian_item(item):
continue
hls_url = _pick_hls_from_media_defs(item.get("mediaDefinitions"))
mp4_url = _pick_mp4_from_media_defs(item.get("mediaDefinitions"))
if not hls_url and not mp4_url:
continue
source = str(item.get("linkUrl") or item.get("uniqueUrl") or item.get("shortieUrl") or "").strip()
if source and source.startswith("/"):
source = _absolute_url(source)
key = source or hls_url or mp4_url
if key in seen:
continue
seen.add(key)
tracking = item.get("trackingTimeWatched")
duration_raw = ""
if isinstance(tracking, dict):
duration_raw = str(tracking.get("video_duration") or "").strip()
title = str(item.get("videoTitle") or item.get("metaTitle") or "Shorties").strip()
thumb = str(item.get("imageUrl") or "").strip().replace("\\/", "/")
views = str(item.get("likeInfo") or item.get("likeNumber") or "").strip()
favorites = str(item.get("favoriteInfo") or item.get("favoriteNumber") or "").strip()
embed_url = ""
embed_raw = item.get("embedUrl")
if isinstance(embed_raw, str) and embed_raw.strip():
unescaped = html.unescape(embed_raw.replace("\\/", "/"))
match = _EMBED_SRC_RE.search(unescaped)
if match:
embed_url = match.group(1).strip()
feed.append(
{
"type": "video",
"url": hls_url or mp4_url,
"sample": thumb or hls_url or mp4_url,
"ext": "mp4",
"score": 0,
"fav_count": favorites or "0",
"title": title,
"duration": duration_raw,
"views": views,
"source": source or _absolute_url("/shorties"),
"hls_url": hls_url,
"mp4_url": mp4_url,
"embed_url": embed_url,
}
)
return feed
def _extract_meta_fields(page_html: str) -> tuple[str, str]:
duration_match = _DURATION_RE.search(page_html)
views_match = _VIEWS_RE.search(page_html)
duration = duration_match.group("duration") if duration_match else ""
views = views_match.group("views") if views_match else ""
return duration, views
async def _resolve_video_candidate(
session: aiohttp.ClientSession,
item: ShortiesCandidate,
semaphore: asyncio.Semaphore,
) -> dict | None:
async with semaphore:
page_html = await _fetch_text(session, item.page_url)
if not page_html:
return None
mp4_url = _extract_best_mp4(page_html)
if not mp4_url:
return None
duration, views = _extract_meta_fields(page_html)
if not item.duration:
item.duration = duration
if not item.views:
item.views = views
return {
"type": "video",
"url": mp4_url,
"sample": item.thumb_url or mp4_url,
"ext": "mp4",
"score": 0,
"fav_count": 0,
"title": item.title,
"duration": item.duration or "",
"views": item.views or "",
"source": item.page_url,
}
async def fetch_shorties_feed(count: int = 8, random_pages: int = 3) -> list[dict]:
count = max(1, min(count, 20))
random_pages = max(1, min(random_pages, 2))
page_numbers = {1}
while len(page_numbers) < random_pages:
page_numbers.add(random.randint(1, 40))
list_urls = [f"{SHORTIES_LIST_URL}?page={page}" for page in sorted(page_numbers)]
timeout = aiohttp.ClientTimeout(total=20)
connector = aiohttp.TCPConnector(limit=16)
async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session:
list_pages = await asyncio.gather(*[_fetch_text(session, url) for url in list_urls], return_exceptions=True)
json_shorties_feed: list[dict] = []
for page in list_pages:
if isinstance(page, Exception) or not page:
continue
shorties_items = _extract_json_shorties(page)
if not shorties_items:
continue
json_shorties_feed.extend(_feed_from_json_shorties(shorties_items))
if json_shorties_feed:
russian_only = [item for item in json_shorties_feed if _is_russian_feed_item(item)]
# Строго пытаемся отдать russian first; если пусто — отдаём локализованную rt-ленту.
selected_feed = russian_only if russian_only else json_shorties_feed
random.shuffle(selected_feed)
# dedupe by source/url after enrichment
dedup: list[dict] = []
seen_keys: set[str] = set()
for item in selected_feed:
key = str(item.get("source") or item.get("url") or "").strip()
if not key or key in seen_keys:
continue
seen_keys.add(key)
dedup.append(item)
return dedup[:count]
candidates: list[ShortiesCandidate] = []
for page in list_pages:
if isinstance(page, Exception) or not page:
continue
candidates.extend(_extract_ldjson_candidates(page))
candidates.extend(_extract_anchor_candidates(page))
candidates = _dedupe_candidates(candidates)
if not candidates:
logger.warning("Shorties parser: no candidates found")
return []
random.shuffle(candidates)
candidates = candidates[: max(count * 3, 12)]
semaphore = asyncio.Semaphore(5)
resolved = await asyncio.gather(
*[_resolve_video_candidate(session, item, semaphore) for item in candidates],
return_exceptions=True,
)
feed: list[dict] = []
for item in resolved:
if isinstance(item, dict) and item.get("url"):
feed.append(item)
russian_only_fallback = [item for item in feed if _is_russian_feed_item(item)]
if russian_only_fallback:
feed = russian_only_fallback
random.shuffle(feed)
return feed[:count]