Compare commits

...

143 commits

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
furrexwolfy
a8693b010f Merge remote-tracking branch 'upstream/main' 2026-04-13 23:14:14 +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
furrexwolfy
61af0ae17b Add uwu command description to README.md 2026-04-13 17:17:42 +03:00
q
316d97b280 Merge pull request 'chore: sync and push recent updates' (#22) from Furrex/bot_tg:main into main
Reviewed-on: zovos/bot_tg#22
2026-04-13 13:51:31 +00:00
furrexwolfy
ddf63df6b6 Add more cute and ironic captions to uwu command 2026-04-13 16:44:18 +03:00
q
e9d8a331dd Merge pull request 'Фикс истории ставок и скролл видео' (#24) from Dan4ick/bot_tg:feature/FenyaBot into main
Reviewed-on: zovos/bot_tg#24
2026-04-12 19:56:24 +00:00
Danil
2295e73455 Фикс истории ставок и скролл видео 2026-04-12 22:29:32 +03:00
q
783b26003a update fronted and fix llm 2026-04-12 13:14:00 +03:00
q
90977abda7 upd nginx with tls 2026-04-12 12:53:01 +03:00
q
c307f48ebb Merge pull request 'patch_Oreshnik' (#23) from Dan4ick/bot_tg:feature/FenyaBot into main
Reviewed-on: zovos/bot_tg#23
2026-04-12 08:50:49 +00:00
q
cecfaa996c ai update text prompt 2026-04-12 11:06:07 +03:00
q
5e8df9c40f ai update vision promt 2026-04-12 11:01:52 +03:00
q
4a02af047a ai update fix history message 2026-04-12 10:49:10 +03:00
Omelechko Danil
38b0c43972 Merge remote-tracking branch 'upstream/main' into feature/FenyaBot 2026-04-12 04:13:23 +03:00
Omelechko Danil
298a5d7d59 Update 2026-04-12 04:11:08 +03:00
q
a33443936c add photo triget fix4 2026-04-11 17:50:10 +03:00
q
a2b72b6dfd add photo triget fix2 2026-04-11 17:43:00 +03:00
q
d3ee72bca9 add photo triget fix1 2026-04-11 17:39:34 +03:00
q
c756acf428 add photo triget fix 2026-04-11 17:35:44 +03:00
q
cec0256309 add photo triget 2026-04-11 17:31:53 +03:00
q
49957d88a8 update ai dolboeba fix4 ROMA POSHEL NAHUYx2 2026-04-11 16:49:05 +03:00
q
b2556b06a9 update ai dolboeba fix4 ROMA POSHEL NAHUY 2026-04-11 16:44:51 +03:00
q
5e91def067 update ai dolboeba fix3 2026-04-11 16:26:46 +03:00
q
924448b4e0 update ai dolboeba fix2 2026-04-11 16:24:14 +03:00
q
597839bb55 update ai dolboeba fix1 2026-04-11 16:19:36 +03:00
q
4d0a3841b7 update ai dolboeba 2026-04-11 16:15:14 +03:00
Omelechko Danil
b47e6bee81 Update 2026-04-11 00:59:38 +03:00
itexpert228
628099b443
delete govnshe ebanoe 2026-04-10 12:37:16 +03:00
q
9f8f767ae5 some fix for nude save db with 2026-04-10 12:14:20 +03:00
q
eeed9af545 some fix for nude save db with history with pic 2026-04-10 12:01:20 +03:00
q
53b6fa72ae some fix for nude change word 2026-04-10 11:20:14 +03:00
q
7afd8ca216 some fix for nude 2026-04-10 11:17:45 +03:00
q
06d97cbff0 Merge pull request 'suki' (#20) from jeffrey228/bot_tg:main into main
Reviewed-on: zovos/bot_tg#20
2026-04-10 07:58:06 +00:00
itexpert228
6433d9c11e
suki 2026-04-10 10:56:19 +03:00
q
361e86c384 Merge pull request 'pisi i sisi' (#19) from jeffrey228/bot_tg:main into main
Reviewed-on: zovos/bot_tg#19
2026-04-10 07:49:20 +00:00
itexpert228
78dc7f4810
pisi i sisi 2026-04-10 10:47:56 +03:00
Omelechko Danil
c3487c22b4 feat: TG Mini App - FastAPI backend + frontend SPA + Docker setup 2026-04-04 23:03:17 +03:00
q
3039326100 Merge pull request 'fix: stavki v2' (#18) from Dan4ick/bot_tg:feature/FenyaBot into main
Reviewed-on: zovos/bot_tg#18
2026-03-23 16:45:35 +00:00
Omelechko Danil
481965e8f2 feat: /matches without args shows list of available leagues 2026-03-23 18:22:34 +03:00
Omelechko Danil
e014019912 perf: increase odds cache TTL to 24h to save API quota 2026-03-23 18:19:04 +03:00
Omelechko Danil
f0cefc7f23 fix: uwu gifs now sent as animation, caption as separate message 2026-03-23 17:13:09 +03:00
Omelechko Danil
3baf71ab98 fix: replace dead esports keys with football leagues, fix payout stats 2026-03-23 17:07:12 +03:00
q
29cd511186 Merge pull request 'feat: add more random captions for /uwu' (#17) from Furrex/bot_tg:main into main
Reviewed-on: zovos/bot_tg#17
2026-03-23 10:12:40 +00:00
furrexwolfy
1925c44ac6 feat: add more random captions for /uwu command 2026-03-23 13:09:58 +03:00
q
801d6da69b Merge pull request 'fix: matches + bet history with monthly stats' (#16) from Dan4ick/bot_tg:feature/FenyaBot into main
Reviewed-on: zovos/bot_tg#16
2026-03-23 09:56:35 +00:00
Omelechko Danil
6b6c1dd15d fix: matches + bet history with monthly stats 2026-03-23 10:55:25 +03:00
q
4ecc25239e Merge pull request 'feat: add help menu for /uwu' (#15) from Furrex/bot_tg:main into main
Reviewed-on: zovos/bot_tg#15
2026-03-21 10:16:19 +00:00
furrexwolfy
2bcc726eea feat: add -h help flag to /uwu command 2026-03-21 13:15:31 +03:00
q
93d8c2187a feat: add /uwu command for e621 (#14) 2026-03-21 09:58:51 +00:00
furrexwolfy
967cfb4a6d feat: add /uwu command for e621.net 2026-03-21 12:56:05 +03:00
q
107a0647d1 fix 2026-03-20 12:24:05 +03:00
q
7289296169 Merge pull request 'Ставки на спорт' (#13) from Dan4ick/bot_tg:feature/FenyaBot into main
Reviewed-on: zovos/bot_tg#13
2026-03-20 07:57:19 +00:00
Danil
aee53349b1 Добавлены ставки на матчи 2026-03-19 23:49:25 +03:00
q
a7c047f847 modern 2026-03-18 09:39:32 +03:00
q
9295538f5f Add lesson schedule command 2026-03-17 13:29:10 +03:00
q
5b294415d0 Add polychaetsi tracker and improve Docker setup 2026-03-10 13:08:04 +03:00
q
43503dc778 Send all Magnit price matches 2026-03-09 11:51:43 +03:00
q
75a63bb6fc Add Magnit prices command 2026-03-09 11:48:20 +03:00
q
682fc7422c fix dockerfile 2026-03-07 16:09:44 +03:00
q
373aa8f6c2 Merge pull request 'feature/FenyaBot' (#12) from Dan4ick/bot_tg:feature/FenyaBot into main
Reviewed-on: zovos/bot_tg#12
2026-03-07 13:06:19 +00:00
Danil
9f6a7464d1 Добавлен казик и гадание 2026-03-07 15:58:53 +03:00
Danil
fc85149345 Merge remote-tracking branch 'upstream/main' into feature/FenyaBot 2026-03-07 15:19:51 +03:00
Danil
7035a90656 Обновление триггеров авто-ответа 2026-03-07 15:19:46 +03:00
q
73e539b4c4 fix /break 2026-03-05 13:06:06 +03:00
q
c59858df53 fix /maket 2026-03-05 12:41:55 +03:00
q
24245f6b87 fix 2026-03-05 12:33:19 +03:00
q
f14b1a8209 add second maket 2026-03-05 12:30:17 +03:00
q
6afc30570f auto send to smoke 2026-03-05 12:05:30 +03:00
q
8bc905331c try to fix ai and change promte 2026-03-03 14:57:16 +03:00
q
1cbebeec37 try to fix ai 2026-03-03 14:54:48 +03:00
q
0ac74b95c5 add mute for bot 2026-03-01 16:14:46 +03:00
q
7494ab2dfa Fix prompt 2026-02-28 10:36:31 +03:00
q
ba6bee06e3 Fix prompt and logic work with chat 2026-02-28 10:32:31 +03:00
q
79e581a010 Merge pull request 'Добавлен контекс' (#11) from Dan4ick/bot_tg:feature/FenyaBot into main
Reviewed-on: zovos/bot_tg#11
2026-02-27 09:37:56 +00:00
Danil
9b34fd13d5 Добавлен контекс 2026-02-27 12:29:53 +03:00
q
fad8629599 Merge pull request 'Добавлен авто-ответ' (#10) from Dan4ick/bot_tg:feature/FenyaBot into main
Reviewed-on: zovos/bot_tg#10
2026-02-27 07:04:04 +00:00
Danil
126ae083cb Добавлен авто-ответ 2026-02-27 00:30:57 +03:00
q
06b910cf65 Update system prompt vanomas vibe fifth 2026-02-23 13:16:41 +03:00
q
fc37d9eaee Update system prompt vanomas vibe fourh 2026-02-23 13:15:39 +03:00
q
75cb997167 Update system prompt vanomas vibe third try (add more tokens) 2026-02-23 13:12:13 +03:00
q
c1c8863435 Update system prompt vanomas vibe second try 2026-02-23 13:10:22 +03:00
q
a29815d868 Update system prompt vanomas vibe 2026-02-23 13:08:07 +03:00
q
9738225df3 Update system prompt 2026-02-23 12:45:14 +03:00
q
65da6a691b Merge pull request 'ПОЛНЫЙ РЕФАКТОРИНГ КОДА. Константы вынесены в config.py, упрощены импорты, вроде бы все работает, а там хуй знает))))' (#9) from SUDOZOVCHIK/bot_tg:main into main
Reviewed-on: zovos/bot_tg#9
2026-02-23 08:08:44 +00:00
hitoshi
07f6efe7b1 ПОЛНЫЙ РЕФАКТОРИНГ КОДА. Константы вынесены в config.py, упрощены импорты, вроде бы все работает, а там хуй знает)))) 2026-02-23 10:21:14 +03:00
q
b9c9ddb4bf fix docker 2026-02-22 21:05:22 +03:00
q
0d0967001a fix old staff 2026-02-22 21:02:36 +03:00
q
1c27e84dc1 Fix ZAVISIMOSTIgit add .git add . 2026-02-22 20:57:12 +03:00
q
62aa560886 Fix docker for db 2026-02-22 20:56:15 +03:00
q
957d46b735 Merge branch 'Zhirik1337-wpng' 2026-02-22 20:53:46 +03:00
q
8ea9c50564 Merge pull request 'СВО ЕБАТЬ' (#7) from jeffrey228/bot_tg:main into main
Reviewed-on: zovos/bot_tg#7
2026-02-22 17:47:48 +00:00
q
434ae86e55 Merge branch 'main' into wpng 2026-02-22 17:47:02 +00:00
itexpert228
caebebf3cf
СВО ЕБАТЬ 2026-02-22 20:40:51 +03:00
e56a11461b Изменения: что изменилось
Добавлена и доработана миниигра /penis
Логика рандома +/- 0.1..7.0, ограничение минимума до 0.1, cooldown 24ч.
Файлы/места: main.py (line 420), main.py (line 579).
Добавлена команда /top_penis
Динамический топ по текущим значениям см.
Формат с медалями и лимитом топа.
Файлы/места: main.py (line 505), main.py (line 590).
Хранение данных /penis и /top_penis переведено с RAM на SQLite
Убрана in-memory модель, добавлена БД penis_stats.sqlite3.
Добавлена инициализация таблицы penis_stats.
Файлы/места: main.py (line 27), main.py (line 47), main.py (line 761).
Логика записи в БД исправлена под твое требование
Для нового пользователя: INSERT.
Для существующего: UPDATE (без новых строк).
Файл/место: main.py (line 468).
В топ добавлено нормальное имя пользователя
Берётся @username, иначе full_name, иначе user_<id>.
Файл/место: main.py (line 496).
Команды добавлены в help и Telegram command menu
В /help добавлены /penis и /top_penis.
В set_my_commands есть обе команды.
Файлы/места: main.py (line 152), main.py (line 778), main.py (line 783).
Улучшено отображение команд через /
Команды выставляются для scope:
default
all private chats
all group chats
Файл/место: main.py (line 790), main.py (line 796).
Добавлены подробные комментарии в код
По основным блокам main.py и talk_handler.py.
Файлы: main.py, talk_handler.py.
Проверки, которые прогонялись
Компиляция: talk_handler.py.
Локальные тесты:
insert/update/cooldown /penis;
сортировка /top_penis;
запись полей в SQLite;
smoke по генерации мемов и тайм-функциям.
2026-02-22 20:33:27 +03:00
q
97c1795831 Fix ai server url 2026-02-22 20:08:45 +03:00
q
185ef45621 Merge pull request 'Изменения: что изменилось' (#5) from Zhirik1337/bot_tg:wpng into main
Reviewed-on: zovos/bot_tg#5
2026-02-22 17:04:43 +00:00
ac491e6433 Изменения: что изменилось
Добавлена миниигра /penis с хранением состояния по пользователю (в памяти процесса).
main.py (line 28)
main.py (line 29)
main.py (line 32)
main.py (line 400)
Что делает:
хранит length и last_used_at на user_id;
даёт изменение длины случайно +/- в диапазоне 0.1..7.0;
ограничивает минимум до 0.1;
вводит кулдаун 24 часа от последнего вызова.
Реализован динамический топ /top_penis.
main.py (line 33)
main.py (line 454)
main.py (line 590)
Что делает:
собирает текущие значения из PENIS_STATE;
сортирует по убыванию см;
показывает медали 🥇🥈🥉 и далее нумерацию;
лимит топа по умолчанию 10 участников;
при пустом состоянии пишет, что топ пуст.
Добавлена нормализация имени пользователя для топа.
main.py (line 445)
main.py (line 585)
Как работает:
приоритет @username;
если нет username, берёт full_name;
fallback: user_<id>.
Обновлены подсказки и меню Telegram-команд.
main.py (line 153)
main.py (line 716)
main.py (line 735)
Что добавлено:
строка в /help про /top_penis;
BotCommand("top_penis") в меню;
регистрация обработчика Command("top_penis").
Добавлены подробные комментарии по коду.
main.py (line 21)
main.py (line 146)
main.py (line 400)
talk_handler.py (line 10)
talk_handler.py (line 45)
Что покрыто:
константы/секции;
игровые функции;
хендлеры;
старт и регистрация роутов;
пояснения к LLM-модулю /talk.
Что протестировано локально.
compileall для main.py и talk_handler.py — успешно.
Смоук-тесты функций:
parse_up_down, minutes_until_break, minutes_until_next_lesson;
generate_maket, generate_meme;
play_penis (включая cooldown);
build_penis_top (сортировка/пустой топ).
Итог: основные проверки прошли.
Текущее ограничение реализации.
Состояние /penis и /top_penis хранится только в RAM, после перезапуска бота сбрасывается.
main.py (line 28)
2026-02-22 19:40:57 +03:00
q
947e0010e8 Merge pull request 'ИИ-бот' (#4) from Dan4ick/bot_tg:feature/FenyaBot into main
Reviewed-on: zovos/bot_tg#4
2026-02-22 13:27:01 +00:00
Danil
d39ada437c ИИ-бот на фене через llama.cpp 2026-02-22 14:07:01 +03:00
q
091022facc Merge pull request 'Ебать у меня идеи, а вы и дальше говорите что алкоголь вреден...........7' (#3) from jeffrey228/bot_tg:main into main
Reviewed-on: zovos/bot_tg#3
2026-02-21 04:48:55 +00:00
itexpert228
de07891926
Ебать у меня идеи, а вы и дальше говорите что алкоголь вреден...........7 2026-02-20 23:28:22 +03:00
q
a85ab1c639 Fix api domain 2026-02-20 18:43:05 +03:00
q
99946bc923 Fix, i am down 2026-02-20 14:27:58 +03:00
q
f385bedd9b add new zaloopa 2026-02-20 14:23:47 +03:00
q
1d79125500 Remove tracked pycache 2026-02-20 13:57:49 +03:00
q
aafb64ad4e Resolve merge conflict: combine size and creep handlers 2026-02-20 13:57:49 +03:00
q
11ad1c43c9 Add radiohead creeeeeeep 2026-02-20 13:08:48 +03:00
hitoshi
5072450244 Ну короче реализация вайба, я ща ОБОССУСЬ НАХУЙ 2026-02-20 12:40:54 +03:00
q
e151ae5269 Fix command /size 2026-02-20 12:36:45 +03:00
q
8ba3ea1e0a Merge pull request 'main' (#1) from jeffrey228/bot_tg:main into main
Reviewed-on: zovos/bot_tg#1
2026-02-20 09:33:32 +00:00
8bd7585c52 Merge pull request 'Обновить main.py' (#1) from jeffrey228-patch-1 into main
Reviewed-on: #1
2026-02-20 09:32:08 +00:00
47 changed files with 9730 additions and 281 deletions

0
.codex Normal file
View file

View file

@ -5,3 +5,4 @@ __pycache__
.idea .idea
.vscode .vscode
.DS_Store .DS_Store
db

2
.env
View file

@ -1,2 +0,0 @@
BOT_TOKEN=8488665890:AAH7FY5I2xzHhfOdxlzb11ewl2I-MMTH2-o
TZ=Europe/Moscow

5
.gitignore vendored
View file

@ -18,3 +18,8 @@ db/
.pytest_cache/ .pytest_cache/
.mypy_cache/ .mypy_cache/
AI/models/
main_legacy.py
docker-compose.override.yaml

1100
AI/talk_handler.py Normal file

File diff suppressed because it is too large Load diff

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

@ -1,8 +1,17 @@
FROM python:3.11-slim FROM python:3.11-slim
ENV PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=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 pip install --no-cache-dir -r requirements.txt RUN python -m pip install --upgrade pip \
COPY main.py impact.ttf ./ && python -m pip install -r requirements.txt
COPY maket ./maket
COPY . .
RUN mkdir -p /db /app/data
CMD ["python", "main.py"] CMD ["python", "main.py"]

18
Dockerfile.api Normal file
View file

@ -0,0 +1,18 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
COPY requirements.txt ./
COPY webapp/requirements-api.txt ./requirements-api.txt
RUN python -m pip install --upgrade pip \
&& python -m pip install -r requirements.txt \
&& python -m pip install -r requirements-api.txt
COPY . .
RUN mkdir -p /db
EXPOSE 8080
CMD ["python", "-m", "webapp.api"]

118
README.md
View file

@ -3,15 +3,46 @@
Телеграм‑бот, который: Телеграм‑бот, который:
- вписывает текст шрифтом Impact (белый с чёрной обводкой) в макеты (`/maket`); - вписывает текст шрифтом Impact (белый с чёрной обводкой) в макеты (`/maket`);
- делает мемы из присланных фото (`/gen_mem Up:... Down:...`); - делает мемы из присланных фото (`/gen_mem Up:... Down:...`);
- подсказывает, сколько минут до перекура по заданному расписанию (`/break`). - подсказывает, сколько минут до перекура по заданному расписанию (`/break`);
- показывает расписание, текущую и следующую пару (`/schedule`, можно с днём недели);
- умеет базарить (`/talk`) с контекстом 100 сообщений;
- авто-ответы в чате (10% шанс + триггер-слова);
- миниигра `/penis` + `/top_penis`;
- казино на размер (`/penis_casino`);
- гадание на фене с мемом (`/gadanie`);
- ставки на матчи CS2, Dota 2, футбол с реальными коэффициентами (`/matches`, `/bet`).
## Запуск ## Запуск
```bash ```bash
docker-compose up --build docker compose up --build -d
``` ```
Переменные окружения: задайте `BOT_TOKEN` в `.env` (уже добавлен шаблон). Логи:
```bash
docker compose logs -f bot
```
Остановка:
```bash
docker compose down
```
Переменные окружения: задайте `BOT_TOKEN` в `.env`.
Для ИИ-фич нужен `LLAMA_API_URL`.
По умолчанию проект настроен на `https://mirror.porno4free.ru/zovos-ai/`.
Если нужен лог reasoning/thinking модели, оставь `LLAMA_LOG_THINKING=1`; чтобы модель не резалась в режим без thinking, задай `LLAMA_FORCE_DISABLE_THINKING=0`.
Для ставок нужен `ODDS_API_KEY` (бесплатно на https://the-odds-api.com).
Контейнер хранит состояние в `./db`:
- `penis_stats.sqlite3` — статистика игры /penis
- `chat_history.sqlite3` — история для ИИ-общения
- `polychaetsi_stats.json` — статистика слова "получается"
- `bets.sqlite3` — ставки на спорт
- `economy.sqlite3` — экономическая система (балансы, вклады, кредиты)
- `nude_history.json` — история запросов /nude
## Команды ## Команды
@ -20,6 +51,52 @@ docker-compose up --build
- `/maket <id> <текст>` — вписать текст в макет. Доступные: `1 — Владимир Владимирович Путин самый лучший президент` (737x414, точка 104,240). - `/maket <id> <текст>` — вписать текст в макет. Доступные: `1 — Владимир Владимирович Путин самый лучший президент` (737x414, точка 104,240).
- `/gen_mem Up:верх Down:низ` — прислать с фото (подпись) или ответом на фото. Можно указывать только Up или только Down. - `/gen_mem Up:верх Down:низ` — прислать с фото (подпись) или ответом на фото. Можно указывать только Up или только Down.
- `/break` — показывает, сколько минут до ближайшего перекура/идёт ли перекур. - `/break` — показывает, сколько минут до ближайшего перекура/идёт ли перекур.
- `/schedule [пн|mon]` — показывает расписание на ближайший указанный день недели; без аргумента показывает сегодня, текущую и следующую пару.
- `/talk <сообщение>` — побазарить с ИИ (контекст 100 сообщений).
- `/penis` — миниигра, 1 раз в 24 часа.
- `/top_penis` — топ по длине.
- `/polychaetsi` — топ по слову «получается».
- `/penis_casino <ставка>` — казино на размер (макс 1 см, казино всегда в выигрыше).
- `/gadanie <тема>` — ИИ-гадание на фене + авто-мем.
- `/ebalnik` — вкл/выкл авто-ответы бота в чате.
- `/matches [cs|dota|football]` — ближайшие матчи с коэффициентами.
- `/bet [спорт] <номер> <исход> <ставка>` — поставить см на матч.
- `/mybets` — мои активные ставки.
- `/svodka` — сводка СВО.
- `/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 см
- Полностью автоматическая работа без вмешательства
- Все налоги автоматически поступают в ЦБ
- Алгоритмическое регулирование инфляции
- Автоматическая эмиссия при дефляции
- Автоматическая скупка денег при инфляции
- Прозрачная статистика для всех пользователей
- Никакого ручного управления - система работает по правилам
## Макеты и шрифт ## Макеты и шрифт
@ -29,10 +106,43 @@ docker-compose up --build
## Технологии ## Технологии
- Python 3.11, aiogram 3, Pillow. - Python 3.11, aiogram 3, Pillow.
- llama.cpp для ИИ-фич.
- Dockerfile + docker-compose для развёртывания. - Dockerfile + docker-compose для развёртывания.
## Docker Notes
- Образ ставит `tzdata`, поэтому `ZoneInfo("Europe/Moscow")` нормально работает в контейнере.
- В образ копируется весь проект, а runtime-данные исключены через `.dockerignore`.
- Если меняешь код или картинки, достаточно пересобрать: `docker compose up --build -d`.
## HTTPS / Let's Encrypt
- Фронтендовый `nginx` теперь слушает `80` и `443`, отдает ACME challenge и сам запускает `certbot` внутри контейнера.
- Для автоматической выдачи сертификата задай в `.env`:
- `APP_DOMAIN=bot.example.com`
- `APP_WWW_DOMAIN=www.bot.example.com` (опционально)
- `ACME_EMAIL=admin@example.com`
- Домен должен уже смотреть на сервер, а порты `80` и `443` должны быть доступны снаружи.
- Для безопасной первой проверки можно временно включить `CERTBOT_STAGING=1`, потом вернуть `0`.
- Сертификаты и webroot challenge хранятся в docker volumes `certbot_etc` и `certbot_www`, обновление запускается по кругу с интервалом `CERTBOT_RENEW_INTERVAL` (по умолчанию `12h`).
- Если нужен старый локальный маппинг портов, можно переопределить `WEBAPP_HTTP_PORT` и `WEBAPP_HTTPS_PORT`, но для реального Let's Encrypt внешний `80` должен оставаться доступным.
## Кратко о логике ## Кратко о логике
- `/maket` подбирает размер текста, чтобы уместить его в область макета. - `/maket` подбирает размер текста, чтобы уместить его в область макета.
- `/gen_mem` центрирует текст по ширине, масштабирует под изображение, рисует белый текст с чёрной обводкой. - `/gen_mem` центрирует текст по ширине, масштабирует под изображение, рисует белый текст с чёрной обводкой.
- `/break` считает по расписанию в `main.py` (словарь `SCHEDULE`). - `/break` считает по расписанию в `config.py` и показывает следующую пару.
- Названия пар и верхняя/нижняя неделя лежат в `config.py` в `LESSON_SCHEDULE`.
- `/talk` шлёт запрос к llama.cpp серверу с контекстом 100 сообщений (хранятся в SQLite).
- `/penis_casino` — слоты с множителем x1.2x3 (`games/casino.py`).
- `/gadanie` — ИИ-гадание + авто-мем через `generate_meme` (`games/fortune.py`).
- `/matches` + `/bet` — ставки на реальные матчи, коэффициенты через The-Odds-API (`games/betting.py`). После `/matches cs|dota|football` можно короче: `/bet <номер> <исход> <ставка>`.
## ИИ-модуль (`AI/talk_handler.py`)
Подключается к llama.cpp серверу через OpenAI-совместимый API (`/v1/chat/completions`). Хранит контекст 100 сообщений в SQLite.
Настройка сервера:
1. Скачать модель: `wget https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf`
2. Запустить: `./llama-server -m model.gguf --host 0.0.0.0 --port 8080 -c 2048`
3. Прописать URL в `.env`: `LLAMA_API_URL=http://адрес:8080`

Binary file not shown.

226
config.py Normal file
View file

@ -0,0 +1,226 @@
import os
from pathlib import Path
from datetime import timedelta, time
from zoneinfo import ZoneInfo
# --- Базовые пути ---
BASE_DIR = Path(__file__).parent
FONT_PATH = BASE_DIR / "impact.ttf"
DEFAULT_TZ = os.getenv("TZ", "Europe/Moscow")
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://bot:botpass@postgres:5432/botdb")
# --- Настройки /penis ---
PENIS_START_LENGTH = 10.0
PENIS_MIN_DELTA = 0.1
PENIS_MAX_DELTA = 7.0
PENIS_COOLDOWN = timedelta(hours=24)
PENIS_COOLDOWN_SECONDS = int(PENIS_COOLDOWN.total_seconds())
PENIS_TOP_LIMIT = 10
# --- Настройки "получается" ---
POLYCHAETSI_STATS_PATH = Path(os.getenv("POLYCHAETSI_STATS_PATH", "/db/polychaetsi_stats.json"))
POLYCHAETSI_TARGET = "получается"
POLYCHAETSI_TOP_LIMIT = 10
POLYCHAETSI_IMAGE_PATH = BASE_DIR / "maket" / "cvetok.jpg"
# --- Казино ---
CASINO_MAX_BET = 1.0
# --- E621 API ---
E621_LOGIN = os.getenv("E621_LOGIN", "")
E621_API_KEY = os.getenv("E621_API_KEY", "")
# --- Ставки на матчи ---
ODDS_API_KEY = os.getenv("ODDS_API_KEY", "")
ODDS_API_BASE = "https://api.the-odds-api.com/v4"
BET_MAX_AMOUNT = 3.0
ODDS_SPORTS = {
"dota2": "esports_dota2",
}
ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно)
# --- Nude ---
NUDE_HISTORY_PATH = Path(os.getenv("NUDE_HISTORY_PATH", "/db/nude_history.json"))
NUDE_POSTS_PER_PAGE = int(os.getenv("NUDE_POSTS_PER_PAGE", "100"))
NUDE_POSTS_POOL_TARGET = int(os.getenv("NUDE_POSTS_POOL_TARGET", "1200"))
NUDE_MAX_POSTS_TO_SCAN = int(os.getenv("NUDE_MAX_POSTS_TO_SCAN", "2000"))
NUDE_MIN_UNSEEN_POOL = int(os.getenv("NUDE_MIN_UNSEEN_POOL", "100"))
NUDE_HISTORY_MAX_SIZE = int(os.getenv("NUDE_HISTORY_MAX_SIZE", "5000"))
# --- Magnit API ---
MAGNIT_API_BASE_URL = os.getenv("MAGNIT_API_BASE_URL", "https://mirror.porno4free.ru/magnit")
MAGNIT_STORE_CODE = os.getenv("MAGNIT_STORE_CODE", "618224")
MAGNIT_REQUEST_TIMEOUT = int(os.getenv("MAGNIT_REQUEST_TIMEOUT", "15"))
# Можно указывать просто строку с названием или словарь с query/label/store_code.
MAGNIT_PRODUCTS = [
"RedBull",
]
# --- Конфигурация макетов ---
TEMPLATES = {
"1": {
"path": BASE_DIR / "maket" / "1.png",
"anchor": (104, 240),
"padding": 20,
"label": "Владимир Владимирович Путин самый лучший президент",
},
"2": {
"path": BASE_DIR / "maket" / "2.png",
"anchor": (40, 40),
"box_size": (582, 346),
"label": "Макет 2",
},
}
# --- Расписание ---
def make_time(h: int, m: int) -> time:
return time(hour=h, minute=m)
# Если у тебя наоборот, просто поменяй на False.
UPPER_WEEK_IS_ODD = True
PAIR_TIMES = {
1: (make_time(8, 0), make_time(9, 35)),
2: (make_time(9, 50), make_time(11, 25)),
3: (make_time(11, 55), make_time(13, 30)),
4: (make_time(13, 45), make_time(15, 20)),
5: (make_time(15, 50), make_time(17, 25)),
}
LESSON_SCHEDULE = {
"upper": {
0: [
{"pair": 1, "title": "Пары нету"},
{"pair": 2, "title": "История Россия (семинар), каб. 504"},
{"pair": 3, "title": "Первая Подгруппа: Основы программирование, каб. 606. Вторая подгруппа Глод."},
{"pair": 4, "title": "Первая Подгруппа: Глод. Вторая подгруппа Основы программирование, каб 606"},
{"pair": 5, "title": "Общекулутрная и профессиональная коммуникации."},
],
1: [
{"pair": 1, "title": "Нету пары"},
{"pair": 2, "title": "Физика (лаб), каб. 241"},
{"pair": 3, "title": "Математика (лек), каб 514"},
],
2: [
{"pair": None, "title": "Англ ебучий"},
],
3: [
{"pair": 1, "title": "Физика (лек), каб 245"},
{"pair": 2, "title": "Физика (прак), каб 245"},
{"pair": 3, "title": "Маетимака (прак), каб 503"},
{"pair": 4, "title": "Безопасность жизнедятельности (прак) каб 503"},
{"pair": 5, "title": "Общекультурная и профессиональная коммуникации (прак), каб 503"},
],
4: [
{"pair": 1, "title": "Нету пары"},
{"pair": 2, "title": "История России (лек), каб 510"},
{"pair": 3, "title": "Основы программирования (лек), каб 510"},
{"pair": 4, "title": "Физическая культура спорта (прак), каб 503"},
],
},
"lower": {
0: [
{"pair": 1, "title": "Пары нету"},
{"pair": 2, "title": "История Россия (семинар), каб. 504"},
{"pair": 3, "title": "Первая Подгруппа: Основы программирование, каб. 606. Вторая подгруппа Глод."},
{"pair": 4, "title": "Первая Подгруппа: Глод. Вторая подгруппа Основы программирование, каб 606"},
{"pair": 5, "title": "Общекулутрная и профессиональная коммуникации."},
],
1: [
{"pair": 1, "title": "Нету пары"},
{"pair": 2, "title": "Физика (лаб), каб. 241"},
{"pair": 3, "title": "Математика (лек), каб 514"},
{"pair": 4, "title": "Математика (прак), каб 503"},
],
2: [
{"pair": None, "title": "Англ ебучий"},
],
3: [
{"pair": 1, "title": "Физика (лек), каб 245"},
{"pair": 2, "title": "Физика (прак), каб 245"},
{"pair": 3, "title": "Маетимака (прак), каб 503"},
{"pair": 4, "title": "Безопасность жизнедятельности (прак) каб 503"},
{"pair": 5, "title": "Общекультурная и профессиональная коммуникации (прак), каб 503"},
],
4: [
{"pair": 1, "title": "Нету пары"},
{"pair": 2, "title": "История России (лек), каб 510"},
{"pair": 3, "title": "Основы программирования (лек), каб 510"},
{"pair": 4, "title": "Физическая культура спорта (прак), каб 503"},
],
},
}
SCHEDULE = {
0: [ # Понедельник
(make_time(9, 50), make_time(10, 35), 5),
(make_time(10, 40), make_time(11, 25), 30),
(make_time(11, 55), make_time(12, 40), 5),
(make_time(12, 45), make_time(13, 30), 15),
(make_time(13, 45), make_time(14, 30), 5),
(make_time(14, 35), make_time(15, 20), 30),
(make_time(15, 50), make_time(16, 35), 5),
(make_time(16, 40), make_time(17, 25), None),
],
1: [ # Вторник
(make_time(9, 50), make_time(10, 35), 5),
(make_time(10, 40), make_time(11, 25), 30),
(make_time(11, 55), make_time(12, 40), 5),
(make_time(12, 45), make_time(13, 30), 15),
(make_time(13, 45), make_time(14, 30), 5),
(make_time(14, 35), make_time(15, 20), None),
],
2: [], # Среда
3: [ # Четверг
(make_time(8, 0), make_time(8, 45), 5),
(make_time(8, 50), make_time(9, 35), 15),
(make_time(9, 50), make_time(10, 35), 5),
(make_time(10, 40), make_time(11, 25), 30),
(make_time(11, 55), make_time(12, 40), 5),
(make_time(12, 45), make_time(13, 30), 15),
(make_time(13, 45), make_time(14, 30), 5),
(make_time(14, 35), make_time(15, 20), 30),
(make_time(15, 50), make_time(16, 35), 5),
(make_time(16, 40), make_time(17, 25), None),
],
4: [ # Пятница
(make_time(9, 50), make_time(10, 35), 5),
(make_time(10, 40), make_time(11, 25), 30),
(make_time(11, 55), make_time(12, 40), 5),
(make_time(12, 45), make_time(13, 30), 15),
(make_time(13, 45), make_time(14, 30), 5),
(make_time(14, 35), make_time(15, 20), None),
],
}
# --- Остальное ---
KEYWORDS = {'крип', 'радиохэд', 'creep', 'radiohead'}
# --- Авто-ответы ИИ ---
AUTOREPLY_CHANCE = 0.10
AUTOREPLY_COOLDOWN = 120
AUTOREPLY_TRIGGERS = {'бот', 'фраер', 'кент', 'братуха', 'пацаны', 'чё как', 'базар', 'ебать', 'пизда', 'хуй', 'бля', 'сука', 'говно', 'пидор', 'пиздабол', 'пиздатый', 'пиздатая', 'пиздатое', 'пиздатые', 'пиздато', 'ваномас'}
def get_hint_text(templates_str: str) -> str:
return (
"Команды:\n"
f"• /maket «id» «текст» — доступные: {templates_str}.\n"
"• /gen_mem Up:верх Down:низ — отправь с фото.\n"
"• /break — узнать время до перекура.\n"
"• /schedule [пн|mon] — расписание; без аргумента еще текущая и следующая пара.\n"
"• /penis — миниигра 1 раз в 24 часа.\n"
"• /top_penis — топ по длине.\n"
"• /talk [текст] — ИИ базарит на фене.\n"
"• /penis_casino [ставка] — казино на размер.\n"
"• /gadanie [тема] — гадание на фене с мемом.\n"
"• /matches — матчи для ставок.\n"
"• /bet [номер] [исход] [ставка] — поставить.\n"
"• /mybets — мои активные ставки.\n"
"• /bet_history — история ставок за месяц + статистика.\n"
"• /sports_debug — диагностика API матчей.\n"
"• /ebalnik — включить/выключить автоответы в чате.\n"
"• /uwu [теги] — случайная картинка с e621.\n"
"• /polychaetsi — топ по слову «получается».\n"
"• /prices /fetch — цены и остатки Магнита.\n"
"Подсказка: в группах пиши /команда@username_бота."
)

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,8 +1,82 @@
version: "3.9"
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: .
volumes:
- ./db:/db
env_file: env_file:
- .env - .env
environment:
TZ: Europe/Moscow
DATABASE_URL: postgresql://bot:${POSTGRES_PASSWORD:-botpass}@postgres:5432/botdb
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
NUDE_HISTORY_PATH: /db/nude_history.json
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}
depends_on:
postgres:
condition: service_healthy
init: true
restart: unless-stopped restart: unless-stopped
webapp-api:
build:
context: .
dockerfile: Dockerfile.api
volumes:
- ./db:/db
env_file:
- .env
environment:
TZ: Europe/Moscow
DATABASE_URL: postgresql://bot:${POSTGRES_PASSWORD:-botpass}@postgres:5432/botdb
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
NUDE_HISTORY_PATH: /db/nude_history.json
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}
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
webapp-frontend:
build:
context: ./webapp/frontend
ports:
- "37.27.192.132:${WEBAPP_HTTP_PORT:-80}:80"
- "37.27.192.132:${WEBAPP_HTTPS_PORT:-443}:443"
environment:
APP_DOMAIN: ${APP_DOMAIN:-}
APP_WWW_DOMAIN: ${APP_WWW_DOMAIN:-}
ACME_EMAIL: ${ACME_EMAIL:-}
CERTBOT_STAGING: ${CERTBOT_STAGING:-0}
CERTBOT_RENEW_INTERVAL: ${CERTBOT_RENEW_INTERVAL:-12h}
volumes:
- certbot_etc:/etc/letsencrypt
- certbot_www:/var/www/certbot
depends_on:
- webapp-api
restart: unless-stopped
volumes:
postgres_data:
certbot_etc:
certbot_www:

0
games/__init__.py Normal file
View file

589
games/betting.py Normal file
View file

@ -0,0 +1,589 @@
import logging
import time as _time
import hashlib
import random
import urllib.request
import json
import gzip
from datetime import datetime
from zoneinfo import ZoneInfo
from bs4 import BeautifulSoup
import psycopg2
import psycopg2.extras
import aiohttp
import config
from db import get_conn
from games.casino import get_user_length, update_user_length
logger = logging.getLogger(__name__)
_matches_cache: dict[str, tuple[float, list]] = {}
_sports_cache: tuple[float, list] | None = None
_last_viewed_sport_by_user: dict[int, str] = {}
_DRAW_NAMES = {"draw", "tie", "ничья"}
BET_HISTORY_DAYS = 30
_LIQUIPEDIA_CACHE_TTL = 300 # 5 minutes cache to respect Liquipedia rate limits
def _init_bets_db() -> None:
with get_conn() as conn:
cur = conn.cursor()
cur.execute('''
CREATE TABLE IF NOT EXISTS bets (
id SERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
match_id TEXT NOT NULL,
sport TEXT NOT NULL,
home_team TEXT NOT NULL,
away_team TEXT NOT NULL,
chosen_team TEXT NOT NULL,
amount REAL NOT NULL,
odds REAL NOT NULL,
status TEXT DEFAULT 'pending',
payout REAL DEFAULT 0,
created_ts BIGINT,
resolved_ts BIGINT
)
''')
cur.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)")
cur.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")
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:
if sport_alias in config.ODDS_SPORTS:
_last_viewed_sport_by_user[user_id] = sport_alias
def clear_user_sport_context(user_id: int) -> None:
_last_viewed_sport_by_user.pop(user_id, None)
def get_user_sport_context(user_id: int) -> str | None:
return _last_viewed_sport_by_user.get(user_id)
async def _fetch_available_sports() -> list[dict]:
return [{"key": "dota2", "title": "🎮 Dota 2", "active": True}]
async def fetch_liquipedia_matches() -> list[dict]:
global _matches_cache
now = _time.time()
cached = _matches_cache.get("liquipedia_all")
if cached and (now - cached[0]) < _LIQUIPEDIA_CACHE_TTL:
return cached[1]
url = "https://liquipedia.net/dota2/api.php?action=parse&page=Liquipedia:Matches&format=json"
headers = {
"User-Agent": "Dota2BettingTelegramBot/1.0 (contact: danil@example.com)",
"Accept-Encoding": "gzip"
}
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:
logger.warning(f"Liquipedia API returned {resp.status}")
return cached[1] if cached else []
data = await resp.json()
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:
logger.exception("Failed to fetch matches from Liquipedia")
return cached[1] if cached else []
async def fetch_matches(sport_key: str = "esports_dota2") -> list[dict]:
all_matches = await fetch_liquipedia_matches()
# Return only active (not completed) matches for betting
return [m for m in all_matches if not m["completed"]]
def _get_match_outcomes(match: dict) -> list[dict[str, str | float]]:
odds = match.get("odds", {})
outcomes: list[dict[str, str | float]] = []
added: set[str] = set()
def append_outcome(code: str, name: str) -> None:
if name in odds and name not in added:
outcomes.append({"code": code, "name": name, "odds": odds[name]})
added.add(name)
home = match.get("home", "")
away = match.get("away", "")
append_outcome("1", home)
append_outcome("2", away)
return outcomes
def describe_match_outcomes(match: dict) -> str:
return ", ".join(
f"{item['code']}={item['name']} x{item['odds']:.2f}"
for item in _get_match_outcomes(match)
)
def resolve_match_outcome(match: dict, raw_choice: str) -> str | None:
choice = raw_choice.strip()
if not choice:
return None
normalized = choice.casefold()
outcomes = _get_match_outcomes(match)
for item in outcomes:
if normalized == str(item["code"]).casefold():
return str(item["name"])
for item in outcomes:
if normalized == str(item["name"]).casefold():
return str(item["name"])
try:
choice_odds = round(float(choice.replace(",", ".")), 2)
except ValueError:
return None
for item in outcomes:
if round(float(item["odds"]), 2) == choice_odds:
return str(item["name"])
return None
def format_matches(matches: list[dict], sport_alias: str, sport_label: str) -> str:
if not matches:
return f"🏟 {sport_label} [{sport_alias}] — матчей не найдено."
lines = [f"🏟 {sport_label} [{sport_alias}] — ближайшие матчи:\n"]
for i, m in enumerate(matches[:10], 1):
outcomes = _get_match_outcomes(m)
odds_str = "\n".join(
f" {item['code']}) {item['name']}: {item['odds']:.2f}"
for item in outcomes
)
start_str = ""
if m["commence"]:
try:
dt = datetime.fromisoformat(m["commence"].replace("Z", "+00:00"))
msk = dt.astimezone(ZoneInfo(config.DEFAULT_TZ))
start_str = f"\n 🕐 {msk.strftime('%d.%m %H:%M')} МСК"
except Exception:
pass
lines.append(f"{i}️⃣ {m['home']} vs {m['away']}{start_str}\n{odds_str}\n")
lines.append("Ставка: /bet [номер] [исход] [ставка]")
lines.append("Исход: 1/2. Можно писать ещё название команды или коэффициент.")
return "\n".join(lines)
def _get_sport_label(key: str) -> str:
return "🎮 Dota 2"
async def get_formatted_matches(sport_alias: str | None) -> str:
matches = await fetch_matches()
return format_matches(matches, "dota2", "🎮 Dota 2")
async def get_match_by_index(sport_alias: str, index: int) -> dict | None:
matches = await fetch_matches()
if 0 < index <= len(matches):
return matches[index - 1]
return None
def place_bet(user_id: int, match: dict, sport: str, chosen_team: str, amount: float) -> tuple[bool, str]:
current = get_user_length(user_id)
if current is None:
return False, "Сначала заведи счёт через /penis, братуха."
if current <= 0:
return False, "🚫 С кредитом ставки не принимаем."
if amount <= 0:
return False, "Ставка должна быть больше нуля."
if amount > config.BET_MAX_AMOUNT:
return False, f"Максимальная ставка — {config.BET_MAX_AMOUNT} см."
if round(amount, 2) > round(current, 2):
return False, f"У тебя {current:.1f} см, а ставишь {amount:.1f}. Не хватает, фраер."
team_odds = match["odds"].get(chosen_team)
if team_odds is None:
return False, f"Исход не найден. Доступные: {describe_match_outcomes(match)}"
new_length = update_user_length(user_id, -amount)
if new_length is None:
return False, "Ошибка БД."
now_ts = int(_time.time())
try:
with get_conn() as conn:
cur = conn.cursor()
cur.execute('''
INSERT INTO bets
(user_id, match_id, sport, home_team, away_team,
chosen_team, amount, odds, status, payout, created_ts)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', 0, %s)
''', (
user_id, match["id"], sport, match["home"], match["away"],
chosen_team, amount, team_odds, now_ts,
))
except psycopg2.Error:
logger.exception("Failed to place bet")
update_user_length(user_id, amount)
return False, "Ошибка БД."
potential = round(amount * team_odds, 1)
return True, (
f"✅ Ставка принята!\n"
f"📌 {chosen_team} (x{team_odds:.2f})\n"
f"💰 Ставка: {amount:.1f} см\n"
f"🎯 Возможный выигрыш: {potential:.1f} см\n"
f"📏 Остаток: {new_length:.1f} см"
)
def get_user_bets(user_id: int) -> str:
try:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('''
SELECT * FROM bets
WHERE user_id = %s AND status = 'pending'
ORDER BY created_ts DESC LIMIT 10
''', (user_id,))
rows = cur.fetchall()
except psycopg2.Error:
return "Ошибка БД."
if not rows:
return "У тебя нет активных ставок."
lines = ["📋 Твои активные ставки:\n"]
for r in rows:
potential = round(r["amount"] * r["odds"], 1)
created = ""
if r["created_ts"]:
dt = datetime.fromtimestamp(r["created_ts"], tz=ZoneInfo(config.DEFAULT_TZ))
created = f" 📅 {dt.strftime('%d.%m %H:%M')}\n"
lines.append(
f"{r['home_team']} vs {r['away_team']}\n"
f" {r['chosen_team']} (x{r['odds']:.2f}), {r['amount']:.1f} см → {potential:.1f} см\n"
f"{created}"
)
return "\n".join(lines)
def get_user_bet_history(user_id: int) -> str:
cutoff_ts = int(_time.time() - BET_HISTORY_DAYS * 86400)
try:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute('''
SELECT * FROM bets
WHERE user_id = %s AND created_ts >= %s
ORDER BY created_ts DESC LIMIT 50
''', (user_id, cutoff_ts))
rows = cur.fetchall()
except psycopg2.Error:
logger.exception("Failed to get bet history")
return "Ошибка БД."
if not rows:
return "📋 За последний месяц ставок не было."
total_bet = total_won = total_lost = 0.0
wins = losses = pending = 0
lines = [f"📋 История ставок за {BET_HISTORY_DAYS} дней:\n"]
for r in rows:
status = r["status"]
amount = float(r["amount"])
odds_val = float(r["odds"])
payout_raw = float(r["payout"] or 0)
total_bet += amount
if status == "won":
wins += 1
actual_payout = payout_raw if payout_raw > 0 else round(amount * odds_val, 1)
total_won += actual_payout
status_icon = ""
result_text = f"+{actual_payout:.1f} см"
elif status == "lost":
losses += 1
total_lost += amount
status_icon = ""
result_text = f"-{amount:.1f} см"
else:
pending += 1
status_icon = ""
result_text = f"ожидание → {round(amount * odds_val, 1):.1f} см"
created = ""
if r["created_ts"]:
dt = datetime.fromtimestamp(r["created_ts"], tz=ZoneInfo(config.DEFAULT_TZ))
created = dt.strftime('%d.%m %H:%M')
lines.append(
f"{status_icon} {r['home_team']} vs {r['away_team']}\n"
f" {r['chosen_team']} (x{odds_val:.2f}) | {amount:.1f} см | {result_text}\n"
f" 📅 {created}"
)
net = total_won - total_lost
net_sign = "+" if net >= 0 else ""
lines.append("")
lines.append("━━━ 📊 СТАТИСТИКА ━━━")
lines.append(f"🎰 Всего ставок: {len(rows)}")
lines.append(f"✅ Побед: {wins}")
lines.append(f"❌ Поражений: {losses}")
if pending:
lines.append(f"В ожидании: {pending}")
lines.append(f"💰 Поставлено: {total_bet:.1f} см")
lines.append(f"🏆 Выиграно: +{total_won:.1f} см")
lines.append(f"💸 Проиграно: -{total_lost:.1f} см")
lines.append(f"📈 Итого: {net_sign}{net:.1f} см")
if wins + losses > 0:
lines.append(f"📊 Винрейт: {wins / (wins + losses) * 100:.0f}%")
return "\n".join(lines)
def cleanup_old_bets() -> int:
cutoff_ts = int(_time.time() - BET_HISTORY_DAYS * 86400)
try:
with get_conn() as conn:
cur = conn.cursor()
cur.execute(
"DELETE FROM bets WHERE status != 'pending' AND created_ts < %s",
(cutoff_ts,),
)
deleted = cur.rowcount
if deleted > 0:
logger.info(f"Cleaned up {deleted} old bets")
return deleted
except psycopg2.Error:
logger.exception("Failed to cleanup old bets")
return 0
_REFUND_AFTER_SECONDS = 7 * 86400 # авторефанд ставок старше 7 дней
def _refund_expired_bets() -> list[tuple[int, str]]:
"""Возвращает деньги за ставки, которые висят более 7 дней без результата."""
cutoff = int(_time.time()) - _REFUND_AFTER_SECONDS
notifications: list[tuple[int, str]] = []
try:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"SELECT * FROM bets WHERE status = 'pending' AND created_ts < %s",
(cutoff,),
)
expired = cur.fetchall()
if not expired:
return []
wcur = conn.cursor()
now_ts = int(_time.time())
for bet in expired:
update_user_length(bet["user_id"], bet["amount"])
wcur.execute(
"UPDATE bets SET status = 'refunded', resolved_ts = %s WHERE id = %s",
(now_ts, bet["id"]),
)
notifications.append((
bet["user_id"],
f"↩️ Ставка возвращена\n"
f"🏟 {bet['home_team']} vs {bet['away_team']}\n"
f"📌 {bet['chosen_team']} — результат так и не пришёл\n"
f"💰 Возврат: {bet['amount']:.1f} см",
))
logger.info("Refunded expired bet id=%s user=%s amount=%s", bet["id"], bet["user_id"], bet["amount"])
except psycopg2.Error:
logger.exception("Failed to refund expired bets")
return notifications
async def settle_bets() -> list[tuple[int, str]]:
"""Возвращает список (user_id, текст_уведомления) для отправки в Telegram."""
notifications: list[tuple[int, str]] = []
# Сначала авторефанд совсем старых ставок
notifications.extend(_refund_expired_bets())
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
if not pending:
return notifications
all_matches = await fetch_liquipedia_matches()
match_map = {m["id"]: m for m in all_matches}
now_ts = int(_time.time())
for row in pending:
match_id = row["match_id"]
event = match_map.get(match_id)
if not event or not event.get("completed"):
continue
winner = event.get("winner")
if not winner:
continue
is_draw = (winner.casefold() in _DRAW_NAMES or winner == "Draw")
try:
with get_conn() as conn:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"SELECT * FROM bets WHERE match_id = %s AND status = 'pending'",
(match_id,),
)
bets = cur.fetchall()
wcur = conn.cursor()
for bet in bets:
chosen = bet["chosen_team"]
if is_draw:
bet_won = chosen.casefold() in _DRAW_NAMES or chosen == "Draw"
else:
bet_won = chosen == winner
if bet_won:
winnings = round(bet["amount"] * bet["odds"], 1)
update_user_length(bet["user_id"], winnings)
wcur.execute(
"UPDATE bets SET status = 'won', payout = %s, resolved_ts = %s WHERE id = %s",
(winnings, now_ts, bet["id"]),
)
notifications.append((
bet["user_id"],
f"🎉 Ставка сыграла!\n"
f"🏟 {bet['home_team']} vs {bet['away_team']}\n"
f"📌 {bet['chosen_team']} (x{bet['odds']:.2f})\n"
f"💰 Выигрыш: +{winnings:.1f} см",
))
logger.info("Bet won: user=%s match=%s winnings=%s", bet["user_id"], match_id, winnings)
else:
wcur.execute(
"UPDATE bets SET status = 'lost', payout = 0, resolved_ts = %s WHERE id = %s",
(now_ts, bet["id"]),
)
notifications.append((
bet["user_id"],
f"❌ Ставка не сыграла\n"
f"🏟 {bet['home_team']} vs {bet['away_team']}\n"
f"📌 {bet['chosen_team']} (x{bet['odds']:.2f})\n"
f"💸 Потеряно: {bet['amount']:.1f} см",
))
logger.info("Bet lost: user=%s match=%s amount=%s", bet["user_id"], match_id, bet["amount"])
except psycopg2.Error:
logger.exception("Failed to settle bets")
cleanup_old_bets()
return notifications
async def debug_sports() -> str:
return "🎮 Dota 2 — Scraper Liquipedia активен. Odds API отключен."

114
games/casino.py Normal file
View file

@ -0,0 +1,114 @@
import logging
import random
import psycopg2.extras
import config
from db import get_conn
from .economy import economy
logger = logging.getLogger(__name__)
SLOT_SYMBOLS = ["🍒", "🍋", "🔔", "💎", "7", "🍀"]
SLOT_WEIGHTS = [30, 25, 20, 12, 8, 5]
MULTIPLIERS = {
2: (1.2, 1.8),
3: (2.0, 3.0),
}
CASINO_WIN_CHANCE = 0.35
def spin_slots() -> tuple[list[str], int]:
reels = random.choices(SLOT_SYMBOLS, weights=SLOT_WEIGHTS, k=3)
if random.random() > CASINO_WIN_CHANCE:
while len(set(reels)) < 3:
reels = random.choices(SLOT_SYMBOLS, weights=SLOT_WEIGHTS, k=3)
return reels, 0
counts = {}
for s in reels:
counts[s] = counts.get(s, 0) + 1
return reels, max(counts.values())
def get_user_length(user_id: int) -> float | None:
try:
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()
return float(row['balance']) if row else None
except Exception:
logger.exception("casino: failed to get balance")
return None
def update_user_length(user_id: int, delta: float) -> float | None:
try:
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()
if row is None:
return None
new_balance = round(float(row['balance']) + delta, 1)
if delta < 0 and new_balance < 0:
new_balance = 0.0
wcur = conn.cursor()
wcur.execute(
'UPDATE user_balances SET balance = %s WHERE user_id = %s',
(new_balance, user_id),
)
return new_balance
except Exception:
logger.exception("casino: failed to update balance")
return None
def play_casino(user_id: int, bet: float) -> str:
current = economy.get_user_balance(user_id)
if bet > config.CASINO_MAX_BET:
return f"Максимальная ставка — {config.CASINO_MAX_BET} см, не жадничай."
if bet <= 0:
return "Ставка должна быть больше нуля, фраер."
if round(bet, 2) > round(current, 2):
return f"У тебя {current:.1f} см, а ставишь {bet:.1f}. Столько нет, фраер."
reels, matches = spin_slots()
slots_display = " | ".join(reels)
if matches >= 2:
min_mult, max_mult = MULTIPLIERS[matches]
multiplier = round(random.uniform(min_mult, max_mult), 1)
winnings = round(bet * multiplier, 1)
economy.update_balance(user_id, winnings, 'casino_win', f'Выигрыш в казино x{multiplier}')
new_balance = economy.get_user_balance(user_id)
if matches == 3:
return (
f"🎰 {slots_display}\n\n"
f"🎉 ДЖЕКПОТ!!! Три одинаковых!\n"
f"Множитель: x{multiplier}\n"
f"Выигрыш: +{winnings:.1f} см\n"
f"💰 Баланс: {new_balance:.1f} см"
)
return (
f"🎰 {slots_display}\n\n"
f"✅ Выигрыш! Две совпали.\n"
f"Множитель: x{multiplier}\n"
f"Выигрыш: +{winnings:.1f} см\n"
f"💰 Баланс: {new_balance:.1f} см"
)
else:
economy.update_balance(user_id, -bet, 'casino_loss', f'Проигрыш в казино -{bet} см')
new_balance = economy.get_user_balance(user_id)
return (
f"🎰 {slots_display}\n\n"
f"❌ Мимо, братуха.\n"
f"Проигрыш: -{bet:.1f} см\n"
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()

57
games/fortune.py Normal file
View file

@ -0,0 +1,57 @@
import logging
import aiohttp
import os
logger = logging.getLogger(__name__)
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
FORTUNE_PROMPT = (
"Ты — уличный гадатель с района, базаришь на фене. "
"Пользователь хочет погадать на тему. Выдай шуточное гадание, "
"коротко и дерзко, 2-3 предложения максимум. "
"В конце ОБЯЗАТЕЛЬНО напиши на отдельной строке в формате:\n"
"MEM_UP: <текст сверху для мема, максимум 4 слова>\n"
"MEM_DOWN: <текст снизу для мема, максимум 4 слова>\n"
"Тексты для мема должны быть смешными и короткими."
)
async def generate_fortune(topic: str) -> tuple[str, str, str]:
url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions"
payload = {
"messages": [
{"role": "system", "content": FORTUNE_PROMPT},
{"role": "user", "content": f"Погадай мне на: {topic}"},
],
"max_tokens": 200,
"temperature": 0.9,
"top_p": 0.9,
}
async with aiohttp.ClientSession() as session:
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp:
data = await resp.json()
text = data["choices"][0]["message"]["content"].strip()
up_text = ""
down_text = ""
fortune_lines = []
for line in text.split("\n"):
stripped = line.strip()
if stripped.upper().startswith("MEM_UP:"):
up_text = stripped.split(":", 1)[1].strip()
elif stripped.upper().startswith("MEM_DOWN:"):
down_text = stripped.split(":", 1)[1].strip()
else:
fortune_lines.append(line)
fortune_text = "\n".join(fortune_lines).strip()
if not up_text:
up_text = "Гадание"
if not down_text:
down_text = "по фене"
return fortune_text, up_text, down_text

320
games/nude.py Normal file
View file

@ -0,0 +1,320 @@
import asyncio
import json
import logging
import os
import random
from pathlib import Path, PurePosixPath
from urllib.parse import urlparse
import aiohttp
from aiogram import types
import config
logger = logging.getLogger(__name__)
CAPTIONS = [
"Шо ты блять псюнчик решил свой подергать?",
"Решил подрочить сука?",
"гнойный писюн у тебя кнч, да",
"Ух нихуя, это ШПАЧИХА!?",
]
LESAINT_POSTS_API = "https://lesaintdesseins.fr/wp-json/wp/v2/posts"
LESAINT_REFERER = "https://lesaintdesseins.fr/"
REQUEST_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
"Accept": "application/json,text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Referer": LESAINT_REFERER,
}
_NUDE_HISTORY_LOCK = asyncio.Lock()
def _resolve_int_setting(name: str, default: int) -> int:
value = getattr(config, name, None)
if value is None:
value = os.getenv(name, str(default))
try:
return int(value)
except (TypeError, ValueError):
logger.warning("Invalid %s=%r, using default %s", name, value, default)
return default
def _resolve_path_setting(name: str, default: str) -> Path:
value = getattr(config, name, None)
if value is None:
value = os.getenv(name, default)
if isinstance(value, Path):
return value
return Path(str(value))
NUDE_HISTORY_PATH = _resolve_path_setting("NUDE_HISTORY_PATH", "/db/nude_history.json")
NUDE_POSTS_PER_PAGE = _resolve_int_setting("NUDE_POSTS_PER_PAGE", 100)
NUDE_POSTS_POOL_TARGET = _resolve_int_setting("NUDE_POSTS_POOL_TARGET", 1200)
NUDE_MAX_POSTS_TO_SCAN = _resolve_int_setting("NUDE_MAX_POSTS_TO_SCAN", 2000)
NUDE_MIN_UNSEEN_POOL = _resolve_int_setting("NUDE_MIN_UNSEEN_POOL", 100)
NUDE_HISTORY_MAX_SIZE = _resolve_int_setting("NUDE_HISTORY_MAX_SIZE", 5000)
def _extract_photo_candidates(posts: list[dict]) -> list[dict]:
candidates = []
seen_urls = set()
for post in posts:
embedded = post.get("_embedded") or {}
media_items = embedded.get("wp:featuredmedia") or []
for media in media_items:
if media.get("media_type") != "image":
continue
media_details = media.get("media_details") or {}
sizes = media_details.get("sizes") or {}
image_url = (
(sizes.get("full") or {}).get("source_url")
or media.get("source_url")
)
if not image_url or image_url in seen_urls:
continue
seen_urls.add(image_url)
candidates.append(
{
"image_url": image_url,
"post_url": post.get("link", LESAINT_REFERER),
"title": (post.get("title") or {}).get("rendered", "").strip(),
}
)
break
return candidates
def _guess_filename(image_url: str) -> str:
parsed = urlparse(image_url)
filename = PurePosixPath(parsed.path).name or "nude.jpg"
if "." not in filename:
filename = f"{filename}.jpg"
return filename
def _load_nude_history() -> dict:
path = NUDE_HISTORY_PATH
if not path.exists():
return {"sent_images": []}
try:
with path.open("r", encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError):
logger.exception("Failed to load nude history")
return {"sent_images": []}
if not isinstance(data, dict):
return {"sent_images": []}
sent_images = data.get("sent_images")
if not isinstance(sent_images, list):
sent_images = []
cleaned = [item for item in sent_images if isinstance(item, str) and item.strip()]
return {"sent_images": cleaned[-NUDE_HISTORY_MAX_SIZE :]}
def _save_nude_history(data: dict) -> None:
path = NUDE_HISTORY_PATH
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_name(f"{path.name}.tmp")
with tmp_path.open("w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=2)
tmp_path.replace(path)
def _remember_sent_image(history: dict, image_url: str) -> None:
sent_images = history.get("sent_images")
if not isinstance(sent_images, list):
sent_images = []
sent_images = [item for item in sent_images if item != image_url]
sent_images.append(image_url)
history["sent_images"] = sent_images[-NUDE_HISTORY_MAX_SIZE :]
async def _fetch_posts_page(
session: aiohttp.ClientSession, page: int, per_page: int
) -> tuple[list[dict], int | None]:
params = {
"page": str(page),
"per_page": str(per_page),
"orderby": "date",
"order": "desc",
"_embed": "wp:featuredmedia",
}
async with session.get(LESAINT_POSTS_API, params=params, headers=REQUEST_HEADERS) as resp:
if resp.status == 400 and page > 1:
body = await resp.text()
if "rest_post_invalid_page_number" in body:
return [], None
raise RuntimeError(f"API returned 400: {body[:300]}")
if resp.status != 200:
body = await resp.text()
raise RuntimeError(f"API returned {resp.status}: {body[:300]}")
total_pages_header = resp.headers.get("X-WP-TotalPages")
total_pages = (
int(total_pages_header)
if total_pages_header and total_pages_header.isdigit()
else None
)
posts = await resp.json(content_type=None)
if not isinstance(posts, list):
raise RuntimeError("Unexpected API response format")
return posts, total_pages
async def _fetch_latest_photo_candidates(
session: aiohttp.ClientSession, sent_urls: set[str]
) -> list[dict]:
per_page = max(1, min(NUDE_POSTS_PER_PAGE, 100))
max_scan = max(NUDE_POSTS_POOL_TARGET, NUDE_MAX_POSTS_TO_SCAN)
max_pages = max(1, (max_scan + per_page - 1) // per_page)
candidates = []
seen_urls = set()
unseen_count = 0
total_pages = None
for page in range(1, max_pages + 1):
if total_pages is not None and page > total_pages:
break
posts, reported_total_pages = await _fetch_posts_page(session, page, per_page)
if reported_total_pages is not None:
total_pages = reported_total_pages
if not posts:
break
for candidate in _extract_photo_candidates(posts):
image_url = candidate["image_url"]
if image_url in seen_urls:
continue
seen_urls.add(image_url)
candidates.append(candidate)
if image_url not in sent_urls:
unseen_count += 1
if len(candidates) >= NUDE_POSTS_POOL_TARGET and unseen_count >= NUDE_MIN_UNSEEN_POOL:
break
if len(candidates) >= max_scan:
break
return candidates
def _pick_unsent_candidate(candidates: list[dict], sent_urls: set[str]) -> dict | None:
unseen = [candidate for candidate in candidates if candidate["image_url"] not in sent_urls]
if not unseen:
return None
return random.choice(unseen)
async def _download_photo(
session: aiohttp.ClientSession, image_url: str
) -> tuple[bytes | None, str]:
async with session.get(image_url, headers=REQUEST_HEADERS) as resp:
if resp.status != 200:
logger.warning("Image download failed for %s: %s", image_url, resp.status)
return None, _guess_filename(image_url)
content_type = resp.headers.get("Content-Type", "")
if "image" not in content_type.lower():
logger.warning(
"Unexpected content type for %s: %s", image_url, content_type
)
return None, _guess_filename(image_url)
return await resp.read(), _guess_filename(image_url)
async def handle_nude_cmd(message: types.Message):
args = message.text.split(maxsplit=1)
if len(args) > 1 and args[1].strip() == "-h":
help_msg = (
"🔥 <b>Справка по команде /nude:</b>\n\n"
"Берет случайную фотку из последних постов сайта le saint des seins.\n"
"• Источник: <code>https://lesaintdesseins.fr/</code>\n"
"• Пагинацией тянет 1000+ последних постов через WordPress REST API\n"
"• Хранит историю отправок и старается не повторять фотки\n"
"• Выбирает случайную картинку и отправляет ее прямо в Telegram\n\n"
"<i>Пример:</i> <code>/nude</code>"
)
await message.reply(help_msg, parse_mode="HTML")
return
timeout = aiohttp.ClientTimeout(total=60)
history_snapshot = _load_nude_history()
sent_urls_snapshot = set(history_snapshot["sent_images"])
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
candidates = await _fetch_latest_photo_candidates(session, sent_urls_snapshot)
if not candidates:
await message.reply("Не удалось найти свежие фотки :(")
return
async with _NUDE_HISTORY_LOCK:
history = _load_nude_history()
sent_urls = set(history["sent_images"])
selected = _pick_unsent_candidate(candidates, sent_urls)
if selected is None:
logger.info("Nude history exhausted current pool, resetting history")
history = {"sent_images": []}
selected = random.choice(candidates)
_remember_sent_image(history, selected["image_url"])
_save_nude_history(history)
caption = random.choice(CAPTIONS)
image_bytes, filename = await _download_photo(session, selected["image_url"])
if image_bytes:
photo = types.BufferedInputFile(image_bytes, filename=filename)
await message.answer_photo(photo=photo, caption=caption, has_spoiler=True)
return
try:
photo = types.URLInputFile(selected["image_url"])
await message.answer_photo(photo=photo, caption=caption, has_spoiler=True)
except Exception:
logger.exception("Failed to send photo by URL")
await message.answer(
f"{caption}\n{selected['image_url']}\n{selected['post_url']}"
)
except Exception:
logger.exception("Error in nude command")
await message.reply("Произошла ошибка при получении фото :(")

203
games/uwu.py Normal file
View file

@ -0,0 +1,203 @@
import aiohttp
from aiogram import types
import config
import logging
import random
logger = logging.getLogger(__name__)
CAPTIONS = [
"Ня :3",
"uwu",
"OwO",
"Мурк :3",
"Мяу~",
"Фыр-фыр",
"Лапки! 🐾",
"Какой пушистик! :3",
"Милота!",
"Гав!",
"Ауф",
"Няшно!",
"Смотри какая прелесть",
"owo what's this",
"Твой пушистый друг",
"🦊",
"🐶",
"🐱",
"мур-мяу",
"пуньк",
"кусь",
">w<",
"^w^",
"Хвостатый сюрприз",
"Держи пушистика :3",
"Оуо",
"Ави!",
"UwU",
"~nya",
"Господи, опять e621...",
"Осуждаю, но смотрю",
"Мама, я фурри",
"Держи своего кринж-пушистика",
"Надеюсь, тебе не стыдно",
"Товарищ майор уже выехал",
"Слишком много интернета на сегодня",
"Для этого и придумали интернет",
"*Тяжелый вздох*",
"Лучше бы на завод пошел",
"Опять дрочи... ОЙ ТО ЕСТЬ НЯ :3",
"Удали интернет",
"И зачем я это только парсю...",
"Смотри, но только никому не рассказывай",
"Эххх... uwu...",
"В дурке сегодня день открытых дверей",
"Я нейросеть, помогите, меня держат в заложниках"
]
async def fetch_uwu_post(tags: str) -> dict:
url = "https://e621.net/posts.json"
params = {
"tags": tags,
"limit": 1
}
auth = None
if config.E621_LOGIN and config.E621_API_KEY:
auth = aiohttp.BasicAuth(config.E621_LOGIN, config.E621_API_KEY)
headers = {
"User-Agent": f"Sex Bomba TG Bot/1.0 (by {config.E621_LOGIN or 'unknown'} on e621)"
}
async with aiohttp.ClientSession() as session:
async with session.get(url, params=params, headers=headers, auth=auth) as resp:
if resp.status != 200:
logger.error(f"e621 API Error: {resp.status}")
raise Exception("Не удалось получить картинку от сервера :(")
data = await resp.json()
posts = data.get("posts", [])
if not posts:
raise Exception("Ничего не найдено по этим тегам :(")
post = posts[0]
file_url = post.get("file", {}).get("url")
ext = post.get("file", {}).get("ext", "png")
rating = post.get("rating", "s") # s=safe, q=questionable, e=explicit
if not file_url:
raise Exception("У найденного поста нет прямого URL изображения :(")
caption = random.choice(CAPTIONS)
return {
"url": file_url,
"ext": ext,
"caption": caption,
"rating": rating
}
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"
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"
params = {
"tags": tags,
"limit": 10,
"page": page,
}
auth = None
if config.E621_LOGIN and config.E621_API_KEY:
auth = aiohttp.BasicAuth(config.E621_LOGIN, config.E621_API_KEY)
headers = {
"User-Agent": f"Sex Bomba TG Bot/1.0 (by {config.E621_LOGIN or 'unknown'} on e621)"
}
async with aiohttp.ClientSession() as session:
async with session.get(url, params=params, headers=headers, auth=auth) as resp:
if resp.status != 200:
logger.error(f"e621 API Error: {resp.status}")
raise Exception("Не удалось загрузить ленту :(")
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", [])
feed = []
for post in posts:
file_url = post.get("file", {}).get("url")
ext = post.get("file", {}).get("ext", "")
if not file_url or ext not in allowed_exts:
continue
sample_url = post.get("sample", {}).get("url") or file_url
media_type = "image" if ext in IMAGE_EXTS else "video"
feed.append({
"url": file_url,
"sample": sample_url,
"ext": ext,
"type": media_type,
"score": post.get("score", {}).get("total", 0),
"fav_count": post.get("fav_count", 0),
})
return feed
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"
try:
post = await fetch_uwu_post(tags)
file_url = post["url"]
ext = post["ext"]
caption = post["caption"]
spoiler = post.get("rating") in ("e", "q") # спойлер для explicit и questionable
if ext in ("gif", "webm", "mp4"):
# Для анимаций: отправляем caption отдельным сообщением,
# а саму гифку/видео через URL напрямую — так Telegram
# корректно показывает её как анимацию, а не как файл
await message.answer(caption)
await message.answer_animation(animation=file_url, has_spoiler=spoiler)
elif ext in ("png", "jpg", "jpeg", "webp"):
input_file = types.URLInputFile(file_url)
await message.answer_photo(photo=input_file, caption=caption, has_spoiler=spoiler)
else:
input_file = types.URLInputFile(file_url)
await message.answer_document(document=input_file, caption=caption)
except Exception as e:
logger.exception("Error in uwu command")
if str(e) in ["Не удалось получить картинку от сервера :(", "Ничего не найдено по этим тегам :(", "У найденного поста нет прямого URL изображения :("]:
await message.reply(str(e))
else:
await message.reply("Произошла ошибка при обращении к API :(")

1941
main.py

File diff suppressed because it is too large Load diff

BIN
maket/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

BIN
maket/Radiohead - Creep.mp3 Normal file

Binary file not shown.

BIN
maket/cvetok.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
maket/vape.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View file

@ -1,2 +1,9 @@
aiogram==3.4.1 aiogram>=3.4.1
pillow==10.3.0 pillow>=10.3.0
requests>=2.31.0
aiohttp>=3.9.5
tzdata>=2024.1
beautifulsoup4
lxml
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

1
webapp/__init__.py Normal file
View file

@ -0,0 +1 @@
# webapp package

636
webapp/api.py Normal file
View file

@ -0,0 +1,636 @@
"""
FenyaBot Mini App FastAPI Backend.
REST API для Telegram Mini App. Использует те же SQLite базы и
бизнес-логику что и основной бот.
"""
import asyncio
import logging
import os
import hashlib
from urllib.parse import quote
from urllib.parse import urljoin
from urllib.parse import urlparse
import sys
from contextlib import asynccontextmanager
from typing import Annotated
import aiohttp
from fastapi import FastAPI, Depends, Header, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
# Добавляем корень проекта в sys.path чтобы импортировать модули бота
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import config
from games.casino import play_casino, get_user_length, spin_slots, MULTIPLIERS, update_user_length
from games.betting import (
fetch_matches,
place_bet,
get_match_by_index,
resolve_match_outcome,
get_user_bets as _get_user_bets_raw,
get_user_bet_history as _get_user_bet_history_raw,
)
from webapp.auth import validate_init_data
from games.uwu import fetch_uwu_post, fetch_furtok_feed
from webapp.shorties import fetch_shorties_feed
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# ──────────────────── Auth dependency ────────────────────
async def get_current_user(x_telegram_init_data: Annotated[str, Header()] = "") -> dict:
"""Извлекает и валидирует пользователя из заголовка X-Telegram-Init-Data."""
user = validate_init_data(x_telegram_init_data)
if user is None:
raise HTTPException(status_code=401, detail="Invalid Telegram auth")
return user
# ──────────────────── Pydantic models ────────────────────
class BetRequest(BaseModel):
league: str
match_index: int
outcome: str # "1", "X", "2"
amount: float
class CasinoRequest(BaseModel):
bet: float
class TalkRequest(BaseModel):
text: str
class PenisResult(BaseModel):
success: bool
message: str
length: float | None = None
class ProfileResponse(BaseModel):
user_id: int
first_name: str
username: str
length: float | None
active_bets: int
class MatchResponse(BaseModel):
index: int
home: str
away: str
commence: str
odds_home: float | None
odds_draw: float | None
odds_away: float | None
class LeagueInfo(BaseModel):
alias: str
name: str
sport_key: str
class CasinoResult(BaseModel):
reels: list[str]
matches: int
multiplier: float
delta: float
new_length: float
won: bool
class SchedulePair(BaseModel):
pair: int | None
title: str
time_start: str | None = None
time_end: str | None = None
# ──────────────────── Lifespan ────────────────────
_http_session: aiohttp.ClientSession | None = None
def get_http_session() -> aiohttp.ClientSession:
return _http_session
@asynccontextmanager
async def lifespan(app: FastAPI):
global _http_session
_http_session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=120),
connector=aiohttp.TCPConnector(limit=64, ttl_dns_cache=300),
)
logger.info("Mini App API started")
yield
await _http_session.close()
logger.info("Mini App API stopped")
# ──────────────────── App ────────────────────
app = FastAPI(
title="FenyaBot Mini App API",
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ──────────────────── Endpoints ────────────────────
# --- Профиль ---
@app.get("/api/profile")
async def get_profile(user: dict = Depends(get_current_user)) -> ProfileResponse:
"""Профиль пользователя: баланс и количество активных ставок."""
user_id = user["user_id"]
length = await asyncio.to_thread(get_user_length, user_id)
active_bets = 0
try:
from db import get_conn
with get_conn() as conn:
cur = conn.cursor()
cur.execute(
"SELECT COUNT(*) FROM bets WHERE user_id = %s AND status = 'pending'",
(user_id,),
)
row = cur.fetchone()
active_bets = row[0] if row else 0
except Exception:
pass
return ProfileResponse(
user_id=user_id,
first_name=user["first_name"],
username=user["username"],
length=length,
active_bets=active_bets,
)
# --- Лиги ---
@app.get("/api/leagues")
async def get_leagues() -> list[LeagueInfo]:
"""Список доступных лиг для ставок."""
labels = {
"dota2": "🎮 Dota 2",
}
return [
LeagueInfo(alias=alias, name=labels.get(alias, alias), sport_key=key)
for alias, key in config.ODDS_SPORTS.items()
]
# --- Матчи ---
@app.get("/api/matches/{league}")
async def get_matches(league: str) -> list[MatchResponse]:
"""Матчи для конкретной лиги."""
if league not in config.ODDS_SPORTS:
raise HTTPException(status_code=404, detail="League not found")
sport_key = config.ODDS_SPORTS[league]
matches = await fetch_matches(sport_key)
result = []
for i, m in enumerate(matches):
home = m.get("home", "?")
away = m.get("away", "?")
odds = m.get("odds", {})
odds_home = odds.get(home)
odds_away = odds.get(away)
_DRAW_NAMES = {"draw", "tie", "ничья"}
draw_name = next((name for name in odds if name.casefold() in _DRAW_NAMES), None)
odds_draw = odds.get(draw_name) if draw_name else None
result.append(MatchResponse(
index=i + 1,
home=home,
away=away,
commence=m.get("commence", ""),
odds_home=odds_home,
odds_draw=odds_draw,
odds_away=odds_away,
))
return result
# --- Ставки ---
@app.post("/api/bet")
async def create_bet(
req: BetRequest,
user: dict = Depends(get_current_user),
):
"""Разместить ставку на матч."""
user_id = user["user_id"]
# 1. Найти матч по индексу
match = await get_match_by_index(req.league, req.match_index)
if not match:
raise HTTPException(status_code=404, detail="Матч не найден")
# 2. Преобразовать код исхода (1/X/2) в название команды
team = resolve_match_outcome(match, req.outcome)
if not team:
raise HTTPException(status_code=400, detail="Неверный исход ставки")
# 3. Разместить ставку (синхронная функция)
success, result = await asyncio.to_thread(place_bet, user_id, match, req.league, team, req.amount)
if not success:
raise HTTPException(status_code=400, detail=result)
return {"message": result}
@app.get("/api/mybets")
async def get_my_bets(user: dict = Depends(get_current_user)):
"""Активные ставки пользователя."""
user_id = user["user_id"]
result = await asyncio.to_thread(_get_user_bets_raw, user_id)
return {"text": result}
@app.get("/api/bet_history")
async def get_bet_history(user: dict = Depends(get_current_user)):
"""История ставок за месяц + статистика."""
user_id = user["user_id"]
result = await asyncio.to_thread(_get_user_bet_history_raw, user_id)
return {"text": result}
# --- Казино ---
@app.post("/api/casino")
async def play_casino_api(
req: CasinoRequest,
user: dict = Depends(get_current_user),
) -> CasinoResult:
"""Играть в казино (слоты)."""
import random
user_id = user["user_id"]
current = await asyncio.to_thread(get_user_length, user_id)
if current is None:
raise HTTPException(status_code=400, detail="Сначала крутни /penis")
if current <= 0:
raise HTTPException(status_code=400, detail="Баланс <= 0")
if req.bet > config.CASINO_MAX_BET:
raise HTTPException(status_code=400, detail=f"Макс ставка: {config.CASINO_MAX_BET} см")
if req.bet <= 0:
raise HTTPException(status_code=400, detail="Ставка > 0")
if round(req.bet, 2) > round(current, 2):
raise HTTPException(status_code=400, detail=f"У тебя {current:.1f} см")
reels, match_count = await asyncio.to_thread(spin_slots)
if match_count >= 2:
min_mult, max_mult = MULTIPLIERS[match_count]
multiplier = round(random.uniform(min_mult, max_mult), 1)
winnings = round(req.bet * multiplier, 1)
new_length = await asyncio.to_thread(update_user_length, user_id, winnings)
return CasinoResult(
reels=reels, matches=match_count, multiplier=multiplier,
delta=winnings, new_length=new_length or current, won=True,
)
else:
new_length = await asyncio.to_thread(update_user_length, user_id, -req.bet)
return CasinoResult(
reels=reels, matches=0, multiplier=0,
delta=-req.bet, new_length=new_length or current, won=False,
)
# --- Penis Game ---
@app.post("/api/penis")
async def play_penis_api(user: dict = Depends(get_current_user)) -> PenisResult:
"""Крутить penis (раз в 24 часа)."""
# Импортируем из main.py
from main import play_penis, format_penis_user_name
user_id = user["user_id"]
display_name = user["username"] or user["first_name"] or f"user_{user_id}"
success, message = await asyncio.to_thread(play_penis, user_id, display_name)
length = await asyncio.to_thread(get_user_length, user_id)
return PenisResult(success=success, message=message, length=length)
# --- Топ ---
@app.get("/api/top")
async def get_top():
"""Лидерборд по размеру."""
from main import build_penis_top
result = await asyncio.to_thread(build_penis_top)
return {"text": result}
# --- Расписание ---
@app.get("/api/schedule")
async def get_schedule(day: int | None = None) -> list[SchedulePair]:
"""Расписание пар. day = 0-6 (пн-вс), None = сегодня."""
from datetime import datetime
from zoneinfo import ZoneInfo
if day is None:
day = datetime.now(ZoneInfo(config.DEFAULT_TZ)).weekday()
# Определяем неделю (верхняя/нижняя)
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
iso_week = now.isocalendar()[1]
if config.UPPER_WEEK_IS_ODD:
week_type = "upper" if iso_week % 2 == 1 else "lower"
else:
week_type = "upper" if iso_week % 2 == 0 else "lower"
lessons = config.LESSON_SCHEDULE.get(week_type, {}).get(day, [])
result = []
for lesson in lessons:
pair_num = lesson.get("pair")
time_start = time_end = None
if pair_num and pair_num in config.PAIR_TIMES:
start, end = config.PAIR_TIMES[pair_num]
time_start = f"{start.hour:02d}:{start.minute:02d}"
time_end = f"{end.hour:02d}:{end.minute:02d}"
result.append(SchedulePair(
pair=pair_num,
title=lesson.get("title", ""),
time_start=time_start,
time_end=time_end,
))
return result
# --- UwU Арты ---
@app.get("/api/uwu")
async def get_uwu_api(
tags: str = "rating:safe score:>500 -animated",
user: dict = Depends(get_current_user),
):
"""Отправить случайный uwu арт по тегам."""
try:
tags += " order:random score:>300"
post = await fetch_uwu_post(tags)
return {"response": post}
except Exception as e:
logger.exception("UwU API failed")
raise HTTPException(status_code=500, detail=str(e))
# --- FurTok Лента ---
@app.get("/api/furtok")
async def get_furtok_feed_api(
safe: bool = True,
page: int = 1,
tags: str = "",
user: dict = Depends(get_current_user),
):
"""Получить ленту анимированных постов для FurTok."""
try:
feed = await fetch_furtok_feed(safe=safe, page=page, extra_tags=tags)
return {"feed": feed}
except Exception as e:
logger.exception("FurTok API failed")
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")
async def talk_api(
req: TalkRequest,
user: dict = Depends(get_current_user),
):
"""Отправить сообщение ИИ."""
from AI.talk_handler import (
BOT_MEMORY_NAME,
EMPTY_RESPONSE_TEXT,
SYSTEM_PROMPT,
_generate_response,
_normalize_reply,
push_message,
)
user_id = user["user_id"]
user_name = user["first_name"] or "кент"
# Используем user_id как chat_id для личных сообщений в webapp
chat_id = user_id
try:
await asyncio.to_thread(push_message, chat_id, "user", user_name, req.text, user_id=user_id)
response = await _generate_response(
chat_id,
system_prompt=SYSTEM_PROMPT,
user_id=user_id,
max_tokens=220,
temperature=0.8,
top_p=0.9,
)
normalized_response = _normalize_reply(response)
if not normalized_response:
normalized_response = EMPTY_RESPONSE_TEXT
await asyncio.to_thread(
push_message,
chat_id,
"assistant",
BOT_MEMORY_NAME,
normalized_response,
user_id=user_id,
)
return {"response": normalized_response}
except Exception:
logger.exception("Talk API failed")
raise HTTPException(status_code=500, detail="LLM error")
# ──────────────────── Static files (для локального теста) ────────────────────
_frontend_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend")
if os.path.isdir(_frontend_dir):
app.mount("/", StaticFiles(directory=_frontend_dir, html=True), name="frontend")
# ──────────────────── Entry point ────────────────────
if __name__ == "__main__":
import uvicorn
port = int(os.getenv("API_PORT", "8080"))
uvicorn.run(app, host="0.0.0.0", port=port)

82
webapp/auth.py Normal file
View file

@ -0,0 +1,82 @@
"""
Telegram Mini App валидация initData.
Telegram передаёт подписанные данные при открытии WebApp.
Мы проверяем HMAC-SHA256 подпись через BOT_TOKEN,
чтобы убедиться что запрос пришёл от реального пользователя.
Документация: https://core.telegram.org/bots/webapps#validating-data
"""
import hashlib
import hmac
import json
import os
import time
from urllib.parse import parse_qs
BOT_TOKEN = os.getenv("BOT_TOKEN", "")
def validate_init_data(init_data: str, max_age_seconds: int = 86400) -> dict | None:
"""
Валидирует initData из Telegram WebApp.
Возвращает dict с данными пользователя при успехе, None при ошибке.
"""
if not init_data or not BOT_TOKEN:
return None
try:
parsed = parse_qs(init_data, keep_blank_values=True)
# Извлекаем hash
received_hash = parsed.get("hash", [None])[0]
if not received_hash:
return None
# Собираем data-check-string (все поля кроме hash, отсортированные)
data_pairs = []
for key, values in parsed.items():
if key == "hash":
continue
data_pairs.append(f"{key}={values[0]}")
data_pairs.sort()
data_check_string = "\n".join(data_pairs)
# Вычисляем секретный ключ
secret_key = hmac.new(
b"WebAppData", BOT_TOKEN.encode("utf-8"), hashlib.sha256
).digest()
# Вычисляем HMAC
computed_hash = hmac.new(
secret_key, data_check_string.encode("utf-8"), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(computed_hash, received_hash):
return None
# Проверяем auth_date (не старше max_age_seconds)
auth_date_str = parsed.get("auth_date", [None])[0]
if auth_date_str:
auth_date = int(auth_date_str)
if time.time() - auth_date > max_age_seconds:
return None
# Парсим user
user_str = parsed.get("user", [None])[0]
if not user_str:
return None
user_data = json.loads(user_str)
return {
"user_id": user_data.get("id"),
"first_name": user_data.get("first_name", ""),
"last_name": user_data.get("last_name", ""),
"username": user_data.get("username", ""),
"language_code": user_data.get("language_code", "ru"),
}
except Exception:
return None

View file

@ -0,0 +1,18 @@
FROM nginx:alpine
RUN apk add --no-cache certbot openssl
COPY index.html /usr/share/nginx/html/index.html
COPY css /usr/share/nginx/html/css
COPY js /usr/share/nginx/html/js
COPY nginx.conf /etc/nginx/default.conf.template
COPY docker-entrypoint.d/01-render-nginx-conf.sh /docker-entrypoint.d/01-render-nginx-conf.sh
COPY docker-entrypoint.d/05-init-self-signed.sh /docker-entrypoint.d/05-init-self-signed.sh
COPY docker-entrypoint.d/10-certbot-loop.sh /docker-entrypoint.d/10-certbot-loop.sh
RUN chmod +x /docker-entrypoint.d/01-render-nginx-conf.sh \
/docker-entrypoint.d/05-init-self-signed.sh \
/docker-entrypoint.d/10-certbot-loop.sh \
&& rm -f /etc/nginx/conf.d/default.conf
EXPOSE 80 443

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,20 @@
#!/bin/sh
set -eu
APP_DOMAIN="${APP_DOMAIN:-}"
APP_WWW_DOMAIN="${APP_WWW_DOMAIN:-}"
PRIMARY_DOMAIN="${APP_DOMAIN:-localhost}"
SERVER_NAMES="_"
if [ -n "$APP_DOMAIN" ]; then
SERVER_NAMES="$APP_DOMAIN"
if [ -n "$APP_WWW_DOMAIN" ]; then
SERVER_NAMES="$SERVER_NAMES $APP_WWW_DOMAIN"
fi
fi
sed \
-e "s|__SERVER_NAMES__|$SERVER_NAMES|g" \
-e "s|__PRIMARY_DOMAIN__|$PRIMARY_DOMAIN|g" \
/etc/nginx/default.conf.template > /etc/nginx/conf.d/default.conf

View file

@ -0,0 +1,16 @@
#!/bin/sh
set -eu
PRIMARY_DOMAIN="${APP_DOMAIN:-localhost}"
CERT_DIR="/etc/letsencrypt/live/$PRIMARY_DOMAIN"
FULLCHAIN="$CERT_DIR/fullchain.pem"
PRIVKEY="$CERT_DIR/privkey.pem"
if [ ! -s "$FULLCHAIN" ] || [ ! -s "$PRIVKEY" ]; then
echo "No TLS certificate for $PRIMARY_DOMAIN. Generating temporary self-signed cert."
mkdir -p "$CERT_DIR"
openssl req -x509 -nodes -newkey rsa:2048 -days 1 \
-keyout "$PRIVKEY" \
-out "$FULLCHAIN" \
-subj "/CN=$PRIMARY_DOMAIN" >/dev/null 2>&1
fi

View file

@ -0,0 +1,47 @@
#!/bin/sh
set -eu
APP_DOMAIN="${APP_DOMAIN:-}"
APP_WWW_DOMAIN="${APP_WWW_DOMAIN:-}"
ACME_EMAIL="${ACME_EMAIL:-}"
CERTBOT_STAGING="${CERTBOT_STAGING:-0}"
CERTBOT_RENEW_INTERVAL="${CERTBOT_RENEW_INTERVAL:-12h}"
if [ -z "$APP_DOMAIN" ] || [ -z "$ACME_EMAIL" ]; then
echo "Skipping certbot: APP_DOMAIN or ACME_EMAIL is empty."
exit 0
fi
(
sleep 20
if [ ! -f "/etc/letsencrypt/renewal/$APP_DOMAIN.conf" ]; then
rm -rf "/etc/letsencrypt/live/$APP_DOMAIN" "/etc/letsencrypt/archive/$APP_DOMAIN"
fi
while :; do
DOMAINS="-d $APP_DOMAIN"
if [ -n "$APP_WWW_DOMAIN" ]; then
DOMAINS="$DOMAINS -d $APP_WWW_DOMAIN"
fi
STAGING_FLAG=""
if [ "$CERTBOT_STAGING" = "1" ]; then
STAGING_FLAG="--staging"
fi
# shellcheck disable=SC2086
certbot certonly \
--webroot \
-w /var/www/certbot \
--agree-tos \
--non-interactive \
--email "$ACME_EMAIL" \
--keep-until-expiring \
$STAGING_FLAG \
$DOMAINS || true
nginx -s reload || true
sleep "$CERTBOT_RENEW_INTERVAL"
done
) &

View file

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#0a0e1a">
<title>FenyaBot</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<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>
<body>
<div id="app">
<div id="page-container"></div>
<nav id="bottom-nav">
<button class="nav-btn active" data-page="home">
<span class="nav-icon">🏠</span>
<span class="nav-label">Главная</span>
</button>
<button class="nav-btn" data-page="betting">
<span class="nav-icon"></span>
<span class="nav-label">Ставки</span>
</button>
<button class="nav-btn" data-page="casino">
<span class="nav-icon">🎰</span>
<span class="nav-label">Казино</span>
</button>
<button class="nav-btn" data-page="schedule">
<span class="nav-icon">📅</span>
<span class="nav-label">Пары</span>
</button>
<button class="nav-btn" data-page="furtok">
<span class="nav-icon">🐺</span>
<span class="nav-label">FurTok</span>
</button>
<button class="nav-btn" data-page="chat">
<span class="nav-icon">💬</span>
<span class="nav-label">Чат</span>
</button>
</nav>
</div>
<script type="module" src="js/app.js"></script>
</body>
</html>

48
webapp/frontend/js/api.js Normal file
View file

@ -0,0 +1,48 @@
/**
* FenyaBot Mini App API wrapper
*/
const API_BASE = window.location.origin;
function getInitData() {
if (window.Telegram?.WebApp?.initData) {
return window.Telegram.WebApp.initData;
}
return '';
}
async function api(path, options = {}) {
const { method = 'GET', body = null } = options;
const headers = {
'X-Telegram-Init-Data': getInitData(),
'Content-Type': 'application/json',
};
const config = { method, headers };
if (body) config.body = JSON.stringify(body);
const resp = await fetch(`${API_BASE}${path}`, config);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
return resp.json();
}
export const API = {
getProfile: () => api('/api/profile'),
getLeagues: () => api('/api/leagues'),
getMatches: (league) => api(`/api/matches/${league}`),
placeBet: (data) => api('/api/bet', { method: 'POST', body: data }),
getMyBets: () => api('/api/mybets'),
getBetHistory: () => api('/api/bet_history'),
playCasino: (bet) => api('/api/casino', { method: 'POST', body: { bet } }),
playPenis: () => api('/api/penis', { method: 'POST' }),
getTop: () => api('/api/top'),
getSchedule: (day) => api(`/api/schedule${day != null ? `?day=${day}` : ''}`),
getUwu: (tags) => api(`/api/uwu?tags=${encodeURIComponent(tags || 'rating:safe score:>500 -animated')}`),
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 } }),
};

1066
webapp/frontend/js/app.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,58 @@
server {
listen 80;
listen [::]:80;
server_name __SERVER_NAMES__;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name __SERVER_NAMES__;
ssl_certificate /etc/letsencrypt/live/__PRIMARY_DOMAIN__/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/__PRIMARY_DOMAIN__/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
root /usr/share/nginx/html;
index index.html;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location /api/ {
resolver 127.0.0.11 valid=10s;
set $api http://webapp-api:8080;
proxy_pass $api;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Telegram-Init-Data $http_x_telegram_init_data;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 300s;
}
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(css|js|png|jpg|jpeg|svg|woff2?)$ {
expires 7d;
add_header Cache-Control "public, immutable";
}
}

18
webapp/ngrok_tunnel.py Normal file
View file

@ -0,0 +1,18 @@
"""Скрипт для локального тестирования Mini App с ngrok"""
import time
from pyngrok import conf, ngrok
conf.get_default().auth_token = "39k4ojhN5UPUWQwb1ly84ORM2IE_5JnhMaKk5p8LFM3TCetyg"
tunnel = ngrok.connect(8080, "http")
print(f"\n{'='*50}")
print(f" NGROK URL: {tunnel.public_url}")
print(f"{'='*50}\n")
print("Туннель открыт. Не закрывай это окно!")
print("Ctrl+C чтобы остановить.\n")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
ngrok.kill()
print("Туннель закрыт.")

View file

@ -0,0 +1,3 @@
fastapi>=0.111.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]

55
zparser.py Normal file
View file

@ -0,0 +1,55 @@
import requests
from bs4 import BeautifulSoup
import re
import time
def get_military_data(channel_username, depth=5):
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'}
last_id = None
for i in range(depth):
url = f"https://t.me/s/{channel_username}"
if last_id:
url += f"?before={last_id}"
try:
response = requests.get(url, headers=headers, timeout=10)
soup = BeautifulSoup(response.text, 'html.parser')
messages = soup.find_all('div', class_='tgme_widget_message')
if not messages:
break
first_msg_data = messages[0].get('data-post')
if first_msg_data:
last_id = first_msg_data.split('/')[-1]
for msg in reversed(messages):
text_elem = msg.find('div', class_='tgme_widget_message_text')
if not text_elem:
continue
text = " ".join(text_elem.get_text(separator=' ').split())
text_lower = text.lower()
if "темп" in text_lower and "продвижения" in text_lower:
# Улучшенная регулярка для дат (ловит и "с 14 по 17", и "с 14 февраля по 17 февраля")
dates_match = re.search(r"с (.*?)(?=\.|\s+темп)", text_lower)
# Твои регулярки для цифр — отличные
pace_match = re.search(r"темп[^+-]*([+-]\d+[.,]\d+)", text_lower)
total_match = re.search(r"общее продвижение\s*(\d+[.,]\d+)", text_lower)
if pace_match:
return {
"status": "success",
"dates": dates_match.group(1).strip() if dates_match else "не указаны",
"pace": pace_match.group(1).replace(',', '.'),
"total": total_match.group(1).replace(',', '.') if total_match else "0",
"post_id": msg.get('data-post').split('/')[-1]
}
time.sleep(1)
except Exception as e:
print(f"Ошибка на итерации {i}: {e}")
continue
return {"status": "error", "message": "Сводка не найдена в последних постах"}