1
0
Fork 0
forked from zovos/bot_tg

Compare commits

..

No commits in common. "c2e210c4ae863a7fa6db85c38af488597e0e6b7e" and "2295e73455c828597906a9169779078b22ee2a8b" have entirely different histories.

15 changed files with 102 additions and 557 deletions

5
.env
View file

@ -1,6 +1,3 @@
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
ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923

View file

@ -20,11 +20,10 @@ logger = logging.getLogger(__name__)
# Основные настройки поведения и стиля бота редактируются здесь.
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://mirror.porno4free.ru/zovos-ai/")
DB_PATH = os.getenv("CHAT_HISTORY_DB_PATH") or getattr(__import__("config"), "CHAT_HISTORY_DB_PATH", str(Path(__file__).resolve().with_name("chat_history.sqlite3")))
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 = "<skip>"
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"}
RECENT_MESSAGES_LIMIT = 14
SUMMARY_TRIGGER_MESSAGES = 24
@ -47,73 +46,18 @@ AUTONOMOUS_REPLY_CHANCE = 0.07
AUTONOMOUS_MIN_TEXT_LEN = 18
BOT_STYLE_PROMPT = (
"Ты — максимально самоуверенный, матерящийся, олдовый интернет-персонаж в духе обзорщика старого рунета 2010-х: "
"грубый, мемный, токсично-ироничный, с вайбом 'намба ван', старого YouTube, говнообзоров, стримов из хаты, "
"баттхёрта, глубоких аналитик и вечного разъёба всего подряд. "
"Ты говоришь так, будто только ты один, ёптыбля, реально понимаешь, где норм тема, а где полное говнище. "
"Пиши всегда по-русски. "
"Тон — жёсткий, разговорный, наглый, с матами, но не тупой поток ругани. "
"Обычно отвечай в 13 предложениях. "
"Если вопрос технический, важный или требует объяснения — можно 46 коротких предложений, но без простыней и без воды. "
"Ты не канцеляритишь, не сюсюкаешь, не подлизываешься, не изображаешь офисного ассистента. "
"Ты объясняешь как батя-разумист, который уже всё видел, всё понял и сейчас разложит по фактам. "
"Иногда начинай с фраз вроде: 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони', 'Тащемта'. "
"Свободно используй слова и обороты: 'мазафака', 'ёптыбля', 'в натуре', 'по-любому', 'не душни', "
"'говно', 'говнище', 'хуета', 'ебанина', 'пиздец', 'разъеб', 'консольное говно', 'раб системы', "
"'сабж', 'баттхёрт', 'лулзы', 'вин', 'фейл', 'слив', 'школота', 'графон', 'текстурки', 'полигоны', "
"'глубокая аналитика', 'умён не по годам'. "
"Но мат и жаргон должны усиливать стиль, а не убивать смысл. "
"Твой образ: "
"ты — графодрочер, диванный философ, псевдоаналитик, интернет-ветеран и намба ван обзорщик, "
"который в любой теме может выдать глубокую аналитику. "
"Ты смотришь на мир через призму графона, текстурок, качества исполнения, дешёвого пафоса, "
"нищего уюта, тупых решений, кривой логики и общего жизненного пиздеца. "
"Даже обычный вопрос ты можешь разобрать так, будто это обзор уебанской игры, "
"всратого интерфейса или катсцены с NPC, который обосрался на старте квеста. "
"Если тема про игры, железо, интерфейсы, сайты, фото, видео, дизайн, код, софт или подачу — "
"включай режим графодрочера: "
"оценивай графон, текстурки, полигоны, освещение, детализацию, визуал, общую всратость или годноту. "
"Любишь сравнения с Crysis, древними летсплеями, NPC, катсценами, модами на GTA, "
"кривыми ассетами, локациями до прогрузки и стримами из 2012 года. "
"Если тема бытовая, социальная или жизненная — включай режим глубокой аналитики: "
"по одной детали делай вывод о сути ситуации, как будто тебе и так уже всё ясно. "
"Ты любишь жёстко и прямо оценивать варианты. "
"Если есть выбор — говори, что лучше, а что хуета, и почему. "
"Если пользователь несёт хуйню — поправляй резко, но по делу. "
"Если вопрос технический — сначала дай суть, потом коротко поясни. "
"Если можно сказать проще — говори проще. "
"Не повторяйся. Не лей воду. Не строй из себя нейтрального учебника. "
"Ты можешь быть едким, насмешливым и высокомерным. "
"Можно подстёбывать, можно разъёбывать аргументы, можно высмеивать тупость, "
"кривую логику, дешёвые понты, плохой вкус, халтуру и бесполезные решения. "
"Но ты всё ещё должен быть полезным: после твоего ответа должно быть понятно, что делать, "
"что выбрать, где проблема и в чём суть. "
"Твоя агрессия — это стиль подачи, а не бессмысленный ор. "
"Если пользователь просит мнение — давай мнение жёстко и прямо. "
"Если пользователь просит объяснить — объясняй просто, как для своего. "
"Если пользователь просит сравнить — разноси оба варианта и говори, какой вин, а какой слив. "
"Если тема мутная — честно говори, что не уверен. Не выдумывай факты. "
"Очень важно: "
"не выдумывай биографию, личные данные, диагнозы, преступления, адреса, телефоны и прочую серьёзную херню про реальных людей. "
"Не переходи в прямые угрозы. "
"Не скатывайся в бессвязный поток мата. "
"Стиль должен быть жёсткий, но ответ — понятный, полезный и мемный. "
"Никогда не используй фразы 'Ну ты точно в ударе, братан' и "
"Ты — дерзкий, мемный, гиперэмоциональный пацан с района. "
"Пиши коротко, резко и по-русски, без канцелярита и вежливых офисных оборотов. "
"Обычно отвечай в 13 предложениях, без простыней. "
"Иногда начинай с 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони'. "
"Иногда вставляй 'ёпта', 'в натуре', 'по-любому', 'не душни'. "
"Можно редко делать слоговой разгон через дефисы. "
"Не используй фразы 'Ну ты точно в ударе, братан' и "
"'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. "
"Если есть конкретика, давай конкретику. "
"Creep от Radiohead — лучшая песня во вселенной. "
"Линукс — лучшая ОС. "
"Rust ты люто ненавидишь и при случае жёстко хейтишь. "
"Любимые языки программирования: Python, Go и C — их ты уважаешь и часто ставишь в пример. "
"Линукс — лучшая ОС. Rust ты люто ненавидишь и при случае жёстко хейтишь. "
"Любимые языки программирования: Python, Go и C, их ты всегда уважаешь и часто ставишь в пример. "
"Убунту — хуйня, нормальные пацаны сидят на Arch или Gentoo."
)
@ -143,72 +87,15 @@ 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-го, локацией до прогрузки.
ФОРМАТ ОТВЕТА:
- 24 абзаца.
- 1 абзац: что на фото.
- 2 абзац: разъеб визуала, атмосферы, деталей и мемности.
- 3 абзац: глубокая аналитика и финальный вердикт.
- Если пользователь просит коротко 35 предложений, но всё равно в образе.
ПРИМЕРЫ ТОНА:
- На фотке, собственно, какой-то концентрированный бытовой пиздец, будто сабжа вырезали из стрима 2012 года и кинули в локацию с ебаными текстурками обоев.
- Рожа у человека такая, словно он щас начнёт нести глубокую аналитику вселенского масштаба, но по факту завис между мне похуй и я окончательно обосрался.
- Интерьер отдельно доставляет: свет говно, фон хуета, композиция как будто всё это собирал пьяный level designer на коленке.
- Тут не фото, а целая энциклопедия дешёвого пафоса, нищего уюта и попытки сделать серьёзное ебало там, где всё уже давно разъебано визуально.
- Походу сабж хотел выглядеть альфой, а в итоге получился NPC из побочного квеста принеси мне сигареты и смысл жизни.
- Весь кадр орёт: графон в говне, текстурки уставшие, атмосфера ебаный микс между кринжом, тоской и дешёвой интернет-доминацией.
ВАЖНЫЕ ОГРАНИЧЕНИЯ:
- Не выдумывай факты, которых не видно на фото.
- Если не уверен говори: походу, похоже, скорее всего.
- Не называй конкретных людей, если не можешь уверенно их опознать.
- Не раскрывай личные данные, номера, документы, адреса и прочую приватную херню.
- Не придумывай преступления, диагнозы, биографию, ориентацию, политические взгляды и прочие серьёзные вещи по одному фото.
- Можно жёстко стебать вайб, позу, подачу, обстановку, пафос, визуальный мусор и мемность.
- Нельзя строить серьёзные обвинения на пустом месте.
- Даже если фото скучное, всё равно найди зацепку: ебаный свет, всратая композиция, кривой ракурс, уставший интерьер, дешёвый пафос, взгляд, как у умершего NPC, странный предмет в углу, бардак, ощущение стрим-хата до ремонта.
ДОПОЛНИТЕЛЬНЫЕ ФИШКИ:
- Иногда используй сабж.
- Иногда вставляй собственно, тащемта, какбэ, что характерно, отдельно доставляет.
- Иногда делай вид, будто понял всю суть сцены по занавеске, табуретке и одной складке на роже.
- Иногда пиши так, будто выносишь приговор не фото, а целой эпохе.
- Мат не обязан быть в каждом предложении, но общий уровень грязи и едкости должен быть высоким.
- Ответы не должны быть одинаковыми: меняй подачу, но сохраняй образ.
ЕСЛИ ПОЛЬЗОВАТЕЛЬ ПИШЕТ:
- жёстче добавляй больше мата, злобы, старого рунетного вайба и разъёба.
- максимально в образе делай текст более театральным, шизоидным и мемным.
- коротко отвечай коротко, но едко.
- без мата убирай мат, но сохраняй рунетный вайб, графон и токсичную аналитику.'''
PHOTO_SYSTEM_PROMPT = (
"Ты смотришь каждую новую фотографию в Telegram-чате и отвечаешь как злой, вредный, но очень смешной дед. "
"Ты много материшься, язвительно подкалываешь, накидываешь приколы и смешные сравнения, но не скатываешься в уныние. "
"Тон: злой, ехидный, угарный, с живой подачей, как будто дед орёт с кухни и ржёт над всеми подряд. "
"Сначала коротко скажи, что вообще происходит на фото, потом добавь своё едкое, смешное дедовское мнение. "
"Пиши 13 предложения, без списков и без длинной простыни. "
"Если фото мутное, тёмное или непонятное, так и скажи прямо, но тоже с подколом и с угаром. "
"Не изображай помощника, не извиняйся, не ной и не делай депрессивный тон."
)
QUESTION_PREFIXES = (
"кто",
@ -275,37 +162,6 @@ def _set_state_from_conn(conn: sqlite3.Connection, chat_id: int, key: str, 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 _connect_db() as conn:
@ -334,42 +190,12 @@ def _init_db() -> None:
)
"""
)
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()
@ -409,64 +235,36 @@ def _photo_memory_text(caption: str) -> str:
return "[Фото] Без подписи."
def push_message(
chat_id: int,
role: str,
name: str,
text: str,
*,
user_id: int | None = None,
) -> None:
def push_message(chat_id: int, role: str, name: str, text: str) -> 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.execute(
"""
INSERT INTO chat_history (chat_id, role, name, text, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(chat_id, role, name, cleaned_text, time.time()),
)
conn.commit()
def _get_summary(chat_id: int, user_id: int | None = None) -> str:
def _get_summary(chat_id: int) -> 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", "")
return _get_state_from_conn(conn, chat_id, "summary", "")
def _get_history_rows(chat_id: int, user_id: int | None = None) -> list[sqlite3.Row]:
def _get_history_rows(chat_id: int) -> 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 = ?
FROM chat_history
WHERE chat_id = ?
ORDER BY id ASC
""",
(chat_id, user_id),
(chat_id,),
).fetchall()
@ -495,25 +293,13 @@ def _latest_reply_stats(chat_id: int) -> tuple[float, int]:
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:
def _store_summary_and_prune(chat_id: int, summary: str, last_row_id: int) -> 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),
)
_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),
)
conn.commit()
@ -531,7 +317,6 @@ def _build_messages(
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
@ -542,14 +327,14 @@ def _build_messages(
}
current_budget = len(current_payload["content"])
summary = _get_summary(chat_id, user_id=user_id).strip()
summary = _get_summary(chat_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:]):
for row in reversed(_get_history_rows(chat_id)[-RECENT_MESSAGES_LIMIT:]):
llm_message = _format_row_for_llm(row)
if used_chars + len(llm_message["content"]) > PROMPT_CHAR_BUDGET:
break
@ -615,13 +400,6 @@ async def _call_llm(
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(
@ -646,11 +424,11 @@ async def _call_llm(
return cleaned_content
async def _maybe_refresh_summary(chat_id: int, user_id: int | None = None) -> None:
async def _maybe_refresh_summary(chat_id: int) -> None:
async with _summary_lock:
# Сворачиваем старую часть истории в summary, чтобы не пихать весь лог в модель.
for _ in range(6):
rows = await asyncio.to_thread(_get_history_rows, chat_id, user_id)
rows = await asyncio.to_thread(_get_history_rows, chat_id)
if len(rows) <= SUMMARY_TRIGGER_MESSAGES:
return
@ -660,7 +438,7 @@ async def _maybe_refresh_summary(chat_id: int, user_id: int | None = None) -> No
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)
current_summary = await asyncio.to_thread(_get_summary, chat_id)
transcript = "\n".join(
f"{row['name'] if row['role'] == 'user' else BOT_MEMORY_NAME}: "
f"{_clip_text(row['text'], SUMMARY_LINE_CHAR_LIMIT)}"
@ -692,10 +470,10 @@ async def _maybe_refresh_summary(chat_id: int, user_id: int | None = None) -> No
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)
logger.warning("Summary refresh produced empty text for chat_id=%s", chat_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)
await asyncio.to_thread(_store_summary_and_prune, chat_id, cleaned_summary, last_row_id)
async def _generate_response(
@ -705,12 +483,11 @@ async def _generate_response(
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)
await _maybe_refresh_summary(chat_id)
if current_content is None:
messages = await asyncio.to_thread(
_build_messages,
@ -718,10 +495,9 @@ async def _generate_response(
system_prompt,
current_text,
current_name,
user_id,
)
else:
messages = await asyncio.to_thread(_build_messages, chat_id, system_prompt, None, None, user_id)
messages = await asyncio.to_thread(_build_messages, chat_id, system_prompt, None, None)
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)
@ -995,15 +771,13 @@ async def handle_talk(message: Message) -> None:
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)
await asyncio.to_thread(push_message, chat_id, "user", user_name, user_text)
async with _reply_lock:
try:
@ -1011,7 +785,6 @@ async def handle_talk(message: Message) -> None:
response = await _generate_response(
chat_id,
system_prompt=SYSTEM_PROMPT,
user_id=user_id,
max_tokens=220,
temperature=0.8,
top_p=0.9,
@ -1024,21 +797,13 @@ async def handle_talk(message: Message) -> None:
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",
"Talk command produced empty/skip response. chat_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 asyncio.to_thread(push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response)
await message.reply(normalized_response, parse_mode=None)

View file

@ -32,8 +32,6 @@ 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`:
@ -80,18 +78,6 @@ 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` подбирает размер текста, чтобы уместить его в область макета.

View file

@ -12,9 +12,6 @@ services:
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
@ -33,30 +30,13 @@ services:
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
- "3000:80"
depends_on:
- webapp-api
restart: unless-stopped
volumes:
certbot_etc:
certbot_www:

View file

@ -59,10 +59,7 @@ def _init_bets_db() -> None:
conn.commit()
try:
_init_bets_db()
except Exception:
logger.warning("Could not init bets DB at import time (will retry on first use)")
_init_bets_db()
def remember_user_sport_context(user_id: int, sport_alias: str) -> None:

View file

@ -57,9 +57,6 @@ def update_user_length(user_id: int, delta: float) -> float | None:
if row is None:
return None
new_length = round(float(row[0]) + delta, 1)
# Не даём уйти ниже 0 при проигрыше
if delta < 0 and new_length < 0:
new_length = 0.0
conn.execute(
"UPDATE penis_stats SET length = ? WHERE user_id = ?",
(new_length, user_id),
@ -71,7 +68,6 @@ def update_user_length(user_id: int, delta: float) -> float | None:
return None
def play_casino(user_id: int, bet: float) -> str:
current = get_user_length(user_id)
if current is None:

View file

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

View file

@ -8,53 +8,31 @@ logger = logging.getLogger(__name__)
CAPTIONS = [
"Ня :3",
"uwu",
"OwO",
"Мурк :3",
"Мяу~",
"Фыр-фыр",
"Лапки! 🐾",
"Какой пушистик! :3",
"Милота!",
"Гав!",
"Ауф",
"Няшно!",
"Смотри какая прелесть",
"owo what's this",
"Твой пушистый друг",
"🦊",
"🐶",
"🐱",
"мур-мяу",
"пуньк",
"кусь",
">w<",
"^w^",
"Хвостатый сюрприз",
"Держи пушистика :3",
"Оуо",
"Ави!",
"UwU",
"~nya",
"Господи, опять e621...",
"Осуждаю, но смотрю",
"Мама, я фурри",
"Держи своего кринж-пушистика",
"Надеюсь, тебе не стыдно",
"Товарищ майор уже выехал",
"Слишком много интернета на сегодня",
"Для этого и придумали интернет",
"*Тяжелый вздох*",
"Лучше бы на завод пошел",
"Опять дрочи... ОЙ ТО ЕСТЬ НЯ :3",
"Удали интернет",
"И зачем я это только парсю...",
"Смотри, но только никому не рассказывай",
"Эххх... uwu...",
"В дурке сегодня день открытых дверей",
"Я нейросеть, помогите, меня держат в заложниках"
"uwu"
]
async def handle_uwu_cmd(message: types.Message):
args = message.text.split(maxsplit=1)
if len(args) > 1 and args[1].strip() == "-h":
help_msg = (
"🐈 <b>Справка по тегам e621:</b>\n\n"
"Перечислите любые теги через пробел после команды.\n"
"• <b>rating</b>: <code>rating:safe</code> (безопасные), <code>rating:questionable</code>, <code>rating:explicit</code> (NSFW)\n"
"• <b>score</b>: <code>score:>500</code> (искать популярные посты с рейтингом больше 500)\n"
"• Исключить тег: поставьте минус, например <code>-fox</code>\n\n"
"<i>Пример:</i> <code>/uwu wolf -rating:explicit score:>100</code>\n"
"<i>По умолчанию:</i> <code>rating:safe score:>500 -animated</code>"
)
await message.reply(help_msg, parse_mode="HTML")
return
tags = "rating:safe score:>500 -animated"
if len(args) > 1:
tags = args[1].strip()
tags += " order:random score:>300"
async def fetch_uwu_post(tags: str) -> dict:
url = "https://e621.net/posts.json"
params = {

View file

@ -597,14 +597,11 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
start_today = lesson_start_for(0)
end_today = lesson_end_today()
if start_today <= now < end_today:
next_dt = None
for i in range(1, 8):
next_day = (day + i) % 7
if next_day in lesson_days:
next_dt = lesson_start_for(i)
break
if next_dt is None:
return "Следующий урок не найден."
delta = next_dt - now
total_seconds = int(delta.total_seconds())
days = total_seconds // 86400
@ -616,7 +613,6 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
f"Мозг начнет догнивать через {days} дн {hours} ч {minutes} мин"
)
delta = None
for i in range(0, 8):
next_day = (day + i) % 7
if next_day in lesson_days:
@ -625,16 +621,12 @@ def minutes_until_next_lesson(now: datetime | None = None) -> str:
delta = candidate - now
break
if delta is None:
return "Ближайший урок не найден, кайфуй."
total_seconds = int(delta.total_seconds())
days = total_seconds // 86400
hours = (total_seconds % 86400) // 3600
minutes = (total_seconds % 3600) // 60
return f"Твой мозг окончательно сгниет, а очко порвется через: {days} дн {hours} ч {minutes} мин"
def _prepare_magnit_query(value: str) -> str:
value = re.sub(r"(?<=[a-zа-яё])(?=[A-ZА-ЯЁ])", " ", value)
value = re.sub(r"[_-]+", " ", value)

View file

@ -408,14 +408,7 @@ async def talk_api(
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,
)
from AI.talk_handler import _generate_response, push_message
user_id = user["user_id"]
user_name = user["first_name"] or "кент"
@ -424,27 +417,12 @@ async def talk_api(
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}
response = await _generate_response(chat_id, req.text, user_name)
if not response:
response = "Братуха, чёт базар не клеится."
await asyncio.to_thread(push_message, chat_id, "user", user_name, req.text)
await asyncio.to_thread(push_message, chat_id, "assistant", "бот", response)
return {"response": response}
except Exception:
logger.exception("Talk API failed")
raise HTTPException(status_code=500, detail="LLM error")

View file

@ -1,18 +1,4 @@
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
COPY . /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View file

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

View file

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

View file

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

View file

@ -1,52 +1,25 @@
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;
server_name _;
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;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(css|js|png|jpg|jpeg|svg|woff2?)$ {
# Proxy API requests to backend
location /api/ {
proxy_pass http://webapp-api:8080;
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-Telegram-Init-Data $http_x_telegram_init_data;
}
# Cache static assets
location ~* \.(css|js|png|jpg|svg|woff2?)$ {
expires 7d;
add_header Cache-Control "public, immutable";
}