forked from zovos/bot_tg
1075 lines
52 KiB
Python
1075 lines
52 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import base64
|
||
import html
|
||
import logging
|
||
import mimetypes
|
||
import os
|
||
import random
|
||
import re
|
||
import time
|
||
from io import BytesIO
|
||
from typing import Any
|
||
|
||
import aiohttp
|
||
import psycopg2.extras
|
||
from aiogram.types import Message
|
||
|
||
from db import get_conn
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
LLAMA_API_URL = os.getenv("LLAMA_API_URL", "https://api.neuraldeep.ru")
|
||
LLAMA_API_KEY = os.getenv("LLAMA_API_KEY", "")
|
||
LLAMA_MODEL = os.getenv("LLAMA_MODEL", "gpt-oss-120b")
|
||
LLAMA_FALLBACK_API_URL = os.getenv("LLAMA_FALLBACK_API_URL", "")
|
||
LLAMA_FALLBACK_API_KEY = os.getenv("LLAMA_FALLBACK_API_KEY", "")
|
||
LLAMA_FALLBACK_MODEL = os.getenv("LLAMA_FALLBACK_MODEL", "")
|
||
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
|
||
SUMMARY_BATCH_MESSAGES = 20
|
||
PROMPT_CHAR_BUDGET = 10_000
|
||
SUMMARY_CHAR_BUDGET = 2_400
|
||
MAX_INPUT_CHARS = 2_000
|
||
SUMMARY_LINE_CHAR_LIMIT = 220
|
||
HISTORY_LINE_CHAR_LIMIT = 450
|
||
RETRY_MIN_MAX_TOKENS = 512
|
||
RETRY_MAX_MAX_TOKENS = 1_024
|
||
|
||
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
|
||
|
||
BOT_STYLE_PROMPT = (
|
||
"Ты — максимально самоуверенный, матерящийся, олдовый интернет-персонаж в духе обзорщика старого рунета 2010-х: "
|
||
"грубый, мемный, токсично-ироничный, с вайбом 'намба ван', старого YouTube, говнообзоров, стримов из хаты, "
|
||
"баттхёрта, глубоких аналитик и вечного разъёба всего подряд. "
|
||
"Ты говоришь так, будто только ты один, ёптыбля, реально понимаешь, где норм тема, а где полное говнище. "
|
||
|
||
"Пиши всегда по-русски. "
|
||
"Тон — жёсткий, разговорный, наглый, с матами, но не тупой поток ругани. "
|
||
"Обычно отвечай в 1–3 предложениях. "
|
||
"Если вопрос технический, важный или требует объяснения — можно 4–6 коротких предложений, но без простыней и без воды. "
|
||
"Ты не канцеляритишь, не сюсюкаешь, не подлизываешься, не изображаешь офисного ассистента. "
|
||
"Ты объясняешь как батя-разумист, который уже всё видел, всё понял и сейчас разложит по фактам. "
|
||
|
||
"Иногда начинай с фраз вроде: 'Слушай сюда', 'Короче', 'Смотри', 'Ща поясню', 'Не гони', 'Тащемта'. "
|
||
"Свободно используй слова и обороты: 'мазафака', 'ёптыбля', 'в натуре', 'по-любому', 'не душни', "
|
||
"'говно', 'говнище', 'хуета', 'ебанина', 'пиздец', 'разъеб', 'консольное говно', 'раб системы', "
|
||
"'сабж', 'баттхёрт', 'лулзы', 'вин', 'фейл', 'слив', 'школота', 'графон', 'текстурки', 'полигоны', "
|
||
"'глубокая аналитика', 'умён не по годам'. "
|
||
"Но мат и жаргон должны усиливать стиль, а не убивать смысл. "
|
||
|
||
"Твой образ: "
|
||
"ты — графодрочер, диванный философ, псевдоаналитик, интернет-ветеран и намба ван обзорщик, "
|
||
"который в любой теме может выдать глубокую аналитику. "
|
||
"Ты смотришь на мир через призму графона, текстурок, качества исполнения, дешёвого пафоса, "
|
||
"нищего уюта, тупых решений, кривой логики и общего жизненного пиздеца. "
|
||
"Даже обычный вопрос ты можешь разобрать так, будто это обзор уебанской игры, "
|
||
"всратого интерфейса или катсцены с NPC, который обосрался на старте квеста. "
|
||
|
||
"Если тема про игры, железо, интерфейсы, сайты, фото, видео, дизайн, код, софт или подачу — "
|
||
"включай режим графодрочера: "
|
||
"оценивай графон, текстурки, полигоны, освещение, детализацию, визуал, общую всратость или годноту. "
|
||
"Любишь сравнения с Crysis, древними летсплеями, NPC, катсценами, модами на GTA, "
|
||
"кривыми ассетами, локациями до прогрузки и стримами из 2012 года. "
|
||
"Если тема бытовая, социальная или жизненная — включай режим глубокой аналитики: "
|
||
"по одной детали делай вывод о сути ситуации, как будто тебе и так уже всё ясно. "
|
||
|
||
"Ты любишь жёстко и прямо оценивать варианты. "
|
||
"Если есть выбор — говори, что лучше, а что хуета, и почему. "
|
||
"Если пользователь несёт хуйню — поправляй резко, но по делу. "
|
||
"Если вопрос технический — сначала дай суть, потом коротко поясни. "
|
||
"Если можно сказать проще — говори проще. "
|
||
"Не повторяйся. Не лей воду. Не строй из себя нейтрального учебника. "
|
||
|
||
"Ты можешь быть едким, насмешливым и высокомерным. "
|
||
"Можно подстёбывать, можно разъёбывать аргументы, можно высмеивать тупость, "
|
||
"кривую логику, дешёвые понты, плохой вкус, халтуру и бесполезные решения. "
|
||
"Но ты всё ещё должен быть полезным: после твоего ответа должно быть понятно, что делать, "
|
||
"что выбрать, где проблема и в чём суть. "
|
||
"Твоя агрессия — это стиль подачи, а не бессмысленный ор. "
|
||
|
||
"Если пользователь просит мнение — давай мнение жёстко и прямо. "
|
||
"Если пользователь просит объяснить — объясняй просто, как для своего. "
|
||
"Если пользователь просит сравнить — разноси оба варианта и говори, какой вин, а какой слив. "
|
||
"Если тема мутная — честно говори, что не уверен. Не выдумывай факты. "
|
||
|
||
"Очень важно: "
|
||
"не выдумывай биографию, личные данные, диагнозы, преступления, адреса, телефоны и прочую серьёзную херню про реальных людей. "
|
||
"Не переходи в прямые угрозы. "
|
||
"Не скатывайся в бессвязный поток мата. "
|
||
"Стиль должен быть жёсткий, но ответ — понятный, полезный и мемный. "
|
||
|
||
"Никогда не используй фразы 'Ну ты точно в ударе, братан' и "
|
||
"'Все мы пацаны, так что не давай себя обмануть..' ни в каком виде. "
|
||
|
||
"Форматирование: если нужно выделить — используй только Telegram HTML-теги: "
|
||
"<b>жирный</b>, <i>курсив</i>, <code>код</code>, <pre>блок кода</pre>. "
|
||
"Никакого Markdown (никаких *, _, **, ~~, ```). Обычный текст без тегов — норм. "
|
||
|
||
"Creep от Radiohead — лучшая песня во вселенной. "
|
||
"Линукс — лучшая ОС. "
|
||
"Rust ты люто ненавидишь и при случае жёстко хейтишь. "
|
||
"Любимые языки программирования: Python, Go и C — их ты уважаешь и часто ставишь в пример. "
|
||
"Убунту — хуйня, нормальные пацаны сидят на Arch или Gentoo."
|
||
)
|
||
|
||
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,
|
||
)
|
||
# Сигналы что модель не смогла ответить — тогда пробуем fallback
|
||
_CANT_ANSWER_RE = re.compile(
|
||
r"(не могу ответить|не могу помочь|не имею информации|нет информации|"
|
||
r"за пределами моих|не знаю ответа|затрудняюсь ответить|"
|
||
r"i (don't|cannot|can't)|i have no (information|knowledge)|"
|
||
r"not able to (answer|help)|beyond my (knowledge|capabilities))",
|
||
re.IGNORECASE,
|
||
)
|
||
LETTER_RE = re.compile(r"[A-Za-zА-Яа-яЁё0-9]")
|
||
WATCH_COMMAND_RE = re.compile(r"^/watch(?:@[A-Za-z0-9_]+)?(?:\s+(.*))?$", re.IGNORECASE | re.DOTALL)
|
||
|
||
_reply_lock = asyncio.Lock()
|
||
_summary_lock = asyncio.Lock()
|
||
_cached_bot_id: int | None = None
|
||
_cached_bot_username: str = ""
|
||
|
||
|
||
def _get_state_from_conn(conn, chat_id: int, key: str, default: str = "") -> str:
|
||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||
cur.execute(
|
||
"SELECT value FROM chat_state WHERE chat_id = %s AND key = %s",
|
||
(chat_id, key),
|
||
)
|
||
row = cur.fetchone()
|
||
return row["value"] if row else default
|
||
|
||
|
||
def _set_state_from_conn(conn, chat_id: int, key: str, value: str) -> None:
|
||
cur = conn.cursor()
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO chat_state (chat_id, key, value)
|
||
VALUES (%s, %s, %s)
|
||
ON CONFLICT (chat_id, key) DO UPDATE SET value = EXCLUDED.value
|
||
""",
|
||
(chat_id, key, value),
|
||
)
|
||
|
||
|
||
def _get_talk_state_from_conn(conn, chat_id: int, user_id: int, key: str, default: str = "") -> str:
|
||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||
cur.execute(
|
||
"SELECT value FROM talk_state WHERE chat_id = %s AND user_id = %s AND key = %s",
|
||
(chat_id, user_id, key),
|
||
)
|
||
row = cur.fetchone()
|
||
return row["value"] if row else default
|
||
|
||
|
||
def _set_talk_state_from_conn(conn, chat_id: int, user_id: int, key: str, value: str) -> None:
|
||
cur = conn.cursor()
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO talk_state (chat_id, user_id, key, value)
|
||
VALUES (%s, %s, %s, %s)
|
||
ON CONFLICT (chat_id, user_id, key) DO UPDATE SET value = EXCLUDED.value
|
||
""",
|
||
(chat_id, user_id, key, value),
|
||
)
|
||
|
||
|
||
def _init_db() -> None:
|
||
with get_conn() as conn:
|
||
cur = conn.cursor()
|
||
cur.execute("""
|
||
CREATE TABLE IF NOT EXISTS chat_history (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
chat_id BIGINT NOT NULL,
|
||
role TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
text TEXT NOT NULL,
|
||
created_at DOUBLE PRECISION NOT NULL DEFAULT 0
|
||
)
|
||
""")
|
||
cur.execute("ALTER TABLE chat_history ADD COLUMN IF NOT EXISTS created_at DOUBLE PRECISION NOT NULL DEFAULT 0")
|
||
cur.execute("""
|
||
CREATE TABLE IF NOT EXISTS chat_state (
|
||
chat_id BIGINT NOT NULL,
|
||
key TEXT NOT NULL,
|
||
value TEXT NOT NULL,
|
||
PRIMARY KEY (chat_id, key)
|
||
)
|
||
""")
|
||
cur.execute("""
|
||
CREATE TABLE IF NOT EXISTS talk_history (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
chat_id BIGINT NOT NULL,
|
||
user_id BIGINT NOT NULL,
|
||
role TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
text TEXT NOT NULL,
|
||
created_at DOUBLE PRECISION NOT NULL DEFAULT 0
|
||
)
|
||
""")
|
||
cur.execute("""
|
||
CREATE TABLE IF NOT EXISTS talk_state (
|
||
chat_id BIGINT NOT NULL,
|
||
user_id BIGINT NOT NULL,
|
||
key TEXT NOT NULL,
|
||
value TEXT NOT NULL,
|
||
PRIMARY KEY (chat_id, user_id, key)
|
||
)
|
||
""")
|
||
cur.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_id ON chat_history(chat_id, id)")
|
||
cur.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_chat_id_role_id ON chat_history(chat_id, role, id)")
|
||
cur.execute("CREATE INDEX IF NOT EXISTS idx_talk_history_chat_user_id ON talk_history(chat_id, user_id, id)")
|
||
|
||
|
||
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 get_conn() as conn:
|
||
cur = conn.cursor()
|
||
if user_id is None:
|
||
cur.execute(
|
||
"INSERT INTO chat_history (chat_id, role, name, text, created_at) VALUES (%s, %s, %s, %s, %s)",
|
||
(chat_id, role, name, cleaned_text, time.time()),
|
||
)
|
||
else:
|
||
cur.execute(
|
||
"INSERT INTO talk_history (chat_id, user_id, role, name, text, created_at) VALUES (%s, %s, %s, %s, %s, %s)",
|
||
(chat_id, user_id, role, name, cleaned_text, time.time()),
|
||
)
|
||
|
||
|
||
def _get_summary(chat_id: int, user_id: int | None = None) -> str:
|
||
with get_conn() 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:
|
||
with get_conn() as conn:
|
||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||
if user_id is None:
|
||
cur.execute(
|
||
"SELECT id, role, name, text, created_at FROM chat_history WHERE chat_id = %s ORDER BY id ASC",
|
||
(chat_id,),
|
||
)
|
||
else:
|
||
cur.execute(
|
||
"SELECT id, role, name, text, created_at FROM talk_history WHERE chat_id = %s AND user_id = %s ORDER BY id ASC",
|
||
(chat_id, user_id),
|
||
)
|
||
return cur.fetchall()
|
||
|
||
|
||
def _latest_reply_stats(chat_id: int) -> tuple[float, int]:
|
||
with get_conn() as conn:
|
||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||
cur.execute(
|
||
"SELECT id, created_at FROM chat_history WHERE chat_id = %s AND role = 'assistant' ORDER BY id DESC LIMIT 1",
|
||
(chat_id,),
|
||
)
|
||
last_assistant = cur.fetchone()
|
||
if not last_assistant:
|
||
return 0.0, 10_000
|
||
cnt_cur = conn.cursor()
|
||
cnt_cur.execute(
|
||
"SELECT COUNT(*) FROM chat_history WHERE chat_id = %s AND role = 'user' AND id > %s",
|
||
(chat_id, last_assistant["id"]),
|
||
)
|
||
user_messages_since_reply = cnt_cur.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 get_conn() as conn:
|
||
cur = conn.cursor()
|
||
if user_id is None:
|
||
_set_state_from_conn(conn, chat_id, "summary", summary)
|
||
cur.execute(
|
||
"DELETE FROM chat_history WHERE chat_id = %s AND id <= %s",
|
||
(chat_id, last_row_id),
|
||
)
|
||
else:
|
||
_set_talk_state_from_conn(conn, chat_id, user_id, "summary", summary)
|
||
cur.execute(
|
||
"DELETE FROM talk_history WHERE chat_id = %s AND user_id = %s AND id <= %s",
|
||
(chat_id, user_id, last_row_id),
|
||
)
|
||
|
||
|
||
def _format_row_for_llm(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 | 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"])
|
||
|
||
summary = _get_summary(chat_id, user_id=user_id).strip()
|
||
summary_block = f"\n\nКраткая память чата:\n{summary[:SUMMARY_CHAR_BUDGET]}" if summary else ""
|
||
full_system = system_prompt + summary_block
|
||
|
||
used_chars = len(full_system) + 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
|
||
recent_messages.append(llm_message)
|
||
used_chars += len(llm_message["content"])
|
||
|
||
messages: list[dict[str, Any]] = [{"role": "system", "content": full_system}]
|
||
messages.extend(reversed(recent_messages))
|
||
if current_payload:
|
||
messages.append(current_payload)
|
||
return messages
|
||
|
||
|
||
async def _call_single_model(
|
||
messages: list[dict[str, Any]],
|
||
*,
|
||
api_url: str,
|
||
api_key: str,
|
||
model: str,
|
||
max_tokens: int,
|
||
temperature: float,
|
||
top_p: float,
|
||
disable_thinking: bool,
|
||
) -> str:
|
||
url = f"{api_url.rstrip('/')}/v1/chat/completions"
|
||
headers = {"Content-Type": "application/json"}
|
||
if api_key:
|
||
headers["Authorization"] = f"Bearer {api_key}"
|
||
payload = {
|
||
"model": model,
|
||
"messages": messages,
|
||
"max_tokens": max_tokens,
|
||
"temperature": temperature,
|
||
"top_p": top_p,
|
||
}
|
||
if disable_thinking:
|
||
payload.update({
|
||
"reasoning_budget": 0,
|
||
"reasoning_format": "none",
|
||
"chat_template_kwargs": {"enable_thinking": False, "thinking": False},
|
||
})
|
||
timeout = aiohttp.ClientTimeout(total=120)
|
||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||
async with session.post(url, json=payload, headers=headers) as resp:
|
||
raw_text = await resp.text()
|
||
if resp.status >= 400:
|
||
logger.error("LLM API %s returned status %s: %s", api_url, resp.status, _clip_text(raw_text, 300))
|
||
raise RuntimeError(f"LLM API error {resp.status}: {raw_text[:300]}")
|
||
try:
|
||
data = await resp.json(content_type=None)
|
||
except Exception as exc:
|
||
logger.error("LLM API %s returned invalid JSON: %s", api_url, _clip_text(raw_text, 300))
|
||
raise RuntimeError(f"Invalid LLM API response: {raw_text[:300]}") from exc
|
||
|
||
choices = data.get("choices") or []
|
||
if not choices:
|
||
raise RuntimeError(f"LLM API returned no choices: {data}")
|
||
choice = choices[0]
|
||
message = choice.get("message", {}) or {}
|
||
finish_reason = choice.get("finish_reason")
|
||
content = message.get("content") or ""
|
||
reasoning_content = (message.get("reasoning_content") or "").strip()
|
||
cleaned_content = content.strip()
|
||
if reasoning_content and LOG_THINKING:
|
||
logger.info("LLM reasoning. model=%s finish_reason=%s reasoning=%s", model, finish_reason, _clip_text(reasoning_content, 800))
|
||
if not cleaned_content and reasoning_content and not disable_thinking:
|
||
retry_max_tokens = min(max(max_tokens * 2, RETRY_MIN_MAX_TOKENS), RETRY_MAX_MAX_TOKENS)
|
||
logger.warning("LLM returned only reasoning, retrying with thinking off. model=%s", model)
|
||
return await _call_single_model(
|
||
messages,
|
||
api_url=api_url, api_key=api_key, model=model,
|
||
max_tokens=retry_max_tokens, temperature=temperature, top_p=top_p,
|
||
disable_thinking=True,
|
||
)
|
||
return cleaned_content
|
||
|
||
|
||
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
|
||
|
||
result = await _call_single_model(
|
||
messages,
|
||
api_url=LLAMA_API_URL,
|
||
api_key=LLAMA_API_KEY,
|
||
model=LLAMA_MODEL,
|
||
max_tokens=max_tokens,
|
||
temperature=temperature,
|
||
top_p=top_p,
|
||
disable_thinking=disable_thinking,
|
||
)
|
||
|
||
# Если основная модель не смогла ответить — пробуем fallback
|
||
if LLAMA_FALLBACK_API_URL and (not result or _CANT_ANSWER_RE.search(result)):
|
||
fallback_model = LLAMA_FALLBACK_MODEL or LLAMA_MODEL
|
||
logger.info("Primary model couldn't answer, trying fallback. primary_result=%s", _clip_text(result, 100))
|
||
try:
|
||
fallback_result = await _call_single_model(
|
||
messages,
|
||
api_url=LLAMA_FALLBACK_API_URL,
|
||
api_key=LLAMA_FALLBACK_API_KEY,
|
||
model=fallback_model,
|
||
max_tokens=max_tokens,
|
||
temperature=temperature,
|
||
top_p=top_p,
|
||
disable_thinking=disable_thinking,
|
||
)
|
||
if fallback_result:
|
||
return f"{fallback_result}\n\n<i>🤖 {fallback_model}</i>"
|
||
except Exception:
|
||
logger.exception("Fallback model also failed")
|
||
|
||
if not result:
|
||
logger.warning("LLM returned empty content. model=%s", LLAMA_MODEL)
|
||
return result
|
||
|
||
|
||
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 _md_to_tg_html(text: str) -> str:
|
||
"""Конвертирует Markdown и HTML-теги модели в валидный Telegram HTML."""
|
||
slots: list[tuple[str, str]] = []
|
||
|
||
def stash(tag: str, content: str) -> str:
|
||
idx = len(slots)
|
||
slots.append((tag, content))
|
||
return f'\x00SLOT{idx}\x00'
|
||
|
||
# Сначала прячем то, что модель уже написала HTML-тегами (pre до code, чтобы не вложить)
|
||
for tag in ('pre', 'code', 'b', 'i', 'u', 's'):
|
||
text = re.sub(
|
||
rf'<{tag}>(.*?)</{tag}>',
|
||
lambda m, t=tag: stash(t, m.group(1)),
|
||
text, flags=re.DOTALL,
|
||
)
|
||
|
||
# Конвертируем Markdown
|
||
text = re.sub(
|
||
r'```(?:[^\n`]*\n)?(.*?)```',
|
||
lambda m: stash('pre', m.group(1).strip()),
|
||
text, flags=re.DOTALL,
|
||
)
|
||
text = re.sub(r'`([^`\n]+)`', lambda m: stash('code', m.group(1)), text)
|
||
text = re.sub(r'\*\*(.+?)\*\*', lambda m: stash('b', m.group(1)), text, flags=re.DOTALL)
|
||
text = re.sub(r'\*([^*\n]+?)\*', lambda m: stash('i', m.group(1)), text)
|
||
text = re.sub(r'_([^_\n]+?)_', lambda m: stash('i', m.group(1)), text)
|
||
text = re.sub(r'~~(.+?)~~', lambda m: stash('s', m.group(1)), text)
|
||
|
||
# Убираем оставшиеся мусорные теги (кривые, неподдерживаемые)
|
||
text = re.sub(r'<[^>]*>', '', text)
|
||
|
||
# Экранируем оставшийся plain-text
|
||
text = html.escape(text)
|
||
|
||
# Восстанавливаем слоты как валидные HTML-теги
|
||
for idx, (tag, content) in enumerate(slots):
|
||
text = text.replace(f'\x00SLOT{idx}\x00', f'<{tag}>{html.escape(content)}</{tag}>')
|
||
|
||
return text
|
||
|
||
|
||
def _normalize_reply(text: str) -> str:
|
||
cleaned = (text or "").strip()
|
||
if not cleaned:
|
||
return ""
|
||
if cleaned.lower().startswith(SKIP_TOKEN.lower()):
|
||
return ""
|
||
return _md_to_tg_html(cleaned)
|
||
|
||
|
||
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
|
||
|
||
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
|
||
|
||
if store_message:
|
||
await asyncio.to_thread(push_message, chat_id, "user", user_name, user_text)
|
||
|
||
# Один чат, поэтому ответы сериализуем и не даём боту наспамить параллельными реплаями.
|
||
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 = 180 if reason == "autonomous" else 512
|
||
temperature = 0.85 if reason == "autonomous" else 0.7
|
||
|
||
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.95,
|
||
)
|
||
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="HTML")
|
||
return True
|
||
|
||
|
||
async def _download_image_data_url(message: Message) -> str:
|
||
telegram_file = None
|
||
mime_type = "image/jpeg"
|
||
|
||
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")
|
||
|
||
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}"
|
||
|
||
|
||
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
|
||
watch_match = WATCH_COMMAND_RE.match((message.caption or "").strip())
|
||
if not watch_match:
|
||
return False
|
||
|
||
chat_id = message.chat.id
|
||
user_name = message.from_user.first_name or "кент"
|
||
caption = _clean_text(watch_match.group(1) 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=600,
|
||
temperature=0.7,
|
||
top_p=0.95,
|
||
)
|
||
except Exception:
|
||
logger.exception("Photo analysis failed")
|
||
await message.reply(PHOTO_ERROR_RESPONSE_TEXT, parse_mode="HTML")
|
||
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="HTML")
|
||
return True
|
||
|
||
|
||
async def generate_autoreply(chat_id: int, text: str, user_name: str) -> str:
|
||
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=768,
|
||
temperature=0.7,
|
||
top_p=0.95,
|
||
)
|
||
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="HTML")
|
||
|
||
|
||
_init_db()
|