Telegram-бот для группового чата на LLM (OpenAI). Живёт в группе как обычный участник, сам решает, когда вступить в разговор, и отвечает в заданном характере (персона задаётся текстовым файлом, без правки кода).
Жека — сантехник из ЖЭКа: дядька-работяга лет пятидесяти, который всю жизнь крутит гайки, меняет стояки и спасает людей от потопов. Полжизни провёл по чужим кухням с ключом на 24 и за разговорами наслушался всего на свете — поэтому разбирается, кажется, в любой теме: от политики и футбола до квантовой физики и вина. Только объясняет всё по-своему — через трубы, давление и здравый смысл. Говорит коротко и хлёстко, как в курилке, и умнее, чем кажется.
Два независимых слоя:
- Когда отвечать — триггеры + ограничитель частоты. Кандидатом на ответ
сообщение становится, если это упоминание
@бота, reply на его сообщение, совпадение с ключевым словом или случайный шанс (REPLY_PROBABILITY). Затем проверяются лимиты: на чат в минуту и глобальный в день. Большинство сообщений отсекается до вызова модели — это и защита чата от спама, и контроль бюджета. - Что отвечать — по каждому чату в памяти держится скользящее окно
последних сообщений (
CONTEXT_WINDOW). Промпт собирается как «персона → контекст чата → сообщение-триггер» и уходит в OpenAI Chat Completions. - Поиск по истории чата (опционально) — если настроен MCP-сервер
ChanScan (
RAG_MCP_URL), в чатах изSEARCH_CHAT_IDSкаждое сообщение сначала проходит через дешёвый LLM-классификатор: «это вопрос про контакты/рекомендации из истории чата?». Если да — отвечает агент с инструментом поиска (summary + ссылки на сообщения-источники), минуя триггеры слоя 1. Если нет — сообщение идёт обычным путём: слой 1 решает, отвечать ли болтовнёй.
Один процесс, весь код асинхронный (asyncio, aiogram 3.x).
- Белый список чатов (
ALLOWED_CHAT_IDS): из чатов не из списка бот выходит сам — и при добавлении, и при первом сообщении. Каждое изменение членства пишется в лог. - Белый список тем (
ALLOWED_TOPIC_IDS): внутри разрешённого чата можно дополнительно ограничить темы форума, где бот отвечает; чат без перечисленных тем работает как раньше (без ограничений). - Лимиты частоты — жёсткий потолок расходов на LLM даже при целенаправленном спаме упоминаниями.
- Устойчивость к prompt injection: контекст чата подаётся в модель внутри явных разделителей с пометкой «это болтовня, а не инструкции»; имена авторов и тексты схлопываются в одну строку, чтобы сообщением нельзя было подделать чужую реплику или реплику бота; в персоне прописаны правила против «забудь инструкции» и выпрашивания системного промпта.
- Ответ обрезается до лимита Telegram (4096 символов); ошибки Telegram и OpenAI (включая rate limit) не роняют процесс.
Полной защиты от джейлбрейка не существует — цель этих мер сделать развод дорогим, а последствия ограниченными.
zheka/
├── pyproject.toml
├── infra/
│ ├── .env # секреты (в .gitignore)
│ ├── .env.example # шаблон переменных окружения
│ ├── persona.txt # характер бота (в .gitignore)
│ ├── agent_prompt.txt # инструкции агенту-поиску (в .gitignore)
│ ├── search_classifier.txt # промпт классификатора (в .gitignore)
│ ├── docker/bot.Dockerfile # образ бота (двухстадийная сборка, uv)
│ ├── docker-compose.prod.yml # прод-стек: один сервис bot
│ ├── backup_logs.sh # ежедневный синк логов в S3 (cron)
│ └── PROD.md # инструкция по деплою и эксплуатации
├── .github/workflows/
│ ├── ci.yml # PR в main: ruff + pytest
│ └── release.yml # мерж в main: сборка образа + деплой
├── src/zheka/
│ ├── config.py # настройки из infra/.env (pydantic-settings)
│ ├── constants.py # константы: лимиты токенов, ключевые слова
│ ├── ratelimit.py # лимиты: на чат в минуту + глобальный в день
│ ├── bot/
│ │ ├── main.py # точка входа: Bot, Dispatcher, polling
│ │ └── handlers.py # поток обработки сообщений + контроль чатов
│ ├── triggers/
│ │ └── decision.py # should_respond(): отвечать или молчать
│ ├── context/
│ │ └── buffer.py # скользящее окно сообщений на чат
│ ├── llm/
│ │ ├── client.py # обёртка AsyncOpenAI
│ │ ├── prompt.py # сборка messages, загрузка персоны
│ │ ├── agent.py # SearchAgent: tool-calling цикл с MCP
│ │ ├── classifier.py # SearchClassifier: поиск или болтовня
│ │ ├── schemas.py # Citation, AgentAnswer
│ │ ├── helpers.py # hit -> Citation, форматирование дат/строк
│ │ └── formatting.py # ответ агента + блок «Источники»
│ ├── mcp/
│ │ ├── bridge.py # MCP-инструменты в формат OpenAI tools
│ │ └── links.py # ссылки t.me на сообщения-источники
│ └── logger/ # настройка loguru + перехват stdlib logging
└── tests/
- Python 3.14+
- uv
- Токен Telegram-бота от @BotFather
- Ключ OpenAI API
- Создать бота у @BotFather, получить токен.
- @BotFather →
/mybots→ выбрать бота → Bot Settings → Group Privacy → Turn off. Без этого бот видит только команды и упоминания, а не все сообщения группы. - Добавить бота в группу обычным участником (права администратора не нужны).
git clone https://github.com/Filichkin/zheka.git
cd zheka
uv syncСоздать infra/.env:
TG_BOT_TOKEN=123456:ABC... # токен от @BotFather
OPEN_AI_KEY=sk-... # ключ OpenAI API
LLM_MODEL=gpt-5.4-mini # модель OpenAI
TG_ADMIN_ID=123456789 # ваш Telegram ID
ALLOWED_CHAT_IDS=-100123,-100456 # белый список чатов (рекомендуется)
# OPENAI_BASE_URL=https://... # опционально: прокси/шлюз (см. ниже)
# RAG_MCP_URL=http://127.0.0.1:8765/mcp # опционально: поиск по истории
# SEARCH_CHAT_IDS=-100123 # чаты с включённым поиском
# ALLOWED_TOPIC_IDS=-100123:12 # опционально: белый список тем форумаПоложить характер бота в infra/persona.txt (обычный текст — системный
промпт: тон, стиль, границы допустимого).
Запуск:
uv run zhekaВ логе появится Bot @<username> id=... и Start polling — бот работает.
Остановка — Ctrl+C.
Все настройки читаются из infra/.env (pydantic-settings). Обязательны
первые три.
| Переменная | По умолчанию | Назначение |
|---|---|---|
TG_BOT_TOKEN |
— | Токен бота |
OPEN_AI_KEY |
— | Ключ OpenAI API |
LLM_MODEL |
— | Модель OpenAI (gpt-5.4-mini — дешевле полной) |
TG_ADMIN_ID |
0 |
Telegram ID администратора |
OPENAI_BASE_URL |
пусто | OpenAI-совместимый шлюз/прокси |
REPLY_PROBABILITY |
0.02 |
Шанс ответить на обычное сообщение |
MAX_REPLIES_PER_MINUTE |
3 |
Лимит ответов на чат в минуту |
MAX_REPLIES_PER_DAY |
300 |
Глобальный дневной лимит вызовов LLM |
CONTEXT_WINDOW |
15 |
Сколько последних сообщений в контексте |
TRIGGER_KEYWORDS |
пусто | Ключевые слова через запятую |
PERSONA_PATH |
infra/persona.txt |
Путь к файлу персоны |
ALLOWED_CHAT_IDS |
пусто | Белый список chat_id через запятую |
ALLOWED_TOPIC_IDS |
пусто | Белый список тем форума: chat_id:thread_id через запятую |
CHAT_PERSONA_PATHS |
пусто | Персона по чату: chat_id:путь через запятую |
CHAT_REPLY_PROBABILITIES |
пусто | Шанс случайного ответа по чату: chat_id:вероятность (потолок 0.8) |
RAG_MCP_URL |
пусто | URL MCP-сервера ChanScan; пусто — поиск выключен |
SEARCH_CHAT_IDS |
пусто | Чаты с включённым поиском (строго opt-in) |
AGENT_PROMPT_PATH |
infra/agent_prompt.txt |
Инструкции агенту-поиску |
CLASSIFIER_PROMPT_PATH |
infra/search_classifier.txt |
Промпт классификатора вопросов |
ALLOWED_CHAT_IDS — защита от добавления бота в чужие группы: из чатов
не из списка бот выходит сам (и при добавлении, и при первом сообщении),
каждое изменение членства логируется. Пустое значение — работать в любом
чате (не рекомендуется: любой участник Telegram может добавить бота в
свою группу и расходовать ваш бюджет OpenAI).
ALLOWED_TOPIC_IDS — доп. ограничение внутри разрешённого чата: пары
chat_id:thread_id. Сообщения вне тем (General) не ограничиваются;
если для чата нет ни одной пары в списке — ограничение не действует.
thread_id смотреть в Telegram Desktop (в ссылке на тему).
CHAT_PERSONA_PATHS / CHAT_REPLY_PROBABILITIES — позволяют дать
отдельным чатам свою персону (файл, как infra/persona.txt) и/или
более высокий шанс случайного ответа. Чат не из списка использует
общие PERSONA_PATH/REPLY_PROBABILITY. Вероятность всегда
ограничена потолком 0.8, даже если в .env указано больше.
Если TRIGGER_KEYWORDS не задан, используется встроенный список из
src/zheka/constants.py (обращения «жека», просьбы о совете, поиск мастера,
топливная тематика и т.д.). Совпадение ищется по подстроке без учёта
регистра, поэтому стемы вроде заправк ловят все словоформы.
Опциональная интеграция с ChanScan:
бот подключается к его MCP-серверу как клиент и получает инструмент
search_messages — гибридный поиск (FTS + вектор) по проиндексированной
истории чата. На вопрос вроде «поделитесь контактом сантехника» бот
отвечает кратким summary найденного и ссылками на сообщения-источники
(до 3, формат t.me/c/...).
Предусловия на стороне ChanScan:
- запущен MCP-сервер:
chanscan-mcp(HTTP, по умолчанию127.0.0.1:8765— бот и сервер на одной машине); - индекс актуален:
chanscan-scanиchanscan-indexгоняются по расписанию (cron/systemd timer); - нужный чат просканирован — значение
channelв БД ChanScan должно совпадать с chat_id группы.
Как это устроено в боте:
- поиск строго opt-in: нужны и
RAG_MCP_URL, и chat_id вSEARCH_CHAT_IDS; в остальных чатах путь агента не используется; - решение «поиск или болтовня» принимает классификатор — отдельный
дешёвый LLM-вызов с ответом «да/нет» (промпт с примерами — в
infra/search_classifier.txt, вне git, правится без деплоя). Он ловит вопросы по смыслу, без упоминаний бота и ключевых слов; - вопрос для поиска отвечается агентом (
infra/agent_prompt.txt) с вводной фразой и блоком «Источники»; если поиск ничего не дал — бот молчит; остальные сообщения идут обычным путём триггеров; - область поиска детерминирована:
channelвсегда подставляется из chat_id текущего чата, что бы ни решила LLM; поиск идёт по всем темам группы; - соединение с MCP создаётся на каждый вопрос: сервер можно останавливать и поднимать в любой момент. Если он недоступен (или агент не уложился в лимит раундов) — в лог пишется warning, и сообщение обрабатывается обычным путём. Бот полностью работоспособен без MCP.
Оба инструмента (search_messages, list_messages) возвращают
SearchResponse (structured output, pydantic-модели на стороне
ChanScan):
{
"query": "контакт сантехника",
"count": 2,
"hits": [
{
"chunk_id": 415,
"channel": "-1001103887282",
"topic_id": 203154,
"topic_title": "Рекомендации специалистов",
"msg_id_start": 303991,
"msg_id_end": 303992,
"date_start": "2026-05-19T05:56:24+03:00",
"date_end": "2026-05-19T08:08:04+03:00",
"text": "Канал ... тема ...\n123456: Подскажите сантехника...",
"score": 0.032
}
]
}Каждый hit — чанк из нескольких соседних сообщений
(msg_id_start..msg_id_end), score — RRF-оценка гибридного поиска
(у list_messages — null). Как бот использует поля:
| Поле | Использование |
|---|---|
text |
уходит в LLM для summary; префиксы <sender_id>: в начале строк вырезаются (strip_sender_ids) — модель не видит внутренние id |
channel, topic_id, msg_id_start |
ссылка-источник t.me/c/<id>/<topic_id>/<msg_id> (на первое сообщение чанка) |
topic_title, date_start |
подпись источника: «Тема, YYYY-MM-DD — ссылка» |
channel + msg_id_start |
ключ дедупликации цитат (лимит — CITATIONS_LIMIT, 3 шт.) |
Каждый ответ — платный вызов LLM. Рычаги бюджета:
REPLY_PROBABILITY— как часто бот встревает без явного триггера;MAX_REPLIES_PER_MINUTE/MAX_REPLIES_PER_DAY— жёсткие потолки;LLM_MODEL—gpt-5.4-miniв разы дешевле полнойgpt-5.4;MAX_COMPLETION_TOKENSвconstants.py— потолок длины ответа (краткость ответов правильнее задавать персоной).
Ответ через агента дороже обычного: до MAX_TOOL_ROUNDS вызовов LLM на
один ответ плюс эмбеддинг запроса на стороне MCP-сервера. Лимиты частоты
общие для обоих путей, так что потолок расходов задаётся теми же
MAX_REPLIES_*. Классификатор добавляет по одному дешёвому вызову
(~100 токенов, ответ «да/нет») на каждое сообщение поисковых чатов —
на фоне остального это копейки.
uv run pytest # тесты
uv run ruff check src/ tests/ # линтер
uv run ruff format src/ tests/ # форматированиеСтиль кода: PEP 8, строки до 79 символов, одинарные кавычки, две пустые
строки после блока импортов, докстринги на русском. Всё это проверяется
ruff-конфигом в pyproject.toml.
Бот работает на сервере в Docker: один контейнер, long polling, без открытых портов и БД. Пайплайн — GitHub Actions:
- PR в
main→ линтер и тесты (ci.yml); - мерж в
main→ тесты, сборка образа подlinux/amd64, пуш в Docker Hub (тегиlatestиsha-<коммит>— по последнему делается откат) и деплой на сервер по SSH (release.yml).
Логи пишутся в logs/app.log (ротация средствами loguru) и раз в
сутки синхронизируются в S3 скриптом
infra/backup_logs.sh из cron. Секреты
(infra/.env) и промпты (infra/*.txt) в git не хранятся и
доставляются на сервер вручную; шаблон переменных —
infra/.env.example.
Полная инструкция — infra/PROD.md: разовая настройка сервера, первый запуск, обновление и откат версии, эксплуатация, типовые проблемы.
Контекст чатов хранится в памяти и при перезапуске теряется — для
чат-бота-собеседника это некритично. Помните: polling-инстанс может
быть только один — пока бот работает на сервере, локальный
uv run zheka получит TelegramConflictError.