Skip to content

Repository files navigation

Жека Жека

Telegram-бот для группового чата на LLM (OpenAI). Живёт в группе как обычный участник, сам решает, когда вступить в разговор, и отвечает в заданном характере (персона задаётся текстовым файлом, без правки кода).

Жека — сантехник из ЖЭКа: дядька-работяга лет пятидесяти, который всю жизнь крутит гайки, меняет стояки и спасает людей от потопов. Полжизни провёл по чужим кухням с ключом на 24 и за разговорами наслушался всего на свете — поэтому разбирается, кажется, в любой теме: от политики и футбола до квантовой физики и вина. Только объясняет всё по-своему — через трубы, давление и здравый смысл. Говорит коротко и хлёстко, как в курилке, и умнее, чем кажется.

Как это работает

Два независимых слоя:

  1. Когда отвечать — триггеры + ограничитель частоты. Кандидатом на ответ сообщение становится, если это упоминание @бота, reply на его сообщение, совпадение с ключевым словом или случайный шанс (REPLY_PROBABILITY). Затем проверяются лимиты: на чат в минуту и глобальный в день. Большинство сообщений отсекается до вызова модели — это и защита чата от спама, и контроль бюджета.
  2. Что отвечать — по каждому чату в памяти держится скользящее окно последних сообщений (CONTEXT_WINDOW). Промпт собирается как «персона → контекст чата → сообщение-триггер» и уходит в OpenAI Chat Completions.
  3. Поиск по истории чата (опционально) — если настроен 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

Подготовка бота в Telegram

  1. Создать бота у @BotFather, получить токен.
  2. @BotFather → /mybots → выбрать бота → Bot SettingsGroup PrivacyTurn off. Без этого бот видит только команды и упоминания, а не все сообщения группы.
  3. Добавить бота в группу обычным участником (права администратора не нужны).

Установка и запуск

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 (обращения «жека», просьбы о совете, поиск мастера, топливная тематика и т.д.). Совпадение ищется по подстроке без учёта регистра, поэтому стемы вроде заправк ловят все словоформы.

Поиск по истории чата (MCP)

Опциональная интеграция с 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.

Структура ответа 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_messagesnull). Как бот использует поля:

Поле Использование
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_MODELgpt-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.

Лицензия

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages