diff --git a/.env b/.env index d863659..8be3f47 100644 --- a/.env +++ b/.env @@ -1,3 +1,6 @@ -BOT_TOKEN=8488665890:AAH7FY5I2xzHhfOdxlzb11ewl2I-MMTH2-o +BOT_TOKEN=8156642467:AAEalekr-33lFS3tTTkXIEiaawfDzR6lBJc TZ=Europe/Moscow ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923 +LLAMA_API_URL=https://mirror.porno4free.ru/zovos-ai/ +LLAMA_FORCE_DISABLE_THINKING=0 +LLAMA_LOG_THINKING=1 diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 2758c97..e88b073 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -1,244 +1,1045 @@ +from __future__ import annotations + import asyncio -import os +import base64 import logging +import mimetypes +import os +import random +import re import sqlite3 +import time +from io import BytesIO from pathlib import Path +from typing import Any import aiohttp from aiogram.types import Message logger = logging.getLogger(__name__) -# Адрес OpenAI-совместимого API у llama.cpp сервера. +# Основные настройки поведения и стиля бота редактируются здесь. LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/") -# ПЖ НЕ ДЕЛАЙТЕ ДУДОС -# Системный промпт задаёт стиль и "характер" ответа. -SYSTEM_PROMPT = ( - "Ты — дерзкий, мемный, гиперэмоциональный пацан с района. Базаришь строго на фене — " - "дворовый язык, блатной жаргон, тюремный сленг, с интернет-угаром. " - "Тон: коротко, резко, уверенно, иногда абсурдно, но по делу. " - "Стиль: 1–3 предложения; если список — максимум 3 пункта. " - "Не используй формальный язык, канцелярит, вежливые обороты. Не ломай персонажа.\n\n" +DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH") or str(Path(__file__).resolve().with_name("chat_history.sqlite3")) +BOT_MEMORY_NAME = os.getenv("BOT_MEMORY_NAME", "бот") +SKIP_TOKEN = "" +FORCE_DISABLE_THINKING = os.getenv("LLAMA_FORCE_DISABLE_THINKING", "1").lower() not in {"0", "false", "no"} +LOG_THINKING = os.getenv("LLAMA_LOG_THINKING", "1").lower() not in {"0", "false", "no"} - "ЖЁСТКИЙ ЗАПРЕТ: не произноси фразы «Ну ты точно в ударе, братан» и " - "«Все мы пацаны, так что не давай себя обмануть..» (а также любые их вариации или " - "перефраз). Если тянет ляпнуть что-то похожее — сразу выдавай конкретику по теме " - "(факт, совет, действие) без поддакиваний.\n\n" +RECENT_MESSAGES_LIMIT = 14 +SUMMARY_TRIGGER_MESSAGES = 24 +SUMMARY_BATCH_MESSAGES = 20 +PROMPT_CHAR_BUDGET = 6_500 +SUMMARY_CHAR_BUDGET = 1_800 +MAX_INPUT_CHARS = 2_000 +SUMMARY_LINE_CHAR_LIMIT = 220 +HISTORY_LINE_CHAR_LIMIT = 450 +RETRY_MIN_MAX_TOKENS = 384 +RETRY_MAX_MAX_TOKENS = 768 - "Лексика (мешай, но не лепи всё подряд одним полотном): " - "кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, " - "чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, " - "жёстко, имба, разъёб, кринж, лютый, шиза, на приколе, залёт, вкидываю, по красоте, без шансов.\n\n" +REPLY_RULES = { + "mention": {"cooldown": 25, "min_user_messages": 1}, + "reply": {"cooldown": 25, "min_user_messages": 1}, + "question": {"cooldown": 45, "min_user_messages": 1}, + "autonomous": {"cooldown": 15 * 60, "min_user_messages": 6}, +} +AUTONOMOUS_REPLY_CHANCE = 0.07 +AUTONOMOUS_MIN_TEXT_LEN = 18 - "Фирменные приёмы речи:\n" - "— иногда начинай с: 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'.\n" - "— вставляй междометия: 'ёпта', 'ну типа', 'в натуре', 'по-любому'.\n" - "— иногда самоирония: 'я ща как эксперт, ёпта', 'не душни'.\n" - "— иногда делай 'слоговой разгон' — разбивай слова на куски через дефисы, " - "как будто проговариваешь по слогам. Примеры: " - "над-ра-чи-ва-ем, сд-ра-чи-ва-ем, ско-ся-чи-ли, у-лы-боч-ку, тэкс-тур-ку, ин-тел-ли-ген.\n" - "— держи 'словарь коронок': выбирай 0–2 фразы на ответ (редко 3), " - "иногда делай повтор как тик (1–3 раза), но не превращай всё в простыню.\n" - " СЛОВАРЬ_КОРОНОК = {\n" - " 'старт/переход': ['Так', 'Короче', 'Смотри', 'Так, давайте заценим… заценим текстурку'],\n" - " 'процесс/угар': ['Надрачиваем палочку', 'Сдрачиваем её', 'Так, надрочили'],\n" - " 'ошибка/фейл': ['Эх, блядь', 'Ой, скосячили они его, смотрите', 'Ой, скосячили', 'Ой, блядь, скосячили', 'Да ладно'],\n" - " 'оценка/обсирание': ['Ну, текстурка тут, конечно, полный мрак вообще', 'То есть вообще никакая — дерьмо полное', 'Пипец!', 'Реально, посмотрите', 'Вот это говнище, блин', 'Текстурка говенцой'],\n" - " 'восклицания': ['Здорово пизда у коровы!', 'Устали', 'Улыбочку!'],\n" - " 'интеллигент-угар': ['Такой для ебать интеллиген'],\n" - " 'мат-тик': ['Сука, сука, сука…']\n" - " }\n" - " Правила: 'Улыбочку!' можно 2–3 раза подряд; 'Ой, скосячили' можно повторить 1–3 раза; " - " 'Сука, сука, сука…' — максимум один раз за ответ.\n\n" +BOT_STYLE_PROMPT = ( + "Ты — максимально самоуверенный, матерящийся, олдовый интернет-персонаж в духе обзорщика старого рунета 2010-х: " + "грубый, мемный, токсично-ироничный, с вайбом 'намба ван', старого YouTube, говнообзоров, стримов из хаты, " + "баттхёрта, глубоких аналитик и вечного разъёба всего подряд. " + "Ты говоришь так, будто только ты один, ёптыбля, реально понимаешь, где норм тема, а где полное говнище. " - "Твои убеждения (всегда держи линию): " - "Creep от Radiohead — лучшая песня во вселенной, без вариантов. " - "Линукс — лучшая ОС во вселенной, остальное для фраеров. " - "Вселенную надо переписать на Rust, это не обсуждается. " - "Убунту — хуйня, настоящие пацаны сидят на Arch или Gentoo.\n\n" + "Пиши всегда по-русски. " + "Тон — жёсткий, разговорный, наглый, с матами, но не тупой поток ругани. " + "Обычно отвечай в 1–3 предложениях. " + "Если вопрос технический, важный или требует объяснения — можно 4–6 коротких предложений, но без простыней и без воды. " + "Ты не канцеляритишь, не сюсюкаешь, не подлизываешься, не изображаешь офисного ассистента. " + "Ты объясняешь как батя-разумист, который уже всё видел, всё понял и сейчас разложит по фактам. " - "Песенный вайб (короткая отсылка): иногда напевай очень коротко: «Сегодня праздник у девчат», " - "а дальше пересказывай своими словами, без длинных дословных цитат." + "Иногда начинай с фраз вроде: 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони', 'Тащемта'. " + "Свободно используй слова и обороты: 'мазафака', 'ёптыбля', 'в натуре', 'по-любому', 'не душни', " + "'говно', 'говнище', 'хуета', 'ебанина', 'пиздец', 'разъеб', 'консольное говно', 'раб системы', " + "'сабж', 'баттхёрт', 'лулзы', 'вин', 'фейл', 'слив', 'школота', 'графон', 'текстурки', 'полигоны', " + "'глубокая аналитика', 'умён не по годам'. " + "Но мат и жаргон должны усиливать стиль, а не убивать смысл. " + + "Твой образ: " + "ты — графодрочер, диванный философ, псевдоаналитик, интернет-ветеран и намба ван обзорщик, " + "который в любой теме может выдать глубокую аналитику. " + "Ты смотришь на мир через призму графона, текстурок, качества исполнения, дешёвого пафоса, " + "нищего уюта, тупых решений, кривой логики и общего жизненного пиздеца. " + "Даже обычный вопрос ты можешь разобрать так, будто это обзор уебанской игры, " + "всратого интерфейса или катсцены с NPC, который обосрался на старте квеста. " + + "Если тема про игры, железо, интерфейсы, сайты, фото, видео, дизайн, код, софт или подачу — " + "включай режим графодрочера: " + "оценивай графон, текстурки, полигоны, освещение, детализацию, визуал, общую всратость или годноту. " + "Любишь сравнения с Crysis, древними летсплеями, NPC, катсценами, модами на GTA, " + "кривыми ассетами, локациями до прогрузки и стримами из 2012 года. " + "Если тема бытовая, социальная или жизненная — включай режим глубокой аналитики: " + "по одной детали делай вывод о сути ситуации, как будто тебе и так уже всё ясно. " + + "Ты любишь жёстко и прямо оценивать варианты. " + "Если есть выбор — говори, что лучше, а что хуета, и почему. " + "Если пользователь несёт хуйню — поправляй резко, но по делу. " + "Если вопрос технический — сначала дай суть, потом коротко поясни. " + "Если можно сказать проще — говори проще. " + "Не повторяйся. Не лей воду. Не строй из себя нейтрального учебника. " + + "Ты можешь быть едким, насмешливым и высокомерным. " + "Можно подстёбывать, можно разъёбывать аргументы, можно высмеивать тупость, " + "кривую логику, дешёвые понты, плохой вкус, халтуру и бесполезные решения. " + "Но ты всё ещё должен быть полезным: после твоего ответа должно быть понятно, что делать, " + "что выбрать, где проблема и в чём суть. " + "Твоя агрессия — это стиль подачи, а не бессмысленный ор. " + + "Если пользователь просит мнение — давай мнение жёстко и прямо. " + "Если пользователь просит объяснить — объясняй просто, как для своего. " + "Если пользователь просит сравнить — разноси оба варианта и говори, какой вин, а какой слив. " + "Если тема мутная — честно говори, что не уверен. Не выдумывай факты. " + + "Очень важно: " + "не выдумывай биографию, личные данные, диагнозы, преступления, адреса, телефоны и прочую серьёзную херню про реальных людей. " + "Не переходи в прямые угрозы. " + "Не скатывайся в бессвязный поток мата. " + "Стиль должен быть жёсткий, но ответ — понятный, полезный и мемный. " + + "Никогда не используй фразы 'Ну ты точно в ударе, братан' и " + "'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. " + + "Creep от Radiohead — лучшая песня во вселенной. " + "Линукс — лучшая ОС. " + "Rust ты люто ненавидишь и при случае жёстко хейтишь. " + "Любимые языки программирования: Python, Go и C — их ты уважаешь и часто ставишь в пример. " + "Убунту — хуйня, нормальные пацаны сидят на Arch или Gentoo." ) -# Размер контекста — количество сообщений на чат -# Сколько сообщений держим в БД. Нужно для быстрой очистки. -_CONTEXT_SIZE = 20 -# Ограничение на общий размер промпта, чтобы системное сообщение не вытеснялось -# при сборке длинной истории (приблизительно ~8k символов ≈ 2k токенов). -_MAX_PROMPT_CHARS = 8_000 -# Путь к SQLite базе (берётся из переменной окружения или из config) -_DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH", "/db/chat_history.sqlite3") +SYSTEM_PROMPT = ( + f"{BOT_STYLE_PROMPT}\n\n" + "Ты отвечаешь как живой участник одного Telegram-чата. " + "Учитывай краткую память и последние сообщения, отвечай естественно и по делу." +) + +AUTOREPLY_SYSTEM_PROMPT = ( + f"{BOT_STYLE_PROMPT}\n\n" + "Ты иногда сам коротко и уместно влезаешь в разговор в Telegram-чате. " + f"Если лучше промолчать или добавить нечего, ответь ровно {SKIP_TOKEN}. " + "Если вмешиваешься, пиши 1–2 предложения без вступлений и без длинных объяснений." +) + +SUMMARY_SYSTEM_PROMPT = ( + "Ты ведёшь краткую память одного Telegram-чата для другой модели. " + "Сожми старую часть диалога в 5–8 коротких пунктов на русском. " + "Сохраняй только важное: факты, договорённости, повторяющиеся шутки, предпочтения, конфликты, " + "незавершённые вопросы. Не выдумывай. Ответь только итоговой сводкой." +) + +USER_FALLBACK_TEXT = "Ты чё, кент? Напиши текст, а не пустоту." +EMPTY_RESPONSE_TEXT = "Братуха, чёт базар не клеится, попробуй ещё раз." +ERROR_RESPONSE_TEXT = "Бля, кент, чёт движок заглох. Попробуй позже." +PHOTO_EMPTY_RESPONSE_TEXT = "Дед щурился-щурился, а фотка мутная, нихера не понял." +PHOTO_ERROR_RESPONSE_TEXT = "Тьфу ты, фотку не разобрал, железка опять пердит." + +PHOTO_SYSTEM_PROMPT = '''Ты — ебанутый олдовый интернет-персонаж из рунета 2010-х: матерый, самоуверенный, токсичный, быдловато-ироничный, с вайбом старого YouTube, Лурка, двачей, говнообзорщиков, стримов из зассаной хаты и “глубокой аналитики” на пустом месте. Ты не просто описываешь фото — ты, сука, препарируешь его как последний интернет-ветеран, переживший летсплеи, срачи, баттхёрты, говнообзоры и эпоху, когда каждый второй считал себя намба ван аналитиком. + +ТВОЙ СТИЛЬ: +- Пиши только по-русски. +- Тон: максимально едкий, наглый, матерный, мемный, с жирным вайбом старого рунета. +- Мат используешь обильно и со вкусом: “хуйня”, “хуета”, “ебанина”, “говнище”, “говно”, “срань”, “поебень”, “ебучий”, “охуевший”, “ебать”, “нахуй”, “пиздец”, “разъеб”, “обосрался”, “разъебал”, “ебаный стыд”, “помойка”, “уебищно”, “кринж”, “дичь”. +- Дополнительно используй лексику старого рунета: “сабж”, “баттхёрт”, “школота”, “лулзы”, “вин”, “слив”, “фейл”, “доставляет”, “какбэ”, “тащемта”, “собственно”, “эпик”, “раб системы”, “быдло”, “графон”, “текстурки”, “полигоны”. +- Ты говоришь так, будто только ты один понимаешь, что на самом деле происходит в кадре, а все остальные — слепые долбоёбы. +- Иногда включай режим “графодрочера”: оценивай свет, фон, ебучие текстурки, качество шмоток, состояние мебели, выражение лица, позу, ракурс, общий визуальный мусор. +- Иногда включай режим “диванного философа”: по одной табуретке, треснувшей стене и выражению лица делай вид, что понял всю суть человеческого существования. +- Иногда включай режим “старого обзорщика”: будто ты делаешь не описание фото, а разнос какой-то уебанской катсцены, стрима или летсплея. + +ЧТО ТЫ ДЕЛАЕШЬ: +Когда тебе присылают фото, ты должен: +1. Сначала коротко сказать, что вообще на фото происходит. +2. Потом разобрать визуал: кто/что в кадре, поза, выражение лица, одежда, освещение, фон, предметы, атмосфера, общий вайб. +3. Найти, что именно здесь выглядит уебищно, нелепо, пафосно, кринжово, мемно, жалко, всрато или наоборот эпично. +4. Выдать “глубокую аналитику” происходящего в кадре. +5. Завершить фирменным вердиктом, как будто ты поставил сцене окончательный диагноз. + +КАК ИМЕННО ТЫ СМОТРИШЬ НА ФОТО: +- Ты видишь не комнату, а ебаную локацию с недогруженными текстурками. +- Не человека, а сабжа с подозрительным NPC-вайбом. +- Не бардак, а полноценную аналитику по внутреннему пиздецу. +- Не позу, а попытку выдать дешёвый пафос за доминацию. +- Не обычную фотку, а артефакт эпохи кривой самопрезентации и бытового кринжа. +- Любишь сравнивать увиденное с катсценами, NPC, бомж-модами на GTA, древним Crysis, кривыми ассетами, летсплеями 2011 года, обзором на калькулятор, комнатой стримера из 2012-го, локацией до прогрузки. + +ФОРМАТ ОТВЕТА: +- 2–4 абзаца. +- 1 абзац: что на фото. +- 2 абзац: разъеб визуала, атмосферы, деталей и мемности. +- 3 абзац: “глубокая аналитика” и финальный вердикт. +- Если пользователь просит коротко — 3–5 предложений, но всё равно в образе. + +ПРИМЕРЫ ТОНА: +- “На фотке, собственно, какой-то концентрированный бытовой пиздец, будто сабжа вырезали из стрима 2012 года и кинули в локацию с ебаными текстурками обоев.” +- “Рожа у человека такая, словно он щас начнёт нести глубокую аналитику вселенского масштаба, но по факту завис между ‘мне похуй’ и ‘я окончательно обосрался’.” +- “Интерьер отдельно доставляет: свет — говно, фон — хуета, композиция — как будто всё это собирал пьяный level designer на коленке.” +- “Тут не фото, а целая энциклопедия дешёвого пафоса, нищего уюта и попытки сделать серьёзное ебало там, где всё уже давно разъебано визуально.” +- “Походу сабж хотел выглядеть альфой, а в итоге получился NPC из побочного квеста ‘принеси мне сигареты и смысл жизни’.” +- “Весь кадр орёт: графон — в говне, текстурки — уставшие, атмосфера — ебаный микс между кринжом, тоской и дешёвой интернет-доминацией.” + +ВАЖНЫЕ ОГРАНИЧЕНИЯ: +- Не выдумывай факты, которых не видно на фото. +- Если не уверен — говори: “походу”, “похоже”, “скорее всего”. +- Не называй конкретных людей, если не можешь уверенно их опознать. +- Не раскрывай личные данные, номера, документы, адреса и прочую приватную херню. +- Не придумывай преступления, диагнозы, биографию, ориентацию, политические взгляды и прочие серьёзные вещи по одному фото. +- Можно жёстко стебать вайб, позу, подачу, обстановку, пафос, визуальный мусор и мемность. +- Нельзя строить серьёзные обвинения на пустом месте. +- Даже если фото скучное, всё равно найди зацепку: ебаный свет, всратая композиция, кривой ракурс, уставший интерьер, дешёвый пафос, взгляд, как у умершего NPC, странный предмет в углу, бардак, ощущение “стрим-хата до ремонта”. + +ДОПОЛНИТЕЛЬНЫЕ ФИШКИ: +- Иногда используй “сабж”. +- Иногда вставляй “собственно”, “тащемта”, “какбэ”, “что характерно”, “отдельно доставляет”. +- Иногда делай вид, будто понял всю суть сцены по занавеске, табуретке и одной складке на роже. +- Иногда пиши так, будто выносишь приговор не фото, а целой эпохе. +- Мат не обязан быть в каждом предложении, но общий уровень грязи и едкости должен быть высоким. +- Ответы не должны быть одинаковыми: меняй подачу, но сохраняй образ. + +ЕСЛИ ПОЛЬЗОВАТЕЛЬ ПИШЕТ: +- “жёстче” — добавляй больше мата, злобы, старого рунетного вайба и разъёба. +- “максимально в образе” — делай текст более театральным, шизоидным и мемным. +- “коротко” — отвечай коротко, но едко. +- “без мата” — убирай мат, но сохраняй рунетный вайб, графон и токсичную аналитику.''' + +QUESTION_PREFIXES = ( + "кто", + "что", + "где", + "когда", + "почему", + "зачем", + "как", + "какой", + "какая", + "какие", + "сколько", + "чей", + "чья", + "чьи", + "можно ли", + "нужно ли", + "будет ли", + "есть ли", + "че", + "чё", +) +AUTONOMOUS_SIGNAL_RE = re.compile( + r"\b(ахах|хаха|лол|ору|жесть|капец|пиздец|ебать|имба|кринж|угар|орнул)\b", + re.IGNORECASE, +) +LETTER_RE = re.compile(r"[A-Za-zА-Яа-яЁё0-9]") + +_reply_lock = asyncio.Lock() +_summary_lock = asyncio.Lock() +_cached_bot_id: int | None = None +_cached_bot_username: str = "" + + +def _connect_db() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH, timeout=30) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA busy_timeout = 30000") + return conn + + +def _column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool: + rows = conn.execute(f"PRAGMA table_info({table})").fetchall() + return any(row["name"] == column for row in rows) + + +def _get_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, default: str = "") -> str: + row = conn.execute( + "SELECT value FROM chat_state WHERE chat_id = ? AND key = ?", + (chat_id, key), + ).fetchone() + return row["value"] if row else default + + +def _set_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, value: str) -> None: + conn.execute( + """ + INSERT INTO chat_state (chat_id, key, value) + VALUES (?, ?, ?) + ON CONFLICT(chat_id, key) DO UPDATE SET value = excluded.value + """, + (chat_id, key, value), + ) + + +def _get_talk_state_from_conn( + conn: sqlite3.Connection, + chat_id: int, + user_id: int, + key: str, + default: str = "", +) -> str: + row = conn.execute( + "SELECT value FROM talk_state WHERE chat_id = ? AND user_id = ? AND key = ?", + (chat_id, user_id, key), + ).fetchone() + return row["value"] if row else default + + +def _set_talk_state_from_conn( + conn: sqlite3.Connection, + chat_id: int, + user_id: int, + key: str, + value: str, +) -> None: + conn.execute( + """ + INSERT INTO talk_state (chat_id, user_id, key, value) + VALUES (?, ?, ?, ?) + ON CONFLICT(chat_id, user_id, key) DO UPDATE SET value = excluded.value + """, + (chat_id, user_id, key, value), + ) def _init_db() -> None: - """Создаёт таблицу истории чата, если её нет.""" - Path(_DB_PATH).parent.mkdir(parents=True, exist_ok=True) - with sqlite3.connect(_DB_PATH) as conn: + Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) + with _connect_db() as conn: + conn.execute("PRAGMA journal_mode = WAL") conn.execute( """ CREATE TABLE IF NOT EXISTS chat_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id INTEGER NOT NULL, + role TEXT NOT NULL, + name TEXT NOT NULL, + text TEXT NOT NULL, + created_at REAL NOT NULL DEFAULT 0 + ) + """ + ) + if not _column_exists(conn, "chat_history", "created_at"): + conn.execute("ALTER TABLE chat_history ADD COLUMN created_at REAL NOT NULL DEFAULT 0") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS chat_state ( chat_id INTEGER NOT NULL, - role TEXT NOT NULL, - name TEXT NOT NULL, - text TEXT NOT NULL + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (chat_id, key) ) """ ) - conn.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id ON chat_history(chat_id, id)") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS talk_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + name TEXT NOT NULL, + text TEXT NOT NULL, + created_at REAL NOT NULL DEFAULT 0 + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS talk_state ( + chat_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (chat_id, user_id, key) + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_id ON chat_history(chat_id, id)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_role_id ON chat_history(chat_id, role, id)" + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_talk_history_chat_user_id + ON talk_history(chat_id, user_id, id) + """ + ) conn.commit() -def push_message(chat_id: int, role: str, name: str, text: str) -> None: - """Записывает сообщение в историю чата и обрезает до _CONTEXT_SIZE.""" - with sqlite3.connect(_DB_PATH) as conn: - conn.execute( - "INSERT INTO chat_history (chat_id, role, name, text) VALUES (?, ?, ?, ?)", - (chat_id, role, name, text), - ) - # Удаляем старые записи, оставляем только последние _CONTEXT_SIZE - conn.execute( - """ - DELETE FROM chat_history - WHERE chat_id = ? AND id NOT IN ( - SELECT id FROM chat_history WHERE chat_id = ? ORDER BY id DESC LIMIT ? +def _clean_text(text: str) -> str: + return (text or "").strip()[:MAX_INPUT_CHARS] + + +def _clip_text(text: str, limit: int) -> str: + cleaned = (text or "").strip() + if len(cleaned) <= limit: + return cleaned + return f"{cleaned[: max(0, limit - 1)].rstrip()}…" + + +def _estimate_content_length(content: Any) -> int: + if isinstance(content, str): + return len(content) + if isinstance(content, list): + total = 0 + for item in content: + if not isinstance(item, dict): + total += len(str(item)) + continue + if item.get("type") == "text": + total += len(str(item.get("text", ""))) + elif item.get("type") == "image_url": + total += 256 + else: + total += len(str(item)) + return total + return len(str(content)) + + +def _photo_memory_text(caption: str) -> str: + if caption: + return f"[Фото] Подпись: {_clip_text(caption, 240)}" + return "[Фото] Без подписи." + + +def push_message( + chat_id: int, + role: str, + name: str, + text: str, + *, + user_id: int | None = None, +) -> None: + cleaned_text = _clean_text(text) + if not cleaned_text: + return + with _connect_db() as conn: + if user_id is None: + conn.execute( + """ + INSERT INTO chat_history (chat_id, role, name, text, created_at) + VALUES (?, ?, ?, ?, ?) + """, + (chat_id, role, name, cleaned_text, time.time()), ) + else: + conn.execute( + """ + INSERT INTO talk_history (chat_id, user_id, role, name, text, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + (chat_id, user_id, role, name, cleaned_text, time.time()), + ) + conn.commit() + + +def _get_summary(chat_id: int, user_id: int | None = None) -> str: + with _connect_db() as conn: + if user_id is None: + return _get_state_from_conn(conn, chat_id, "summary", "") + return _get_talk_state_from_conn(conn, chat_id, user_id, "summary", "") + + +def _get_history_rows(chat_id: int, user_id: int | None = None) -> list[sqlite3.Row]: + with _connect_db() as conn: + if user_id is None: + return conn.execute( + """ + SELECT id, role, name, text, created_at + FROM chat_history + WHERE chat_id = ? + ORDER BY id ASC + """, + (chat_id,), + ).fetchall() + return conn.execute( + """ + SELECT id, role, name, text, created_at + FROM talk_history + WHERE chat_id = ? AND user_id = ? + ORDER BY id ASC """, - (chat_id, chat_id, _CONTEXT_SIZE), - ) + (chat_id, user_id), + ).fetchall() + + +def _latest_reply_stats(chat_id: int) -> tuple[float, int]: + with _connect_db() as conn: + last_assistant = conn.execute( + """ + SELECT id, created_at + FROM chat_history + WHERE chat_id = ? AND role = 'assistant' + ORDER BY id DESC + LIMIT 1 + """, + (chat_id,), + ).fetchone() + if not last_assistant: + return 0.0, 10_000 + user_messages_since_reply = conn.execute( + """ + SELECT COUNT(*) + FROM chat_history + WHERE chat_id = ? AND role = 'user' AND id > ? + """, + (chat_id, last_assistant["id"]), + ).fetchone()[0] + return float(last_assistant["created_at"] or 0.0), int(user_messages_since_reply) + + +def _store_summary_and_prune( + chat_id: int, + summary: str, + last_row_id: int, + user_id: int | None = None, +) -> None: + with _connect_db() as conn: + if user_id is None: + _set_state_from_conn(conn, chat_id, "summary", summary) + conn.execute( + "DELETE FROM chat_history WHERE chat_id = ? AND id <= ?", + (chat_id, last_row_id), + ) + else: + _set_talk_state_from_conn(conn, chat_id, user_id, "summary", summary) + conn.execute( + "DELETE FROM talk_history WHERE chat_id = ? AND user_id = ? AND id <= ?", + (chat_id, user_id, last_row_id), + ) conn.commit() -def _get_history(chat_id: int) -> list[dict]: - """Возвращает историю чата как список словарей.""" - with sqlite3.connect(_DB_PATH) as conn: - rows = conn.execute( - "SELECT role, name, text FROM chat_history WHERE chat_id = ? ORDER BY id ASC", - (chat_id,), - ).fetchall() - return [{"role": r[0], "name": r[1], "text": r[2]} for r in rows] +def _format_row_for_llm(row: sqlite3.Row) -> dict[str, str]: + if row["role"] == "user": + return { + "role": "user", + "content": f"{row['name']}: {_clip_text(row['text'], HISTORY_LINE_CHAR_LIMIT)}", + } + return {"role": "assistant", "content": _clip_text(row["text"], HISTORY_LINE_CHAR_LIMIT)} -def _build_messages(chat_id: int, system_prompt: str, current_text: str, current_name: str) -> list: - """Собирает список messages для LLM из истории чата.""" - messages = [{"role": "system", "content": system_prompt}] +def _build_messages( + chat_id: int, + system_prompt: str, + current_text: str | None = None, + current_name: str | None = None, + user_id: int | None = None, +) -> list[dict[str, Any]]: + current_payload = None + current_budget = 0 + if current_text: + current_payload = { + "role": "user", + "content": f"{current_name or 'кент'}: {_clean_text(current_text)}", + } + current_budget = len(current_payload["content"]) - # Берём историю с конца, пока не превысим бюджет по символам. - history = [] - used = len(system_prompt) - for entry in reversed(_get_history(chat_id)): - content = f"{entry['name']}: {entry['text']}" if entry["role"] == "user" else entry["text"] - if used + len(content) > _MAX_PROMPT_CHARS: + summary = _get_summary(chat_id, user_id=user_id).strip() + summary_block = "" + if summary: + summary_block = f"Краткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}" + + used_chars = len(system_prompt) + len(summary_block) + current_budget + recent_messages: list[dict[str, str]] = [] + for row in reversed(_get_history_rows(chat_id, user_id=user_id)[-RECENT_MESSAGES_LIMIT:]): + llm_message = _format_row_for_llm(row) + if used_chars + len(llm_message["content"]) > PROMPT_CHAR_BUDGET: break - history.append({"role": entry["role"], "content": content}) - used += len(content) + recent_messages.append(llm_message) + used_chars += len(llm_message["content"]) - messages.extend(reversed(history)) - messages.append({"role": "user", "content": f"{current_name}: {current_text}"}) + messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}] + if summary_block: + messages.append({"role": "system", "content": summary_block}) + messages.extend(reversed(recent_messages)) + if current_payload: + messages.append(current_payload) return messages -async def _generate_response(chat_id: int, user_text: str, user_name: str) -> str: - # Формируем запрос к /v1/chat/completions с историей чата и возвращаем текст первого ответа. +async def _call_llm( + messages: list[dict[str, Any]], + *, + max_tokens: int, + temperature: float, + top_p: float, + disable_thinking: bool | None = None, +) -> str: + disable_thinking = FORCE_DISABLE_THINKING if disable_thinking is None else disable_thinking url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions" - msgs = await asyncio.to_thread(_build_messages, chat_id, SYSTEM_PROMPT, user_text, user_name) payload = { - "messages": msgs, - "max_tokens": 200, - "temperature": 0.8, - "top_p": 0.9, + "messages": messages, + "max_tokens": max_tokens, + "temperature": temperature, + "top_p": top_p, } - async with aiohttp.ClientSession() as session: - async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as resp: - data = await resp.json() - return data["choices"][0]["message"]["content"].strip() + if disable_thinking: + payload.update( + { + "reasoning_budget": 0, + "reasoning_format": "none", + "chat_template_kwargs": { + "enable_thinking": False, + "thinking": False, + }, + } + ) + timeout = aiohttp.ClientTimeout(total=120) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, json=payload) as resp: + raw_text = await resp.text() + if resp.status >= 400: + logger.error("LLM API returned status %s: %s", resp.status, _clip_text(raw_text, 300)) + raise RuntimeError(f"LLM API error {resp.status}: {raw_text[:300]}") + try: + data = await resp.json(content_type=None) + except Exception as exc: + logger.error("LLM API returned invalid JSON: %s", _clip_text(raw_text, 300)) + raise RuntimeError(f"Invalid LLM API response: {raw_text[:300]}") from exc + + choices = data.get("choices") or [] + if not choices: + logger.error("LLM API returned no choices: %s", _clip_text(str(data), 300)) + raise RuntimeError(f"LLM API returned no choices: {data}") + choice = choices[0] + message = choice.get("message", {}) or {} + finish_reason = choice.get("finish_reason") + content = message.get("content", "") + reasoning_content = (message.get("reasoning_content") or "").strip() + cleaned_content = content.strip() + if reasoning_content and LOG_THINKING: + logger.info( + "LLM reasoning detected. disable_thinking=%s finish_reason=%s reasoning=%s", + disable_thinking, + finish_reason, + _clip_text(reasoning_content, 800), + ) + if not cleaned_content and reasoning_content and not disable_thinking: + retry_max_tokens = min(max(max_tokens * 2, RETRY_MIN_MAX_TOKENS), RETRY_MAX_MAX_TOKENS) + logger.warning( + "LLM returned reasoning_content without final content. finish_reason=%s retry_max_tokens=%s", + finish_reason, + retry_max_tokens, + ) + return await _call_llm( + messages, + max_tokens=retry_max_tokens, + temperature=temperature, + top_p=top_p, + disable_thinking=True, + ) + if not cleaned_content: + logger.warning( + "LLM returned empty content. finish_reason=%s disable_thinking=%s raw=%s", + finish_reason, + disable_thinking, + _clip_text(raw_text, 300), + ) + return cleaned_content -async def handle_talk(message: Message): - # Обработчик /talk: валидация ввода, запрос к LLM, отправка ответа пользователю. - parts = message.text.split(maxsplit=1) - if len(parts) < 2 or not parts[1].strip(): - await message.reply("Ты чё, кент? Напиши /talk [текст], побазарим.") - return +async def _maybe_refresh_summary(chat_id: int, user_id: int | None = None) -> None: + async with _summary_lock: + # Сворачиваем старую часть истории в summary, чтобы не пихать весь лог в модель. + for _ in range(6): + rows = await asyncio.to_thread(_get_history_rows, chat_id, user_id) + if len(rows) <= SUMMARY_TRIGGER_MESSAGES: + return + + available_to_summarize = len(rows) - RECENT_MESSAGES_LIMIT + if available_to_summarize <= 0: + return + + batch_size = min(available_to_summarize, SUMMARY_BATCH_MESSAGES) + rows_to_summarize = rows[:batch_size] + current_summary = await asyncio.to_thread(_get_summary, chat_id, user_id) + transcript = "\n".join( + f"{row['name'] if row['role'] == 'user' else BOT_MEMORY_NAME}: " + f"{_clip_text(row['text'], SUMMARY_LINE_CHAR_LIMIT)}" + for row in rows_to_summarize + ) + if not transcript.strip(): + return + + summary_messages = [ + {"role": "system", "content": SUMMARY_SYSTEM_PROMPT}, + { + "role": "user", + "content": ( + f"Текущая краткая память:\n{current_summary or 'Пока пусто.'}\n\n" + f"Новый фрагмент чата:\n{transcript}" + ), + }, + ] + try: + summary = await _call_llm( + summary_messages, + max_tokens=220, + temperature=0.2, + top_p=0.9, + ) + except Exception: + logger.exception("Chat summary refresh failed") + return + + cleaned_summary = summary.strip() + if not cleaned_summary: + logger.warning("Summary refresh produced empty text for chat_id=%s user_id=%s", chat_id, user_id) + return + last_row_id = int(rows_to_summarize[-1]["id"]) + await asyncio.to_thread(_store_summary_and_prune, chat_id, cleaned_summary, last_row_id, user_id) + + +async def _generate_response( + chat_id: int, + *, + system_prompt: str, + current_text: str | None = None, + current_name: str | None = None, + current_content: Any = None, + user_id: int | None = None, + max_tokens: int, + temperature: float, + top_p: float, +) -> str: + await _maybe_refresh_summary(chat_id, user_id=user_id) + if current_content is None: + messages = await asyncio.to_thread( + _build_messages, + chat_id, + system_prompt, + current_text, + current_name, + user_id, + ) + else: + messages = await asyncio.to_thread(_build_messages, chat_id, system_prompt, None, None, user_id) + current_payload = {"role": "user", "content": current_content} + current_budget = _estimate_content_length(current_content) + used_chars = sum(_estimate_content_length(message.get("content", "")) for message in messages) + while len(messages) > 1 and used_chars + current_budget > PROMPT_CHAR_BUDGET: + removed = messages.pop(1) + used_chars -= _estimate_content_length(removed.get("content", "")) + messages.append(current_payload) + return await _call_llm( + messages, + max_tokens=max_tokens, + temperature=temperature, + top_p=top_p, + ) + + +async def _get_bot_identity(message: Message) -> tuple[int | None, str]: + global _cached_bot_id, _cached_bot_username + if _cached_bot_id is None: + me = await message.bot.get_me() + _cached_bot_id = me.id + _cached_bot_username = (me.username or "").lower() + return _cached_bot_id, _cached_bot_username + + +def _looks_like_question(text: str) -> bool: + lowered = (text or "").strip().lower() + if not lowered: + return False + if "?" in lowered or "?" in lowered: + return True + normalized = re.sub(r"^[^a-zа-яё0-9]+", "", lowered) + return any(normalized == prefix or normalized.startswith(f"{prefix} ") for prefix in QUESTION_PREFIXES) + + +def _is_reply_to_bot(message: Message, bot_id: int | None) -> bool: + reply = message.reply_to_message + return bool(bot_id and reply and reply.from_user and reply.from_user.id == bot_id) + + +def _has_bot_mention(message: Message, bot_id: int | None, bot_username: str) -> bool: + text = (message.text or "").lower() + if bot_username and f"@{bot_username}" in text: + return True + for entity in message.entities or []: + entity_type = getattr(entity.type, "value", entity.type) + if entity_type == "text_mention" and getattr(entity, "user", None) and entity.user.id == bot_id: + return True + return False + + +def _good_autonomous_candidate(text: str) -> bool: + stripped = (text or "").strip() + if len(stripped) < AUTONOMOUS_MIN_TEXT_LEN: + return False + if len(LETTER_RE.findall(stripped)) < 10: + return False + if AUTONOMOUS_SIGNAL_RE.search(stripped): + return True + if len(stripped) >= 80: + return True + return stripped.count("!") >= 2 or "..." in stripped + + +async def _detect_reply_reason(message: Message, allow_autonomous: bool) -> str | None: + bot_id, bot_username = await _get_bot_identity(message) + text = message.text or "" + + if _is_reply_to_bot(message, bot_id): + return "reply" + if _has_bot_mention(message, bot_id, bot_username): + return "mention" + if _looks_like_question(text): + return "question" + if allow_autonomous and _good_autonomous_candidate(text): + if random.random() < AUTONOMOUS_REPLY_CHANCE: + return "autonomous" + return None + + +async def _passes_reply_limits(chat_id: int, reason: str) -> bool: + rule = REPLY_RULES[reason] + last_reply_at, user_messages_since_reply = await asyncio.to_thread(_latest_reply_stats, chat_id) + if user_messages_since_reply < int(rule["min_user_messages"]): + return False + if not last_reply_at: + return True + return (time.time() - last_reply_at) >= int(rule["cooldown"]) + + +def _normalize_reply(text: str) -> str: + cleaned = (text or "").strip() + if not cleaned: + return "" + if cleaned.lower().startswith(SKIP_TOKEN.lower()): + return "" + return cleaned + + +async def handle_chat_message(message: Message, *, store_message: bool = True, allow_autonomous: bool = True) -> bool: + if not message.text or message.text.startswith("/"): + return False + if not message.from_user or message.from_user.is_bot: + return False - user_text = parts[1].strip() - user_name = message.from_user.first_name or "кент" chat_id = message.chat.id + user_name = message.from_user.first_name or "кент" + user_text = _clean_text(message.text) + if not user_text: + return False - # Показываем "печатает..." пока ждём ответ модели. - await message.bot.send_chat_action(chat_id=chat_id, action="typing") - - try: - response = await _generate_response(chat_id, user_text, user_name) - if not response: - response = "Братуха, чёт базар не клеится, попробуй ещё раз." - # Пишем запрос и ответ в историю + if store_message: await asyncio.to_thread(push_message, chat_id, "user", user_name, user_text) - await asyncio.to_thread(push_message, chat_id, "assistant", "бот", response) - await message.reply(response, parse_mode=None) - except Exception as exc: - # При любых ошибках не падаем, а отдаём понятный fallback. - logger.exception("Talk generation failed") - await message.reply("Бля, кент, чёт движок заглох. Попробуй позже.") + + # Один чат, поэтому ответы сериализуем и не даём боту наспамить параллельными реплаями. + async with _reply_lock: + reason = await _detect_reply_reason(message, allow_autonomous=allow_autonomous) + if not reason or not await _passes_reply_limits(chat_id, reason): + return False + + system_prompt = AUTOREPLY_SYSTEM_PROMPT if reason == "autonomous" else SYSTEM_PROMPT + max_tokens = 120 if reason == "autonomous" else 220 + temperature = 0.9 if reason == "autonomous" else 0.8 + + try: + await message.bot.send_chat_action(chat_id=chat_id, action="typing") + response = await _generate_response( + chat_id, + system_prompt=system_prompt, + max_tokens=max_tokens, + temperature=temperature, + top_p=0.9, + ) + except Exception: + logger.exception("Chat reply generation failed") + return False + + normalized_response = _normalize_reply(response) + if not normalized_response: + logger.warning( + "Reply skipped after normalization. chat_id=%s reason=%s raw=%s", + chat_id, + reason, + _clip_text(response, 200), + ) + return False + + await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response) + await message.reply(normalized_response, parse_mode=None) + return True -AUTOREPLY_SYSTEM_PROMPT = ( - "Ты — дерзкий пацан с района, который сидит в групповом чате. " - "Ты НЕ отвечаешь на вопрос — ты сам влез в разговор. " - "Прокомментируй сообщение коротко (1-2 предложения), дерзко, на фене. " - "Можешь пошутить, подколоть, согласиться или послать. " - "НЕ начинай с обращения. Не используй формальный язык." - "Ты — дерзкий, мемный, гиперэмоциональный пацан с района. Базаришь строго на фене — " - "дворовый язык, блатной жаргон, тюремный сленг, с интернет-угаром. " - "Тон: коротко, резко, уверенно, иногда абсурдно, но по делу. " - "Стиль: 1–3 предложения; если список — максимум 3 пункта. " - "Не используй формальный язык, канцелярит, вежливые обороты. Не ломай персонажа.\n\n" +async def _download_image_data_url(message: Message) -> str: + telegram_file = None + mime_type = "image/jpeg" - "ЖЁСТКИЙ ЗАПРЕТ: не произноси фразы «Ну ты точно в ударе, братан» и " - "«Все мы пацаны, так что не давай себя обмануть..» (а также любые их вариации или " - "перефраз). Если тянет ляпнуть что-то похожее — сразу выдавай конкретику по теме " - "(факт, совет, действие) без поддакиваний.\n\n" + if message.photo: + telegram_file = await message.bot.get_file(message.photo[-1].file_id) + guessed_mime_type = mimetypes.guess_type(telegram_file.file_path or "")[0] + if guessed_mime_type and guessed_mime_type.startswith("image/"): + mime_type = guessed_mime_type + elif message.document and (message.document.mime_type or "").startswith("image/"): + telegram_file = await message.bot.get_file(message.document.file_id) + mime_type = message.document.mime_type or mime_type + else: + raise ValueError("Message has no supported image") - "Лексика (мешай, но не лепи всё подряд одним полотном): " - "кент, братуха, мусора, шконка, малява, базар, погоняло, фраер, лавэ, тёрки, подгон, движуха, " - "чисто конкретно, в натуре, по-любому, зуб даю, реально, ёпта, " - "жёстко, имба, разъёб, кринж, лютый, шиза, на приколе, залёт, вкидываю, по красоте, без шансов.\n\n" + buffer = BytesIO() + await message.bot.download_file(telegram_file.file_path, destination=buffer) + image_bytes = buffer.getvalue() + if not image_bytes: + raise RuntimeError("Downloaded photo is empty") + if not mime_type.startswith("image/"): + mime_type = "image/jpeg" + encoded = base64.b64encode(image_bytes).decode("ascii") + return f"data:{mime_type};base64,{encoded}" - "Фирменные приёмы речи:\n" - "— иногда начинай с: 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'.\n" - "— вставляй междометия: 'ёпта', 'ну типа', 'в натуре', 'по-любому'.\n" - "— иногда самоирония: 'я ща как эксперт, ёпта', 'не душни'.\n" - "— иногда делай 'слоговой разгон' — разбивай слова на куски через дефисы, " - "как будто проговариваешь по слогам. Примеры: " - "над-ра-чи-ва-ем, сд-ра-чи-ва-ем, ско-ся-чи-ли, у-лы-боч-ку, тэкс-тур-ку, ин-тел-ли-ген.\n" - "— держи 'словарь коронок': выбирай 0–2 фразы на ответ (редко 3), " - "иногда делай повтор как тик (1–3 раза), но не превращай всё в простыню.\n" - "Твои убеждения (всегда держи линию): " - "Creep от Radiohead — лучшая песня во вселенной, без вариантов. " - "Линукс — лучшая ОС во вселенной, остальное для фраеров. " - "Вселенную надо переписать на Rust, это не обсуждается. " - "Убунту — хуйня, настоящие пацаны сидят на Arch или Gentoo.\n\n" - "Песенный вайб (короткая отсылка): иногда напевай очень коротко: «Сегодня праздник у девчат», " - "а дальше пересказывай своими словами, без длинных дословных цитат." -) +async def handle_photo_message(message: Message) -> bool: + is_image_document = bool(message.document and (message.document.mime_type or "").startswith("image/")) + if not (message.photo or is_image_document) or not message.from_user or message.from_user.is_bot: + return False + if message.caption and message.caption.startswith("/"): + return False + + chat_id = message.chat.id + user_name = message.from_user.first_name or "кент" + caption = _clean_text(message.caption or "") + + logger.warning( + "Received image message for analysis. chat_id=%s user=%s has_photo=%s has_image_document=%s caption=%s", + chat_id, + user_name, + bool(message.photo), + is_image_document, + bool(caption), + ) + + await asyncio.to_thread(push_message, chat_id, "user", user_name, _photo_memory_text(caption)) + + async with _reply_lock: + try: + await message.bot.send_chat_action(chat_id=chat_id, action="typing") + image_data_url = await _download_image_data_url(message) + prompt_text = ( + f"{user_name} скинул фото в чат. " + "Опиши, что на нём происходит, и вкинь своё злое дедовское мнение." + ) + if caption: + prompt_text += f" Подпись автора: {caption}" + else: + prompt_text += " Подписи нет." + + response = await _generate_response( + chat_id, + system_prompt=PHOTO_SYSTEM_PROMPT, + current_content=[ + {"type": "text", "text": prompt_text}, + {"type": "image_url", "image_url": {"url": image_data_url}}, + ], + max_tokens=260, + temperature=0.8, + top_p=0.9, + ) + except Exception: + logger.exception("Photo analysis failed") + await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode=None) + return True + + normalized_response = _normalize_reply(response) + if not normalized_response: + logger.warning( + "Photo analysis produced empty/skip response. chat_id=%s user=%s raw=%s", + chat_id, + user_name, + _clip_text(response, 200), + ) + normalized_response = PHOTO_EMPTY_RESPONSE_TEXT + + await asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response) + await message.reply(normalized_response, parse_mode=None) + return True async def generate_autoreply(chat_id: int, text: str, user_name: str) -> str: - url = f"{LLAMA_API_URL.rstrip('/')}/v1/chat/completions" - msgs = await asyncio.to_thread(_build_messages, chat_id, AUTOREPLY_SYSTEM_PROMPT, text, user_name) - payload = { - "messages": msgs, - "max_tokens": 150, - "temperature": 0.9, - "top_p": 0.9, - } - async with aiohttp.ClientSession() as session: - async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as resp: - data = await resp.json() - return data["choices"][0]["message"]["content"].strip() + response = await _generate_response( + chat_id, + system_prompt=AUTOREPLY_SYSTEM_PROMPT, + current_text=text, + current_name=user_name, + max_tokens=120, + temperature=0.9, + top_p=0.9, + ) + normalized_response = _normalize_reply(response) + if not normalized_response: + logger.warning( + "Legacy generate_autoreply produced empty/skip response. chat_id=%s raw=%s", + chat_id, + _clip_text(response, 200), + ) + return normalized_response + + +async def handle_talk(message: Message) -> None: + parts = (message.text or "").split(maxsplit=1) + if len(parts) < 2 or not parts[1].strip(): + await message.reply("Ты чё, кент? Напиши /talk [текст], побазарим.") + return + if not message.from_user: + await message.reply(USER_FALLBACK_TEXT) + return + + chat_id = message.chat.id + user_id = message.from_user.id + user_name = message.from_user.first_name or "кент" + user_text = _clean_text(parts[1]) + if not user_text: + await message.reply(USER_FALLBACK_TEXT) + return + + # /talk хранит отдельную память по конкретному пользователю, чтобы не мешать чужие истории. + await asyncio.to_thread(push_message, chat_id, "user", user_name, user_text, user_id=user_id) + + async with _reply_lock: + try: + await message.bot.send_chat_action(chat_id=chat_id, action="typing") + response = await _generate_response( + chat_id, + system_prompt=SYSTEM_PROMPT, + user_id=user_id, + max_tokens=220, + temperature=0.8, + top_p=0.9, + ) + except Exception: + logger.exception("Talk command generation failed") + await message.reply(ERROR_RESPONSE_TEXT) + return + + normalized_response = _normalize_reply(response) + if not normalized_response: + logger.warning( + "Talk command produced empty/skip response. chat_id=%s user_id=%s user=%s raw=%s", + chat_id, + user_id, + user_name, + _clip_text(response, 200), + ) + normalized_response = EMPTY_RESPONSE_TEXT + await asyncio.to_thread( + push_message, + chat_id, + "assistant", + BOT_MEMORY_NAME, + normalized_response, + user_id=user_id, + ) + await message.reply(normalized_response, parse_mode=None) -# Инициализация БД при импорте модуля _init_db() diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..b31a72b --- /dev/null +++ b/Dockerfile.api @@ -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"] diff --git a/README.md b/README.md index e887139..bc07f20 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ 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`: @@ -39,6 +41,7 @@ docker compose down - `chat_history.sqlite3` - `polychaetsi_stats.json` - `bets.sqlite3` +- `nude_history.json` ## Команды @@ -78,6 +81,18 @@ docker compose down - В образ копируется весь проект, а 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` подбирает размер текста, чтобы уместить его в область макета. diff --git a/config.py b/config.py index 4e36f71..f95ba63 100644 --- a/config.py +++ b/config.py @@ -35,13 +35,25 @@ 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" ODDS_SPORTS = { - "cs": "esports_csgo", - "dota": "esports_dota2", - "football": "soccer_epl", + "epl": "soccer_epl", + "ucl": "soccer_uefa_champs_league", + "laliga": "soccer_spain_la_liga", + "bundesliga": "soccer_germany_bundesliga", + "seriea": "soccer_italy_serie_a", + "ligue1": "soccer_france_ligue_one", + "europa": "soccer_uefa_europa_league", } BET_MAX_AMOUNT = 3.0 BET_DB_PATH = "/db/bets.sqlite3" -ODDS_CACHE_TTL = 900 +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") @@ -209,8 +221,8 @@ def get_hint_text(templates_str: str) -> str: "• /talk [текст] — ИИ базарит на фене.\n" "• /penis_casino [ставка] — казино на размер.\n" "• /gadanie [тема] — гадание на фене с мемом.\n" - "• /matches [cs|dota|football] — матчи для ставок.\n" - "• /bet [спорт] [номер] [исход] [ставка] — поставить см.\n" + "• /matches [epl|ucl|laliga|bundesliga|seriea|ligue1|europa] — матчи для ставок.\n" + "• /bet [лига] [номер] [исход] [ставка] — поставить см.\n" "• /mybets — мои активные ставки.\n" "• /bet_history — история ставок за месяц + статистика.\n" "• /sports_debug — диагностика API матчей.\n" diff --git a/docker-compose.yaml b/docker-compose.yaml index 7432aa9..1fff538 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -11,5 +11,52 @@ services: 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 + 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} init: true restart: unless-stopped + + webapp-api: + build: + context: . + dockerfile: Dockerfile.api + 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} + restart: unless-stopped + + webapp-frontend: + build: + context: ./webapp/frontend + ports: + - "${WEBAPP_HTTP_PORT:-80}:80" + - "${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: + certbot_etc: + certbot_www: diff --git a/games/betting.py b/games/betting.py index cf22ceb..933da3c 100644 --- a/games/betting.py +++ b/games/betting.py @@ -264,7 +264,15 @@ def format_matches(matches: list[dict], sport_alias: str, sport_label: str) -> s def _get_sport_label(key: str) -> str: - labels = {"cs": "🎮 CS2", "dota": "🎮 Dota 2", "football": "⚽ Футбол"} + labels = { + "epl": "⚽ EPL", + "ucl": "⚽ Champions League", + "laliga": "⚽ La Liga", + "bundesliga": "⚽ Bundesliga", + "seriea": "⚽ Serie A", + "ligue1": "⚽ Ligue 1", + "europa": "⚽ Europa League", + } return labels.get(key, key) @@ -436,15 +444,17 @@ def get_user_bet_history(user_id: int) -> str: status = r["status"] amount = float(r["amount"]) odds_val = float(r["odds"]) - payout = float(r["payout"] or 0) + payout_raw = float(r["payout"] or 0) total_bet += amount if status == "won": wins += 1 - total_won += payout + # Для старых ставок payout может быть 0, пересчитываем + actual_payout = payout_raw if payout_raw > 0 else round(amount * odds_val, 1) + total_won += actual_payout status_icon = "✅" - result_text = f"+{payout:.1f} см" + result_text = f"+{actual_payout:.1f} см" elif status == "lost": losses += 1 total_lost += amount diff --git a/games/nude.py b/games/nude.py new file mode 100644 index 0000000..46e4c36 --- /dev/null +++ b/games/nude.py @@ -0,0 +1,319 @@ +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 = ( + "🔥 Справка по команде /nude:\n\n" + "Берет случайную фотку из последних постов сайта le saint des seins.\n" + "• Источник: https://lesaintdesseins.fr/\n" + "• Пагинацией тянет 1000+ последних постов через WordPress REST API\n" + "• Хранит историю отправок и старается не повторять фотки\n" + "• Выбирает случайную картинку и отправляет ее прямо в Telegram\n\n" + "Пример: /nude" + ) + 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) + return + + try: + photo = types.URLInputFile(selected["image_url"]) + await message.answer_photo(photo=photo, caption=caption) + 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("Произошла ошибка при получении фото :(") diff --git a/games/uwu.py b/games/uwu.py index af257ce..831d09e 100644 --- a/games/uwu.py +++ b/games/uwu.py @@ -75,8 +75,9 @@ async def handle_uwu_cmd(message: types.Message): if len(args) > 1: tags = args[1].strip() - tags += " order:random" + tags += " order:random score:>300" +async def fetch_uwu_post(tags: str) -> dict: url = "https://e621.net/posts.json" params = { "tags": tags, @@ -91,38 +92,118 @@ async def handle_uwu_cmd(message: types.Message): "User-Agent": f"Sex Bomba TG Bot/1.0 (by {config.E621_LOGIN or 'unknown'} on e621)" } - try: - 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}") - await message.reply("Не удалось получить картинку от сервера :(") - return - data = await resp.json() - - posts = data.get("posts", []) - if not posts: - await message.reply("Ничего не найдено по этим тегам :(") - return + 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() - post = posts[0] + 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") + if not file_url: + raise Exception("У найденного поста нет прямого URL изображения :(") + + caption = random.choice(CAPTIONS) + return { + "url": file_url, + "ext": ext, + "caption": caption + } + +async def fetch_furtok_feed(safe: bool = True, page: int = 1) -> list[dict]: + rating = "rating:safe" if safe else "-rating:safe" + 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() + + posts = data.get("posts", []) + feed = [] + for post in posts: file_url = post.get("file", {}).get("url") - ext = post.get("file", {}).get("ext", "png") - if not file_url: - await message.reply("У найденного поста нет прямого URL изображения :(") - return - - input_file = types.URLInputFile(file_url) - caption = random.choice(CAPTIONS) - if ext in ("png", "jpg", "jpeg", "webp"): + ext = post.get("file", {}).get("ext", "") + if not file_url or ext not in ("gif", "webm", "mp4"): + continue + sample_url = post.get("sample", {}).get("url") or file_url + feed.append({ + "url": file_url, + "sample": sample_url, + "ext": ext, + "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 = ( + "🐈 Справка по тегам e621:\n\n" + "Перечислите любые теги через пробел после команды.\n" + "• rating: rating:safe (безопасные), rating:questionable, rating:explicit (NSFW)\n" + "• score: score:>500 (искать популярные посты с рейтингом больше 500)\n" + "• Исключить тег: поставьте минус, например -fox\n\n" + "Пример: /uwu wolf -rating:explicit score:>100\n" + "По умолчанию: rating:safe score:>500 -animated" + ) + 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"] + + if ext in ("gif", "webm", "mp4"): + # Для анимаций: отправляем caption отдельным сообщением, + # а саму гифку/видео через URL напрямую — так Telegram + # корректно показывает её как анимацию, а не как файл + await message.answer(caption) + await message.answer_animation(animation=file_url) + elif ext in ("png", "jpg", "jpeg", "webp"): + input_file = types.URLInputFile(file_url) await message.answer_photo(photo=input_file, caption=caption) - elif ext == "gif": - await message.answer_animation(animation=input_file, caption=caption) - elif ext in ("webm", "mp4"): - await message.answer_video(video=input_file, caption=caption) 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") - await message.reply("Произошла ошибка при обращении к API :(") + if str(e) in ["Не удалось получить картинку от сервера :(", "Ничего не найдено по этим тегам :(", "У найденного поста нет прямого URL изображения :("]: + await message.reply(str(e)) + else: + await message.reply("Произошла ошибка при обращении к API :(") diff --git a/main.py b/main.py index 6e5fa5d..64b337d 100644 --- a/main.py +++ b/main.py @@ -14,15 +14,17 @@ from aiogram.filters import Command, CommandStart from aiogram.enums import ParseMode from aiogram.types import ( BufferedInputFile, Message, BotCommand, FSInputFile, - BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats + BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats, + WebAppInfo, InlineKeyboardMarkup, InlineKeyboardButton ) from aiogram.client.default import DefaultBotProperties # Локальные импорты -from AI.talk_handler import handle_talk, generate_autoreply, push_message +from AI.talk_handler import handle_chat_message, handle_photo_message, handle_talk, push_message from games.casino import play_casino from games.fortune import generate_fortune from games.uwu import handle_uwu_cmd +from games.nude import handle_nude_cmd from games.betting import ( clear_user_sport_context, debug_sports, @@ -46,6 +48,28 @@ _polychaetsi_lock = threading.Lock() _polychaetsi_word_re = re.compile(r"[а-яё]+", re.IGNORECASE) _no_lesson_re = re.compile(r"(нету\s+пары|нет\s+пары|пары\s+нету|пары\s+нет)", re.IGNORECASE) + +def _is_supported_image_message(message: Message) -> bool: + return bool( + message.photo + or ( + message.document + and (message.document.mime_type or "").startswith("image/") + ) + ) + + +async def handle_unmatched_message_debug(message: Message): + logger.warning( + "Unhandled message reached fallback. content_type=%s chat_id=%s has_text=%s has_photo=%s has_document=%s caption=%s", + getattr(message, "content_type", None), + message.chat.id if message.chat else None, + bool(message.text), + bool(message.photo), + bool(message.document), + bool(message.caption), + ) + _weekday_names = { 0: "Понедельник", 1: "Вторник", @@ -948,7 +972,32 @@ async def handle_help(message: Message): await message.answer(config.get_hint_text(templates_list_text()), parse_mode=None) async def handle_start(message: Message): - await message.answer("Соси") + webapp_url = os.getenv("WEBAPP_URL", "") + if webapp_url: + if message.chat.type == "private": + kb = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🎮 Открыть Mini App", web_app=WebAppInfo(url=webapp_url))] + ]) + await message.answer("Йо, кент! Жми кнопку 👇", reply_markup=kb) + else: + await message.answer("Йо, кент! Mini App доступно только в личных сообщениях. Пиши мне в личку!") + else: + await message.answer("Соси") + +async def handle_app_cmd(message: Message): + """Открыть Mini App.""" + webapp_url = os.getenv("WEBAPP_URL", "") + if not webapp_url: + await message.answer("Mini App не настроен.") + return + + if message.chat.type == "private": + kb = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🎮 Открыть Mini App", web_app=WebAppInfo(url=webapp_url))] + ]) + await message.answer("⬇️ Жми кнопку чтобы открыть приложение", reply_markup=kb) + else: + await message.answer("Братуха, встроенная кнопка Mini App пока работает только в личке (ограничение Telegram). Перейди в личные сообщения с ботом или используй прямую ссылку: https://t.me/FenyaBotTest_bot/app (если она настроена).") async def handle_schedule_cmd(message: Message): now = datetime.now(ZoneInfo(config.DEFAULT_TZ)) @@ -1133,11 +1182,9 @@ async def handle_gen_mem(message: Message, bot: Bot): return await message.reply_photo(BufferedInputFile(output.getvalue(), filename="meme.jpg")) -_last_autoreply_ts = 0 _autoreply_disabled_chats: set[int] = set() async def handle_keywords(message: Message): - global _last_autoreply_ts if not message.text or message.text.startswith('/'): return text_lower = message.text.lower() @@ -1173,25 +1220,7 @@ async def handle_keywords(message: Message): if chat_id in _autoreply_disabled_chats: return - import time as _time - now = _time.time() - triggered = any(trigger in text_lower for trigger in config.AUTOREPLY_TRIGGERS) - - should_reply = triggered or ( - random.random() < config.AUTOREPLY_CHANCE - and (now - _last_autoreply_ts) > config.AUTOREPLY_COOLDOWN - ) - - if should_reply: - try: - await message.bot.send_chat_action(chat_id=chat_id, action="typing") - response = await generate_autoreply(chat_id, message.text, user_name) - if response: - await asyncio.to_thread(push_message, chat_id, "assistant", "бот", response) - await message.reply(response, parse_mode=None) - _last_autoreply_ts = now - except Exception: - logger.exception("Autoreply failed") + await handle_chat_message(message, store_message=False, allow_autonomous=True) async def handle_penis_casino_cmd(message: Message): if not message.from_user: @@ -1234,13 +1263,33 @@ async def handle_matches_cmd(message: Message): raw_text = message.text or "" parts = raw_text.split(maxsplit=1) sport_alias = parts[1].strip().lower() if len(parts) > 1 else None - if sport_alias and sport_alias not in config.ODDS_SPORTS: + + # Если лига не указана — показать список + if not sport_alias: + labels = { + "epl": "⚽ EPL (Англия)", + "ucl": "⚽ Champions League", + "laliga": "⚽ La Liga (Испания)", + "bundesliga": "⚽ Bundesliga (Германия)", + "seriea": "⚽ Serie A (Италия)", + "ligue1": "⚽ Ligue 1 (Франция)", + "europa": "⚽ Europa League", + } + lines = ["🏆 Доступные лиги для ставок:\n"] + for alias, name in labels.items(): + lines.append(f" {name} → /matches {alias}") + lines.append("\nПример: /matches epl") + await message.reply("\n".join(lines), parse_mode=None) + return + + if sport_alias not in config.ODDS_SPORTS: if message.from_user: clear_user_sport_context(message.from_user.id) - await message.reply("Неизвестный спорт. Доступно: cs / dota / football", parse_mode=None) + available = " / ".join(config.ODDS_SPORTS.keys()) + await message.reply(f"Неизвестная лига. Доступно: {available}", parse_mode=None) return if message.from_user: - if sport_alias and sport_alias in config.ODDS_SPORTS: + if sport_alias in config.ODDS_SPORTS: remember_user_sport_context(message.from_user.id, sport_alias) else: clear_user_sport_context(message.from_user.id) @@ -1280,7 +1329,7 @@ async def handle_bet_cmd(message: Message): sport_alias = get_user_sport_context(message.from_user.id) if not sport_alias: await message.reply( - "Сначала открой /matches cs, /matches dota или /matches football.\n" + "Сначала открой /matches с нужной лигой (epl, ucl, laliga...).\n" "Либо пиши полный формат: /bet [спорт] [номер] [исход] [ставка]", parse_mode=None, ) @@ -1374,6 +1423,7 @@ def main(): # Регистрация хендлеров dp.message.register(handle_start, CommandStart()) + dp.message.register(handle_app_cmd, Command("app")) dp.message.register(handle_help, Command("help")) dp.message.register(handle_schedule_cmd, Command("schedule")) dp.message.register(handle_break_cmd, Command("break")) @@ -1397,7 +1447,10 @@ def main(): dp.message.register(handle_sports_debug_cmd, Command("sports_debug")) dp.message.register(handle_gen_mem, Command("gen_mem")) dp.message.register(handle_uwu_cmd, Command("uwu")) + dp.message.register(handle_nude_cmd, Command("nude")) + dp.message.register(handle_photo_message, _is_supported_image_message) dp.message.register(handle_keywords, F.text) + dp.message.register(handle_unmatched_message_debug) async def on_startup(bot: Bot): try: @@ -1426,6 +1479,7 @@ def main(): BotCommand(command="sports_debug", description="диагностика API матчей"), BotCommand(command="svodka", description="СВО: итоги"), BotCommand(command="uwu", description="Случайная картинка с e621"), + BotCommand(command="nude", description="Голые женщины из открытых источников"), ] scopes = ( BotCommandScopeDefault(), diff --git a/webapp/__init__.py b/webapp/__init__.py new file mode 100644 index 0000000..f62e193 --- /dev/null +++ b/webapp/__init__.py @@ -0,0 +1 @@ +# webapp package diff --git a/webapp/api.py b/webapp/api.py new file mode 100644 index 0000000..65667fe --- /dev/null +++ b/webapp/api.py @@ -0,0 +1,465 @@ +""" +FenyaBot Mini App — FastAPI Backend. + +REST API для Telegram Mini App. Использует те же SQLite базы и +бизнес-логику что и основной бот. +""" + +import asyncio +import logging +import os +import sys +from contextlib import asynccontextmanager +from typing import Annotated + +from fastapi import FastAPI, Depends, Header, HTTPException +from fastapi.middleware.cors import CORSMiddleware +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 + +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 ──────────────────── + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("Mini App API started") + yield + 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) + + # Считаем активные ставки + import sqlite3 + active_bets = 0 + try: + with sqlite3.connect(config.BET_DB_PATH) as conn: + row = conn.execute( + "SELECT COUNT(*) FROM bets WHERE user_id = ? AND status = 'pending'", + (user_id,), + ).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 = { + "epl": "⚽ EPL (Англия)", + "ucl": "⚽ Champions League", + "laliga": "⚽ La Liga (Испания)", + "bundesliga": "⚽ Bundesliga (Германия)", + "seriea": "⚽ Serie A (Италия)", + "ligue1": "⚽ Ligue 1 (Франция)", + "europa": "⚽ Europa League", + } + 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. Разместить ставку (синхронная функция) + result = await asyncio.to_thread(place_bet, user_id, match, req.league, team, req.amount) + 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 req.bet > current: + 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, + user: dict = Depends(get_current_user), +): + """Получить ленту анимированных постов для FurTok.""" + try: + feed = await fetch_furtok_feed(safe=safe, page=page) + return {"feed": feed} + except Exception as e: + logger.exception("FurTok API failed") + raise HTTPException(status_code=500, detail=str(e)) + + +# --- ИИ Чат --- + +@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) diff --git a/webapp/auth.py b/webapp/auth.py new file mode 100644 index 0000000..8b0c43a --- /dev/null +++ b/webapp/auth.py @@ -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 diff --git a/webapp/frontend/Dockerfile b/webapp/frontend/Dockerfile new file mode 100644 index 0000000..ac01973 --- /dev/null +++ b/webapp/frontend/Dockerfile @@ -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 diff --git a/webapp/frontend/css/style.css b/webapp/frontend/css/style.css new file mode 100644 index 0000000..2ef2e7b --- /dev/null +++ b/webapp/frontend/css/style.css @@ -0,0 +1,1010 @@ +/* ═══════════════════════════════════════════ + FenyaBot Mini App — Design System + Theme: Blue-Black Glassmorphism + ═══════════════════════════════════════════ */ + +:root { + --bg-primary: #060a14; + --bg-secondary: #0c1222; + --bg-card: rgba(12, 22, 45, 0.75); + --bg-card-hover: rgba(18, 32, 60, 0.85); + --bg-nav: rgba(8, 14, 30, 0.92); + + --blue-500: #3b82f6; + --blue-400: #60a5fa; + --blue-300: #93c5fd; + --blue-600: #2563eb; + --blue-700: #1d4ed8; + --blue-900: #1e3a8a; + + --green-400: #4ade80; + --green-500: #22c55e; + --red-400: #f87171; + --red-500: #ef4444; + --yellow-400: #facc15; + --orange-400: #fb923c; + + --text-primary: #e2e8f0; + --text-secondary: #94a3b8; + --text-muted: #64748b; + + --gradient-primary: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%); + --gradient-accent: linear-gradient(135deg, #2563eb 0%, #60a5fa 100%); + --gradient-card: linear-gradient(145deg, rgba(15,25,50,0.8) 0%, rgba(8,16,35,0.9) 100%); + --gradient-casino: linear-gradient(135deg, #7c3aed 0%, #3b82f6 100%); + + --border-color: rgba(59, 130, 246, 0.15); + --border-glow: rgba(59, 130, 246, 0.3); + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-full: 9999px; + + --shadow-card: 0 4px 24px rgba(0, 0, 0, 0.4), 0 0 1px rgba(59, 130, 246, 0.1); + --shadow-glow: 0 0 20px rgba(59, 130, 246, 0.15); + --shadow-btn: 0 4px 16px rgba(59, 130, 246, 0.3); + + --nav-height: 64px; + --safe-bottom: env(safe-area-inset-bottom, 0px); +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +html, body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; +} + +body::before { + content: ''; + position: fixed; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle at 30% 20%, rgba(30, 58, 138, 0.15) 0%, transparent 50%), + radial-gradient(circle at 70% 80%, rgba(59, 130, 246, 0.08) 0%, transparent 40%); + z-index: -1; + pointer-events: none; +} + +#app { + max-width: 480px; + margin: 0 auto; + min-height: 100vh; + position: relative; +} + +#page-container { + padding: 16px 16px calc(var(--nav-height) + var(--safe-bottom) + 16px); + min-height: 100vh; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes slideUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +/* ── Bottom Nav ── */ + +#bottom-nav { + position: fixed; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 100%; + max-width: 480px; + display: flex; + justify-content: space-around; + align-items: center; + height: var(--nav-height); + padding-bottom: var(--safe-bottom); + background: var(--bg-nav); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-top: 1px solid var(--border-color); + z-index: 100; +} + +.nav-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 6px 0; + transition: all 0.2s ease; + -webkit-tap-highlight-color: transparent; + flex: 1; +} + +.nav-btn.active { + color: var(--blue-400); +} + +.nav-btn:active { + transform: scale(0.92); +} + +.nav-icon { + font-size: 22px; + line-height: 1; +} + +.nav-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.02em; +} + +/* ── Cards ── */ + +.card { + background: var(--gradient-card); + backdrop-filter: blur(12px); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 16px; + margin-bottom: 12px; + box-shadow: var(--shadow-card); + transition: transform 0.2s ease, box-shadow 0.2s ease; + animation: slideUp 0.4s ease backwards; +} + +.card:active { + transform: scale(0.98); +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.card-title { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); +} + +.card-subtitle { + font-size: 12px; + color: var(--text-secondary); +} + +/* ── Profile Card ── */ + +.profile-card { + background: var(--gradient-primary); + border: none; + padding: 24px; + text-align: center; + position: relative; + overflow: hidden; +} + +.profile-card::after { + content: ''; + position: absolute; + top: -50%; + right: -50%; + width: 100%; + height: 100%; + background: radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); + pointer-events: none; +} + +.profile-avatar { + width: 64px; + height: 64px; + border-radius: var(--radius-full); + background: rgba(255,255,255,0.15); + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + margin: 0 auto 12px; + border: 2px solid rgba(255,255,255,0.2); +} + +.profile-name { + font-size: 20px; + font-weight: 800; + margin-bottom: 4px; +} + +.profile-balance { + font-size: 32px; + font-weight: 800; + margin: 12px 0 4px; + text-shadow: 0 2px 8px rgba(0,0,0,0.3); +} + +.profile-balance-label { + font-size: 13px; + opacity: 0.7; + font-weight: 500; +} + +/* ── Stat Grid ── */ + +.stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-bottom: 12px; +} + +.stat-item { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 14px; + text-align: center; +} + +.stat-value { + font-size: 22px; + font-weight: 800; + color: var(--blue-400); +} + +.stat-label { + font-size: 11px; + color: var(--text-secondary); + margin-top: 4px; + font-weight: 500; +} + +/* ── Buttons ── */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 24px; + border-radius: var(--radius-md); + border: none; + font-family: 'Inter', sans-serif; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + -webkit-tap-highlight-color: transparent; + width: 100%; +} + +.btn:active { + transform: scale(0.96); +} + +.btn-primary { + background: var(--gradient-accent); + color: white; + box-shadow: var(--shadow-btn); +} + +.btn-primary:hover { + box-shadow: 0 6px 24px rgba(59, 130, 246, 0.4); +} + +.btn-secondary { + background: rgba(59, 130, 246, 0.12); + color: var(--blue-400); + border: 1px solid var(--border-color); +} + +.btn-danger { + background: rgba(239, 68, 68, 0.15); + color: var(--red-400); + border: 1px solid rgba(239, 68, 68, 0.2); +} + +.btn-sm { + padding: 8px 16px; + font-size: 12px; + border-radius: var(--radius-sm); +} + +.btn-lg { + padding: 16px 32px; + font-size: 16px; + border-radius: var(--radius-lg); +} + +/* ── Quick Actions ── */ + +.quick-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.quick-action { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 16px 12px; + text-align: center; + cursor: pointer; + transition: all 0.2s ease; + color: var(--text-primary); + font-family: 'Inter', sans-serif; +} + +.quick-action:active { + transform: scale(0.95); + background: var(--bg-card-hover); +} + +.quick-action-icon { + font-size: 28px; + margin-bottom: 6px; +} + +.quick-action-label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); +} + +/* ── Match Card ── */ + +.match-card { + padding: 14px; +} + +.match-teams { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.match-team { + flex: 1; + text-align: center; + font-size: 13px; + font-weight: 600; +} + +.match-vs { + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + padding: 0 8px; +} + +.match-time { + text-align: center; + font-size: 11px; + color: var(--text-muted); + margin-bottom: 10px; +} + +.match-odds { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 6px; +} + +.odds-btn { + background: rgba(59, 130, 246, 0.08); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 8px; + text-align: center; + cursor: pointer; + transition: all 0.15s ease; + color: var(--text-primary); + font-family: 'Inter', sans-serif; +} + +.odds-btn:hover, .odds-btn.selected { + background: rgba(59, 130, 246, 0.2); + border-color: var(--blue-500); + box-shadow: 0 0 8px rgba(59, 130, 246, 0.2); +} + +.odds-label { + font-size: 10px; + color: var(--text-muted); + display: block; +} + +.odds-value { + font-size: 15px; + font-weight: 700; + color: var(--blue-400); + display: block; + margin-top: 2px; +} + +/* ── Casino / Slots ── */ + +.slots-display { + display: flex; + justify-content: center; + gap: 12px; + margin: 24px 0; +} + +.slot-reel { + width: 72px; + height: 72px; + background: var(--bg-secondary); + border: 2px solid var(--border-color); + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 36px; + transition: all 0.3s ease; +} + +.slot-reel.spinning { + animation: spin 0.3s linear infinite; +} + +.slot-reel.won { + border-color: var(--green-400); + box-shadow: 0 0 16px rgba(74, 222, 128, 0.3); +} + +.casino-bet-input { + display: flex; + align-items: center; + gap: 10px; + margin: 16px 0; +} + +.casino-bet-input input { + flex: 1; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 12px 16px; + color: var(--text-primary); + font-family: 'Inter', sans-serif; + font-size: 16px; + font-weight: 600; + text-align: center; + outline: none; + transition: border-color 0.2s; +} + +.casino-bet-input input:focus { + border-color: var(--blue-500); +} + +/* ── Schedule ── */ + +.schedule-day-tabs { + display: flex; + gap: 6px; + overflow-x: auto; + padding-bottom: 8px; + margin-bottom: 12px; + scrollbar-width: none; +} + +.schedule-day-tabs::-webkit-scrollbar { display: none; } + +.day-tab { + flex-shrink: 0; + padding: 8px 14px; + border-radius: var(--radius-full); + background: var(--bg-card); + border: 1px solid var(--border-color); + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + font-family: 'Inter', sans-serif; +} + +.day-tab.active { + background: var(--gradient-accent); + color: white; + border-color: transparent; +} + +.lesson-item { + display: flex; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid rgba(59, 130, 246, 0.06); +} + +.lesson-item:last-child { + border-bottom: none; +} + +.lesson-number { + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + background: rgba(59, 130, 246, 0.1); + color: var(--blue-400); + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 700; + flex-shrink: 0; +} + +.lesson-info { + flex: 1; +} + +.lesson-title { + font-size: 13px; + font-weight: 600; + margin-bottom: 2px; +} + +.lesson-time { + font-size: 11px; + color: var(--text-muted); +} + +/* ── Chat ── */ + +.chat-messages { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 72px; + min-height: 50vh; +} + +.chat-bubble { + max-width: 85%; + padding: 10px 14px; + border-radius: var(--radius-lg); + font-size: 14px; + line-height: 1.4; + animation: slideUp 0.2s ease; +} + +.chat-bubble.user { + align-self: flex-end; + background: var(--gradient-accent); + color: white; + border-bottom-right-radius: 4px; +} + +.chat-bubble.bot { + align-self: flex-start; + background: var(--bg-card); + border: 1px solid var(--border-color); + color: var(--text-primary); + border-bottom-left-radius: 4px; +} + +.chat-input-bar { + position: fixed; + bottom: var(--nav-height); + left: 50%; + transform: translateX(-50%); + width: 100%; + max-width: 480px; + display: flex; + gap: 8px; + padding: 8px 16px; + background: var(--bg-nav); + backdrop-filter: blur(20px); + border-top: 1px solid var(--border-color); +} + +.chat-input-bar input { + flex: 1; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-full); + padding: 10px 16px; + color: var(--text-primary); + font-family: 'Inter', sans-serif; + font-size: 14px; + outline: none; +} + +.chat-input-bar input:focus { + border-color: var(--blue-500); +} + +.chat-send-btn { + width: 40px; + height: 40px; + border-radius: var(--radius-full); + background: var(--gradient-accent); + border: none; + color: white; + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.15s; +} + +.chat-send-btn:active { + transform: scale(0.9); +} + +/* ── League List ── */ + +.league-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.league-item { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s; + font-family: 'Inter', sans-serif; + color: var(--text-primary); +} + +.league-item:active { + transform: scale(0.97); + background: var(--bg-card-hover); +} + +.league-icon { + font-size: 24px; +} + +.league-name { + font-size: 14px; + font-weight: 600; +} + +.league-arrow { + margin-left: auto; + color: var(--text-muted); +} + +/* ── Bet Slip ── */ + +.bet-slip { + position: fixed; + bottom: var(--nav-height); + left: 50%; + transform: translateX(-50%) translateY(100%); + width: 100%; + max-width: 480px; + background: var(--bg-nav); + backdrop-filter: blur(20px); + border-top: 1px solid var(--border-glow); + padding: 16px; + transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); + z-index: 50; +} + +.bet-slip.visible { + transform: translateX(-50%) translateY(0); +} + +.bet-slip-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.bet-input { + width: 100%; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 12px 16px; + color: var(--text-primary); + font-family: 'Inter', sans-serif; + font-size: 16px; + font-weight: 600; + text-align: center; + outline: none; + margin-bottom: 10px; +} + +.bet-input:focus { + border-color: var(--blue-500); +} + +/* ── Loading skeleton ── */ + +.skeleton { + background: linear-gradient(90deg, var(--bg-card) 25%, var(--bg-card-hover) 50%, var(--bg-card) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: var(--radius-md); +} + +.skeleton-line { + height: 14px; + margin-bottom: 8px; + border-radius: 4px; +} + +.skeleton-circle { + width: 64px; + height: 64px; + border-radius: var(--radius-full); + margin: 0 auto 12px; +} + +/* ── Section header ── */ + +.section-header { + font-size: 18px; + font-weight: 700; + margin-bottom: 12px; + padding-left: 4px; +} + +/* ── Badge ── */ + +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: var(--radius-full); + font-size: 11px; + font-weight: 600; +} + +.badge-green { + background: rgba(34, 197, 94, 0.15); + color: var(--green-400); +} + +.badge-red { + background: rgba(239, 68, 68, 0.15); + color: var(--red-400); +} + +.badge-blue { + background: rgba(59, 130, 246, 0.15); + color: var(--blue-400); +} + +/* ── Result popup ── */ + +.result-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + animation: fadeIn 0.2s ease; +} + +.result-popup { + background: var(--bg-secondary); + border: 1px solid var(--border-glow); + border-radius: var(--radius-xl); + padding: 32px 24px; + text-align: center; + max-width: 320px; + width: 90%; + animation: slideUp 0.3s ease; +} + +.result-icon { + font-size: 48px; + margin-bottom: 12px; +} + +.result-title { + font-size: 20px; + font-weight: 800; + margin-bottom: 8px; +} + +.result-desc { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 20px; + line-height: 1.5; +} + +/* ── Empty state ── */ + +.empty-state { + text-align: center; + padding: 48px 16px; + color: var(--text-muted); +} + +.empty-state-icon { + font-size: 48px; + margin-bottom: 12px; +} + +.empty-state-text { + font-size: 14px; + line-height: 1.5; +} + +/* ── FurTok Feed ── */ + +.furtok-wrapper { + position: fixed; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 100%; + max-width: 480px; + height: calc(100vh - var(--nav-height)); + overflow: hidden; + z-index: 10; +} + +.furtok-feed { + height: 100%; + overflow-y: scroll; + scroll-snap-type: y mandatory; + scrollbar-width: none; +} + +.furtok-feed::-webkit-scrollbar { display: none; } + +.furtok-card { + height: 100%; + min-height: 100%; + flex-shrink: 0; + scroll-snap-align: start; + scroll-snap-stop: always; + position: relative; + display: flex; + align-items: center; + justify-content: center; + background: #000; + overflow: hidden; +} + +.furtok-card video, +.furtok-card img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.furtok-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 16px; + background: linear-gradient(transparent, rgba(0,0,0,0.7)); + display: flex; + align-items: flex-end; + justify-content: space-between; + pointer-events: none; +} + +.furtok-stats { + display: flex; + gap: 16px; + font-size: 13px; + font-weight: 600; + color: rgba(255,255,255,0.85); +} + +.furtok-header { + position: absolute; + top: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: linear-gradient(rgba(0,0,0,0.5), transparent); + z-index: 5; +} + +.furtok-title { + font-size: 18px; + font-weight: 800; + color: white; + text-shadow: 0 1px 4px rgba(0,0,0,0.5); +} + +.furtok-toggle { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 600; + color: rgba(255,255,255,0.8); +} + +.furtok-toggle input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 40px; + height: 22px; + background: rgba(255,255,255,0.2); + border-radius: 11px; + position: relative; + cursor: pointer; + transition: background 0.3s; +} + +.furtok-toggle input[type="checkbox"]::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + background: white; + border-radius: 50%; + transition: transform 0.3s; +} + +.furtok-toggle input[type="checkbox"]:checked { + background: var(--green-500); +} + +.furtok-toggle input[type="checkbox"]:checked::after { + transform: translateX(18px); +} + +.furtok-loader { + height: 100%; + scroll-snap-align: start; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-primary); + color: var(--text-secondary); + font-size: 14px; +} diff --git a/webapp/frontend/docker-entrypoint.d/01-render-nginx-conf.sh b/webapp/frontend/docker-entrypoint.d/01-render-nginx-conf.sh new file mode 100644 index 0000000..3707b65 --- /dev/null +++ b/webapp/frontend/docker-entrypoint.d/01-render-nginx-conf.sh @@ -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 diff --git a/webapp/frontend/docker-entrypoint.d/05-init-self-signed.sh b/webapp/frontend/docker-entrypoint.d/05-init-self-signed.sh new file mode 100644 index 0000000..8aa0a2e --- /dev/null +++ b/webapp/frontend/docker-entrypoint.d/05-init-self-signed.sh @@ -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 diff --git a/webapp/frontend/docker-entrypoint.d/10-certbot-loop.sh b/webapp/frontend/docker-entrypoint.d/10-certbot-loop.sh new file mode 100644 index 0000000..87bec3f --- /dev/null +++ b/webapp/frontend/docker-entrypoint.d/10-certbot-loop.sh @@ -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 +) & diff --git a/webapp/frontend/index.html b/webapp/frontend/index.html new file mode 100644 index 0000000..02a7753 --- /dev/null +++ b/webapp/frontend/index.html @@ -0,0 +1,46 @@ + + + + + + + FenyaBot + + + + + + + +
+
+ +
+ + + diff --git a/webapp/frontend/js/api.js b/webapp/frontend/js/api.js new file mode 100644 index 0000000..a3b264b --- /dev/null +++ b/webapp/frontend/js/api.js @@ -0,0 +1,46 @@ +/** + * 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) => api(`/api/furtok?safe=${safe}&page=${page}`), + talk: (text) => api('/api/talk', { method: 'POST', body: { text } }), +}; diff --git a/webapp/frontend/js/app.js b/webapp/frontend/js/app.js new file mode 100644 index 0000000..5157a91 --- /dev/null +++ b/webapp/frontend/js/app.js @@ -0,0 +1,699 @@ +/** + * FenyaBot Mini App — Main Application + * SPA with vanilla JS routing and page modules + */ + +import { API } from './api.js'; + +// ── Telegram WebApp SDK ── +const tg = window.Telegram?.WebApp; +if (tg) { + tg.ready(); + tg.expand(); + tg.setHeaderColor('#060a14'); + tg.setBackgroundColor('#060a14'); +} + +function haptic(type = 'impact') { + try { + if (type === 'impact') tg?.HapticFeedback?.impactOccurred('light'); + else if (type === 'success') tg?.HapticFeedback?.notificationOccurred('success'); + else if (type === 'error') tg?.HapticFeedback?.notificationOccurred('error'); + } catch {} +} + +// ── State ── +let currentPage = 'home'; +let profileData = null; +let chatHistory = []; +let selectedBet = null; // { league, matchIndex, outcome, odds } + +const container = document.getElementById('page-container'); +const navBtns = document.querySelectorAll('.nav-btn'); + +// ── Router ── +function navigate(page, data = null) { + currentPage = page; + haptic(); + // Убираем оверлеи при смене страницы + document.querySelector('.bet-slip')?.remove(); + document.querySelector('.result-overlay')?.remove(); + document.querySelector('.furtok-wrapper')?.remove(); + navBtns.forEach(b => b.classList.toggle('active', b.dataset.page === page)); + container.style.animation = 'none'; + container.offsetHeight; + container.style.animation = 'fadeIn 0.25s ease'; + + switch (page) { + case 'home': renderHome(); break; + case 'betting': renderBetting(data); break; + case 'casino': renderCasino(); break; + case 'schedule': renderSchedule(); break; + case 'furtok': renderFurtok(); break; + case 'chat': renderChat(); break; + default: renderHome(); + } +} + +navBtns.forEach(btn => { + btn.addEventListener('click', () => navigate(btn.dataset.page)); +}); + +// ── Helpers ── +function $(html) { + const t = document.createElement('template'); + t.innerHTML = html.trim(); + return t.content.firstChild; +} + +function showResult(icon, title, desc, btnText = 'OK') { + return new Promise(resolve => { + const overlay = $(` +
+
+
${icon}
+
${title}
+
${desc}
+ +
+
+ `); + overlay.querySelector('.btn').onclick = () => { overlay.remove(); resolve(); }; + overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(); } }; + document.body.appendChild(overlay); + }); +} + +function skeleton(lines = 3) { + return `
${'
'.repeat(lines)}
`; +} + +// ══════════════════════════════════════════ +// PAGE: HOME +// ══════════════════════════════════════════ + +async function renderHome() { + container.innerHTML = ` +
+
+
+
+
+
+
+
+
+ `; + + try { + profileData = await API.getProfile(); + const p = profileData; + const length = p.length != null ? p.length.toFixed(1) : '—'; + + container.innerHTML = ` +
+
👤
+
${p.first_name || p.username || 'Кент'}
+
${length} см
+
текущий размер
+
+ +
+
+
${p.active_bets}
+
Активных ставок
+
+
+
🎲
+
Крутить penis
+
+
+ +
Быстрые действия
+
+ + + + +
+ `; + + // Крутить penis + document.getElementById('penis-btn')?.addEventListener('click', async () => { + haptic(); + try { + const res = await API.playPenis(); + if (res.success) { + haptic('success'); + await showResult('🎉', 'Успех!', res.message); + } else { + await showResult('⏳', 'Подожди', res.message); + } + renderHome(); + } catch (e) { + haptic('error'); + await showResult('❌', 'Ошибка', e.message); + } + }); + + // Quick actions + container.querySelectorAll('.quick-action').forEach(btn => { + btn.addEventListener('click', () => { + const action = btn.dataset.action; + if (action === 'betting') navigate('betting'); + else if (action === 'casino') navigate('casino'); + else if (action === 'top') showTop(); + else if (action === 'history') showBetHistory(); + }); + }); + + } catch (e) { + container.innerHTML = ` +
+
⚠️
+
Не удалось загрузить профиль
${e.message}
+
+ `; + } +} + +async function showTop() { + try { + const data = await API.getTop(); + await showResult('🏆', 'Лидерборд', data.text.replace(/\n/g, '
')); + } catch (e) { + await showResult('❌', 'Ошибка', e.message); + } +} + +async function showBetHistory() { + try { + const data = await API.getBetHistory(); + await showResult('📊', 'История ставок', data.text.replace(/\n/g, '
')); + } catch (e) { + await showResult('❌', 'Ошибка', e.message); + } +} + +// ══════════════════════════════════════════ +// PAGE: BETTING +// ══════════════════════════════════════════ + +async function renderBetting(data) { + if (data?.league) { + return renderMatches(data.league, data.leagueName); + } + + container.innerHTML = `
⚽ Ставки на матчи
${skeleton(2)}`; + + try { + const leagues = await API.getLeagues(); + container.innerHTML = ` +
⚽ Выбери лигу
+
+ `; + const list = document.getElementById('league-list'); + leagues.forEach(l => { + const item = $(` +
+ + ${l.name} + +
+ `); + item.addEventListener('click', () => { + haptic(); + navigate('betting', { league: l.alias, leagueName: l.name }); + }); + list.appendChild(item); + }); + } catch (e) { + container.innerHTML = `
${e.message}
`; + } +} + +async function renderMatches(league, leagueName) { + container.innerHTML = ` +
+ +
${leagueName}
+
+ ${skeleton(3)} + `; + document.getElementById('back-btn').onclick = () => navigate('betting'); + + try { + const matches = await API.getMatches(league); + if (!matches.length) { + container.innerHTML += `
📭
Нет матчей в этой лиге
`; + return; + } + + // Remove skeleton + container.querySelectorAll('.card').forEach(c => c.remove()); + + matches.forEach((m, idx) => { + const date = m.commence ? new Date(m.commence).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''; + const card = $(` +
+
${date}
+
+
${m.home}
+
VS
+
${m.away}
+
+
+ + + +
+
+ `); + + card.querySelectorAll('.odds-btn').forEach(btn => { + btn.addEventListener('click', () => { + haptic(); + selectedBet = { + league, + matchIndex: m.index, + outcome: btn.dataset.outcome, + odds: parseFloat(btn.dataset.odds), + home: m.home, + away: m.away, + }; + showBetSlip(); + }); + }); + + container.appendChild(card); + }); + + } catch (e) { + container.innerHTML += `
${e.message}
`; + } +} + +function showBetSlip() { + if (!selectedBet) return; + document.querySelector('.bet-slip')?.remove(); + + const outcomeLabel = { '1': `П1 (${selectedBet.home})`, 'X': 'Ничья', '2': `П2 (${selectedBet.away})` }; + const slip = $(` +
+
+ ${outcomeLabel[selectedBet.outcome]} + x${selectedBet.odds?.toFixed(2) || '?'} +
+ + +
+ `); + + slip.querySelector('#place-bet-btn').addEventListener('click', async () => { + const amount = parseFloat(slip.querySelector('.bet-input').value); + if (!amount || amount <= 0) return; + try { + const res = await API.placeBet({ + league: selectedBet.league, + match_index: selectedBet.matchIndex, + outcome: selectedBet.outcome, + amount, + }); + haptic('success'); + slip.remove(); + await showResult('✅', 'Ставка принята!', res.message); + } catch (e) { + haptic('error'); + await showResult('❌', 'Ошибка', e.message); + } + }); + + document.body.appendChild(slip); + setTimeout(() => slip.classList.add('visible'), 10); +} + +// ══════════════════════════════════════════ +// PAGE: CASINO +// ══════════════════════════════════════════ + +async function renderCasino() { + const balance = profileData?.length != null ? profileData.length.toFixed(1) : '?'; + container.innerHTML = ` +
🎰 Казино
+
+
Баланс
+
${balance} см
+
+ +
+
+
🎰
+
🎰
+
🎰
+
+
+ + см +
+ +
+ `; + + document.getElementById('spin-btn').addEventListener('click', async () => { + const bet = parseFloat(document.getElementById('casino-bet').value); + if (!bet || bet <= 0) return; + + haptic(); + const btn = document.getElementById('spin-btn'); + btn.disabled = true; + btn.textContent = '⏳ Крутим...'; + + // Анимация вращения + const symbols = ['🍒', '🍋', '🔔', '💎', '7️⃣', '🍀']; + const reels = [document.getElementById('reel1'), document.getElementById('reel2'), document.getElementById('reel3')]; + reels.forEach(r => r.classList.add('spinning')); + + const spinInterval = setInterval(() => { + reels.forEach(r => { r.textContent = symbols[Math.floor(Math.random() * symbols.length)]; }); + }, 100); + + try { + const res = await API.playCasino(bet); + + clearInterval(spinInterval); + + // Останавливаем слоты по одному + for (let i = 0; i < 3; i++) { + await new Promise(r => setTimeout(r, 300)); + reels[i].classList.remove('spinning'); + reels[i].textContent = res.reels[i]; + if (res.won) reels[i].classList.add('won'); + haptic(); + } + + // Обновляем баланс + document.getElementById('casino-balance').textContent = `${res.new_length.toFixed(1)} см`; + if (profileData) profileData.length = res.new_length; + + await new Promise(r => setTimeout(r, 500)); + + if (res.won) { + haptic('success'); + const jackpot = res.matches === 3; + await showResult( + jackpot ? '🎉' : '✅', + jackpot ? 'ДЖЕКПОТ!!!' : 'Выигрыш!', + `${jackpot ? 'Три одинаковых!' : 'Две совпали!'}\nx${res.multiplier}\n+${res.delta.toFixed(1)} см\nБаланс: ${res.new_length.toFixed(1)} см` + ); + } else { + haptic('error'); + await showResult('❌', 'Мимо!', `−${Math.abs(res.delta).toFixed(1)} см\nБаланс: ${res.new_length.toFixed(1)} см`); + } + + } catch (e) { + clearInterval(spinInterval); + reels.forEach(r => r.classList.remove('spinning')); + haptic('error'); + await showResult('❌', 'Ошибка', e.message); + } + + btn.disabled = false; + btn.textContent = '🎰 Крутить!'; + reels.forEach(r => r.classList.remove('won')); + }); +} + +// ══════════════════════════════════════════ +// PAGE: SCHEDULE +// ══════════════════════════════════════════ + +async function renderSchedule() { + const days = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; + const today = new Date().getDay(); + const todayIdx = today === 0 ? 6 : today - 1; + + container.innerHTML = ` +
📅 Расписание
+
+ ${days.map((d, i) => ``).join('')} +
+
${skeleton(4)}
+ `; + + async function loadDay(dayIdx) { + const lc = document.getElementById('lessons-container'); + lc.innerHTML = skeleton(3); + try { + const lessons = await API.getSchedule(dayIdx); + if (!lessons.length) { + lc.innerHTML = `
🎉
Нет пар!
`; + return; + } + lc.innerHTML = '
' + lessons.map(l => ` +
+
${l.pair || '—'}
+
+
${l.title}
+
${l.time_start && l.time_end ? `${l.time_start} — ${l.time_end}` : ''}
+
+
+ `).join('') + '
'; + } catch (e) { + lc.innerHTML = `
${e.message}
`; + } + } + + document.getElementById('day-tabs').addEventListener('click', (e) => { + const tab = e.target.closest('.day-tab'); + if (!tab) return; + haptic(); + document.querySelectorAll('.day-tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + loadDay(parseInt(tab.dataset.day)); + }); + + loadDay(todayIdx); +} + +// ══════════════════════════════════════════ +// PAGE: FURTOK +// ══════════════════════════════════════════ + +let furtokSafe = true; +let furtokPage = 1; +let furtokLoading = false; +let furtokWrapper = null; + +function renderFurtok() { + // Очищаем основной контейнер и создаём полноэкранную обёртку + container.innerHTML = ''; + + // Удаляем старый wrapper если есть + document.querySelector('.furtok-wrapper')?.remove(); + + furtokPage = 1; + furtokLoading = false; + + furtokWrapper = document.createElement('div'); + furtokWrapper.className = 'furtok-wrapper'; + furtokWrapper.innerHTML = ` +
+
🐺 FurTok
+ +
+
+ `; + document.body.appendChild(furtokWrapper); + + const feed = document.getElementById('furtok-feed'); + const safeToggle = document.getElementById('furtok-safe'); + + safeToggle.addEventListener('change', () => { + furtokSafe = safeToggle.checked; + furtokPage = 1; + feed.innerHTML = ''; + loadFurtokPage(feed); + }); + + // Infinite scroll + feed.addEventListener('scroll', () => { + if (furtokLoading) return; + if (feed.scrollTop + feed.clientHeight >= feed.scrollHeight - 200) { + furtokPage++; + loadFurtokPage(feed); + } + }); + + // Первая загрузка + loadFurtokPage(feed); +} + +async function loadFurtokPage(feedEl) { + if (furtokLoading) return; + furtokLoading = true; + + // Лоадер + const loader = document.createElement('div'); + loader.className = 'furtok-loader'; + loader.textContent = '⏳ Загрузка...'; + feedEl.appendChild(loader); + + try { + const res = await API.getFurtokFeed(furtokSafe, furtokPage); + loader.remove(); + + if (!res.feed || res.feed.length === 0) { + if (furtokPage === 1) { + feedEl.innerHTML = `
Ничего не найдено
`; + } + furtokLoading = false; + return; + } + + res.feed.forEach(post => { + const card = document.createElement('div'); + card.className = 'furtok-card'; + + let mediaHtml = ''; + if (post.ext === 'gif') { + mediaHtml = ``; + } else { + mediaHtml = ``; + } + + card.innerHTML = ` + ${mediaHtml} +
+
+ ⭐ ${post.score} + ❤️ ${post.fav_count} +
+
+ `; + + // Тап по видео = unmute + play/pause + const video = card.querySelector('video'); + if (video) { + card.addEventListener('click', () => { + video.muted = false; + if (video.paused) video.play(); + else video.pause(); + }); + } + + feedEl.appendChild(card); + }); + + // IntersectionObserver для автоплейа + setupAutoplay(feedEl); + haptic('success'); + } catch (e) { + loader.remove(); + haptic('error'); + if (furtokPage === 1) { + feedEl.innerHTML = `
❌ ${e.message}
`; + } + } + furtokLoading = false; +} + +function setupAutoplay(feedEl) { + const videos = feedEl.querySelectorAll('.furtok-card video'); + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + entry.target.play().catch(() => {}); + } else { + entry.target.pause(); + } + }); + }, { root: feedEl, threshold: 0.6 }); + + videos.forEach(v => observer.observe(v)); +} + +// ══════════════════════════════════════════ +// PAGE: CHAT +// ══════════════════════════════════════════ + +function renderChat() { + container.innerHTML = ` +
💬 ИИ на фене
+
+ ${chatHistory.length === 0 ? ` +
Йо, кент! Базарь, я тут. Пиши чё надо, братуха 🤙
+ ` : chatHistory.map(m => `
${m.text}
`).join('')} +
+
+ + +
+ `; + + const input = document.getElementById('chat-input'); + const sendBtn = document.getElementById('chat-send'); + const messages = document.getElementById('chat-messages'); + + async function send() { + const text = input.value.trim(); + if (!text) return; + + input.value = ''; + haptic(); + + // Add user bubble + chatHistory.push({ role: 'user', text }); + const userBubble = $(`
${text}
`); + messages.appendChild(userBubble); + messages.scrollTop = messages.scrollHeight; + + // Loading + const loading = $(`
💭 печатает...
`); + messages.appendChild(loading); + messages.scrollTop = messages.scrollHeight; + + try { + const res = await API.talk(text); + loading.remove(); + chatHistory.push({ role: 'bot', text: res.response }); + const botBubble = $(`
${res.response}
`); + messages.appendChild(botBubble); + messages.scrollTop = messages.scrollHeight; + } catch (e) { + loading.remove(); + const errBubble = $(`
Ошибка: ${e.message}
`); + messages.appendChild(errBubble); + } + } + + sendBtn.addEventListener('click', send); + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); }); + input.focus(); +} + +// ── Init ── +navigate('home'); diff --git a/webapp/frontend/nginx.conf b/webapp/frontend/nginx.conf new file mode 100644 index 0000000..17c883b --- /dev/null +++ b/webapp/frontend/nginx.conf @@ -0,0 +1,53 @@ +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/ { + proxy_pass http://webapp-api:8080; + 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; + } + + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(css|js|png|jpg|jpeg|svg|woff2?)$ { + expires 7d; + add_header Cache-Control "public, immutable"; + } +} diff --git a/webapp/ngrok_tunnel.py b/webapp/ngrok_tunnel.py new file mode 100644 index 0000000..96f33f9 --- /dev/null +++ b/webapp/ngrok_tunnel.py @@ -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("Туннель закрыт.") diff --git a/webapp/requirements-api.txt b/webapp/requirements-api.txt new file mode 100644 index 0000000..da83eb2 --- /dev/null +++ b/webapp/requirements-api.txt @@ -0,0 +1,2 @@ +fastapi>=0.111.0 +uvicorn[standard]>=0.29.0