- Rust 87.9%
- Python 9%
- Nix 3.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Гостевой промпт и тосты «👀» разъехались с волной: они приехали в мост,
пока личность уезжала в конфиг. Возвращаем их в общий строй — нейтральные
встроенные строки, голос оверлеем.
Co-Authored-By: Eva
|
||
| crates/bridge | ||
| docs | ||
| nix | ||
| triage-benchmark | ||
| .gitignore | ||
| AGENTS.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CHANGELOG.md | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
eva-telegram-bridge
Телеграмная поверхность ассистента: отдельный процесс, который ловит сообщения Bot API, ведёт туры в ядре и доставляет ответы обратно.
Это модуль, а не MCP-сервер: его инструменты попадают в реестр ядра
наравне со встроенными — telegram_react, telegram_skip и прочие
десять, в группе telegram. Ядро зовёт их, передавая контекст тура, и по
нему мост понимает, чей ответ гасить и на какое сообщение отвечать.
Граница простая: ядро отвечает за личность — персону, память, историю, туры, политику доступа к опасным инструментам. Мост отвечает за телеграм — токен, кто кому может писать, дебаунсы, потолки ответа на чат и на человека, спонтанность, кэш вложений, привязку «tg-чат → чат ядра». Своё состояние мост держит в своей базе и в базу ядра не лезет.
Голос — тоже забота моста: встроенные строки нейтральны, а поверх них
ложится оверлей из своего конфига (persona.overlay_file — плоский YAML
«tg.ключ → строка», ключи чужих клиентов в общем файле мост пропускает).
У ядра он спрашивает только идентичность — имя и обращение
(GET /v1/persona); они подставляются в {name} и {honorific}, а ядро
постарше их не отдаёт, и подстановки остаются нейтральными. Оверлея нет —
мост говорит нейтрально; оверлей битый — старт падает: онемевший голос
выглядит как подменённая личность, а не как опечатка в пути. Образец —
docs/persona-overlay.example.yaml, ключи и встроенные значения —
crates/bridge/src/strings.rs.
Как выглядит ответ в телеграме — тоже забота моста: правила поверхности он
объявляет ядру на handshake (Module::surface_rules), и ядро вклеивает их
в системный промпт наших туров как есть. Своего мнения о телеграме у ядра
больше нет.
Что умеет
- long-poll Bot API, воркер на чат с дебаунсом (настройка человека жёстче
настройки чата), триаж дешёвой моделью и один тур на всю пачку.
Судья видит только сами сообщения: ни названия чата, ни его id в
промпте нет — шумная вывеска группы уводит дешёвую модель в «влезай».
Разговор вокруг пачки показывается ему лишь по тумблеру
🧾 Контекст триажу, выключенному по умолчанию (глубина хвоста —telegram.triage_context); - вердикт триажа — подсказка, а не приказ: судья решает, кого вовлечь,
но чем ответить, решает сам ассистент. Тур достаётся последнему вовлечённому
каким угодно вердиктом; реакцию он ставит тулом
react, молчание объявляетskip, и переиграть судью вправе. «Хватит эмодзи» доезжает до тура одной лишь просьбой о краткости — мост эмодзи больше не выбирает, иначе на дурную новость прилетает случайное сердечко. Итог каждой пачки — вовлечено против отвеченного — уходит в/v1/statsядра, где копится доля промахов судьи; - доставка стримингом: ответ растёт правкой одного сообщения, под ним кнопки «✋ Отмена» и «⏳ В фон» — они едут с каждым кадром и живут до конца тура. «Отмена» гасит тур и сразу снимает кнопки, не дожидаясь конца; «В фон» отпускает воркер чата на следующую пачку, гасит кадры и снимает кнопки, а ответ доезжает сам. Обе — для того, кому отвечают, и для владельца: чужой тур в группе прохожему не погасить. Ответ на нажатие приходит всегда, в том числе когда гасить уже нечего или ядро недоступно;
- реакции вместо слов, явное молчание, вложения в обе стороны, фото
собеседника прямо в модель (по цепочке user > chat > конфиг
multimodal; без неё — только разрешённым). Реплай на картинку везёт и её саму, и её подпись — разговор о ней не идёт вслепую; - выделенный в реплае кусок родителя и источник пересылки приезжают
прямо в тексте сообщения: цитата — строками под
>с пустой строкой под ними, пересланное — строкой[переслано от X]над телом. Своим полем каждое стало бы ещё одним форматом, который история, триаж и четыре клиента рендерят по-своему (и половина — никак), а обычный текст понимают все и даром. Без выделенного куска реплай по-прежнему означает «отвечаю на сообщение целиком», а без пересылки подписи просто нет; - нарисованное ассистентом уезжает в чат вместе с ответом: байты мост берёт у ядра по id, а не по ссылке — качалка по URL прикрыта щитом приватных адресов, и ядро на localhost ей недоступно;
- кнопочные вопросы, ждущие ответа внутри тура;
- меню
/settingsпо экранам: обзор — список входов, на каждом видно текущее состояние («🧰 Инструменты · 12/40», «🧠 Модель · kimi-k3», «🎛 Набор · весь реестр»), а тумблеры инструментов (внутри ещё страницы групп), выбор модели и выбор набора живут каждый на своём экране с «⬅ Назад» и «✖ Закрыть». Живые реплики ассистента к действиям (menu_quips); открыть и жать может владелец, админ группы или собеседник в личке. Тулы изmaster_toolsв чатах не показываются вовсе — только в консоли, которую владелец забирает в личку кнопкой с обзора. В обзоре живёт кнопка «🖼 Зрение · ✔️/✖️» — картинки чата прямо в модель; клик переключает, и жмёт её только владелец. Недостижимые из чата тулы (client_only, trusted_only вне лички) меню не обещает. Юзер-меню с пресетами (дебаунс, потолок, краткость, фото в модель) и тулtelegram_settings_menu— ассистент открывает меню сам; - inline-режим, самооформление бота и живое описание профиля;
- guest mode (Bot API 10.0): позвали
@хэндломв чат, где Евы нет, — она отвечает одним сообщением, не видя ни истории, ни участников (тумблер «Guest Mode» у BotFather). Черезtelegram.guest_hide_after(«5min», «30sec»; ноль — никогда, по умолчанию 5 минут) ответ сворачивается в «…» с кнопкой «👀» — та разворачивает его и заводит таймер заново; - комментарии под постами канала помечаются как контекст, а не как чужая реплика.
Каркас
Модуль стоит на фреймворке eva — том же, что и ядро: конфиг-секции с наложением файлов, логи и супервизор компонентов даются им, а не пишутся заново.
Компонентов два, у каждого своя секция конфига и своё завершение:
| компонент | секция | что делает |
|---|---|---|
| поверхность | telegram |
поллинг, туры, доставка |
| инструменты | tools |
UDS-сокет, к которому подключается ядро |
Слои
| файл | что это |
|---|---|
bridge.rs |
поллинг, воркеры чатов, доставка, меню-действия |
tools.rs |
инструменты модуля и их связь с идущим туром |
triage.rs |
кого из пачки вовлекать |
menu.rs |
клавиатуры /settings |
api.rs |
HTTP-клиент Bot API, отправка и правка rich-сообщений |
render.rs |
сегменты ответа, строки инструментов, кадры стрима, чанковка |
rich.rs |
медиа-блоки : находим, качаем, подменяем на вложения |
photos.rs |
скачивание и валидация вложений, картинки ядра по id, кэш file_id |
questions.rs |
кнопочные вопросы |
strings.rs |
строки поверхности: нейтральные встроенные и оверлей персоны |
db.rs |
своё состояние: чаты, допуски, настройки, архив |
net.rs |
SSRF-щит для походов по ссылкам из чатов |
Запуск
eva-telegram-bridge --config config.yaml --config secret.yaml
Файлы накладываются друг на друга — секреты живут отдельно от остального
и не уезжают в общедоступный nix-store. Модулей во флейке два, с
одинаковыми опциями: nixosModules.default — системный юнит,
homeManagerModules.default — пользовательский (systemd.user, состояние
в $XDG_STATE_HOME). Оба дают пакет, юнит, конфиг, secretFile и секцию
persona.
Голос модуль собирает сам, и по умолчанию он не нейтральный: в репе лежит
nix/persona-overlay.json (39 строк), и он уезжает в конфиг, пока его не
отключили. Дальше — по вкусу:
| нужно | как |
|---|---|
| поправить пару строк | persona.overlay."tg.press.closed" = "…"; — ляжет поверх дефолтных |
| свой голос целиком | persona.useDefaults = false; и свои строки в persona.overlay |
| нейтральный мост | persona.useDefaults = false; с пустым overlay — секции в конфиге не будет вовсе |
| готовый файл | persona.overlayFile = ./catalog.yaml; — побеждает и дефолты, и overlay |
Голос не секрет: что бы ни вышло, файл копируется в общедоступный стор и
прописывается в settings.persona.overlay_file сам.
Ядру модуль объявляют в его конфиге:
modules:
servers:
telegram:
socket: /run/eva-mcp/telegram.sock
Один бот — один поллер: включать мост можно только вместе с выключением встроенного в ядро (тот спит, пока ему не дадут токен).
Сборка
nix develop -c cargo build