feat(standalone): :integrations-telegram — bridge stability + markdown rendering

Outbound (DurableEvent-driven):
- typing дёргается ДО conv.send (а не только по online-событиям);
  рефреш по online — каждые ≥4 с, чтобы не получить 429-спам
  (Telegram rate-limit ~250 ms на sendChatAction).
- финальный текст — sendMessage(... ParseMode.HTML) на
  DurableEvent.AssistantMessage, без промежуточных editMessage.
- служебные строки ((empty response), ошибки) идут как plain text
  (без parseMode=HTML), чтобы 400 can't parse entities не выбил
  предупреждение в чат.

Inline markdown → Telegram HTML (MarkdownToTelegram):
- **bold** / __bold__ / *italic* / _italic_ / `code` → <b>/<i>/<code>;
  fenced ``` → <pre>; [text](url) → <a href>.
- markdown-таблицы → Unicode-рамка (┌─┬─┐││├─┼─┤└─┴─┘) внутри <pre>
  (Telegram Bot API <table> не поддерживает).
- escapeOutsideTags теперь ведёт стек открытых тегов: раньше брал
  первый попавшийся закрывающий тег и терял парность (агент вставил
  <i> внутри <code> → Telegram 400 can't find end tag для <code>).
- экранирует только вне наших тегов; внутри — уже подготовленный текст.
- сообщения > 4096 режутся по \n вне тегов.

Видимость ошибок:
- kotlin-logging в build.gradle.kts.
- runCatching'и заменены на try/catch + log.error/warn с контекстом
  (chatId, convId, htmlLen, превью первых ≤120 символов HTML).
  Раньше Telegram 400 на sendMessage с 'can't parse entities' уходил
  молча — теперь видно в логе с полным телом запроса.

Тесты: 23 + 12 + 28 = 63 в :integrations-telegram, 132 в :standalone.
:standalone:assemble собирает fatjar.
This commit is contained in:
2026-10-06 00:51:28 +03:00
parent 24212a1003
commit 88dabd49a3
6 changed files with 1540 additions and 297 deletions
+31 -9
View File
@@ -22,6 +22,9 @@
## Как запустить
### Переменные среды
`AGENTIK_SKILLS_DIR=./agentik/skills;AGENTIK_SYSTEM_PROMPT=Ты полезный ассистент.;OPENAI_BASE_URL=http://192.168.88.135:8001/v1;AGENTIK_DB_PATH=./agentik/db.db;OPENAI_CONTEXT_WINDOW=115000;AGENTIK_MEMORY_DIR=./agentik/memory;AGENTIK_LLM_BACKEND=openai;AGENTIK_PORT=8080;OPENAI_API_KEY=sk-76R2p5nxQxflPkIROr6r2xGiuSYzUCYM;OPENAI_MODEL=/root/.cache/huggingface/Qwen3.8-27B-NVFP4-RTX5090;AGENTIK_SOUL=./agentik/SOUL.md;AGENTIK_TELEGRAM_TOKEN=8857094360:AAFeVYMlutSaHptbhpO4mUryR6Je7OUJsm4`
### Требования
- JVM 21+.
@@ -107,14 +110,32 @@ AGENTIK_GOOGLE_MODEL_PATH=/root/gemma-4-E2B-it.litertlm \
- Long-polling через Bot API (`getUpdates?timeout=N`). Не webhook —
не нужен публичный URL, всё работает за NAT.
- Маппинг `chatId ↔ ConversationId` в общей SQLite-БД standalone'а
(таблица `tg_chat_map`). Один Telegram-чат = один диалог с агентом;
сообщения из чата стримят ответы обратно через `editMessageText`.
(таблица `tg_chat_map`). Один Telegram-чат = один диалог с агентом.
- **«печатает…» в чате** через `sendChatAction(TYPING)`:
- одно сразу при получении апдейта (ДО вызова LLM — пользователь
видит статус ровно в момент отправки текста);
- рефреш на каждом online-событии для диалога (Working /
StartReasoning / StartResponse / AppendText / AppendImage / End) —
Telegram гасит статус через ~5 с, поэтому для длинных tool-call'ов
(когда дельт текста нет) мост продолжает рефрешить typing по
`Working`.
- **Финальное сообщение** приходит одним `sendMessage(...)` по
`DurableEvent.AssistantMessage` (целиковый текст из `Content.Text`).
Никакого стриминга через `editMessage` — UX проще, и Telegram
нормально индексирует одиночное сообщение.
- **Markdown → Telegram HTML**: мост конвертирует CommonMark-подобный
markdown агента в `ParseMode.HTML` (`<b>`, `<i>`, `<code>`, `<pre>`,
`<a href>`); остальные символы экранируются. Сообщения длиннее
4096 символов режутся по переводам строк (вне HTML-тегов).
- **Markdown-таблицы** (`| col | col |` + `| --- | --- |`) рендерятся
через Unicode box-drawing (`┌─┬─┐ │ │ │ ├─┼─┤ └─┴─┘`) внутри `<pre>` —
Telegram Bot API не поддерживает `<table>`, поэтому табличный вид
даёт только моноширинная рамка. Markdown/HTML-разметка внутри
ячеек снимается (в `<pre>` inline-форматирование не работает).
- Group-чаты (`group`, `supergroup`) и команда `/new` стартуют **новый
временный** диалог (`ConversationRecord.isTemporal = true`) — не
пишут в мапу, удаляются при следующем `/new`.
- Tool-call показывает «⚙️ обрабатываю…» через `sendChatAction(typing)` —
включается на `onlineOutbox.onlineEvents` пока обрабатывается ход.
- Ошибки агента стримятся в чат одной строкой, polling не падает.
временный** диалог (`ConversationRecord.isTemporal = true`).
- Ошибки агента (`DurableEvent.Error`, исключение из `conv.send`)
приходят одной строкой, polling не падает.
### Как включить
@@ -134,8 +155,9 @@ AGENTIK_GOOGLE_MODEL_PATH=/root/gemma-4-E2B-it.litertlm \
telegram: enabled (polling timeout=30s, persistent=true)
```
4. Ответы приходят стримингом — `editMessageText` обновляет одно и то
же сообщение каждые ~1с пока агент генерирует.
4. Ответ приходит одним сообщением (после завершения хода ассистента).
Пока агент думает / вызывает тулзы / стримит токены — в чате горит
«печатает…».
### Persistent vs temporary