docs(telegram): groups and Jev topic triggers

This commit is contained in:
EugeneTes
2026-10-05 16:29:07 +00:00
parent be3c77b4d0
commit cabec7ad70
5 changed files with 75 additions and 6 deletions

View File

@@ -102,7 +102,7 @@ scripts through `IProcessSpawnPolicy`; `docs/telegram-bot.md#scheduling`). The C
### Telegram host (`hosts/Volcanic.AgenticCore.Telegram`)
Long polling (`Polling/`, with `ReconnectPolicy` backoff). Each private chat gets its own `AgentSession`
Long polling (`Polling/`, with `ReconnectPolicy` backoff; `message`, `callback_query`, `my_chat_member`). Each private chat gets its own `AgentSession`
(`Chats/ChatRegistry`), with workspace `<WorkspaceRoot>/<chatId>` and session scope `telegram-<chatId>`. Access is
limited by `AllowedUserIds`. Incoming messages go through `Inbound/`: the router takes a `ChatInbox` lease per
message in update order, `InboundComposer` builds its part (reply/forward headers from `MessageContext`, text and
@@ -117,6 +117,14 @@ Each chat's time zone lives in `Chats/ChatSettingsStore` (`<agentDir>/telegram/c
feeds the session's zone and the timestamp header. `Scheduling/TelegramJobDelivery` implements `IJobDelivery` (texts
through the chat's `IChatChannel`, prompts as session follow-ups with the timestamp header, never through the inbox)
and `SchedulerHostedService` starts the scheduler; `/jobs` and `/cancel` list and cancel a chat's jobs.
Groups (`Groups/`, `Triggers/`, on only with `OPENROUTER_API_KEY`): the router hands group messages and
`my_chat_member` updates to `GroupFeed` before the allow-list; a group is watched once an allowed user added the bot
or posted there (`GroupRegistry`, `<agentDir>/telegram/groups.json`). Groups never get a session and the bot never
writes there. Each private chat's `trigger` tool manages its topic triggers (`TriggerStore`, workspace
`.volcanic/triggers/triggers.json`); `TriggerEngine` asks TypeSafe's Jev (`JevClient`, OpenRouter `/systemone`, a
`noul` question per trigger, 3 retries) about every message and logs one line per message; matches are batched for 10 s
by `TriggerDispatcher` and delivered as follow-up prompts (`TelegramTriggerDelivery`); snoozed triggers queue messages
(`SnoozeQueue`) and check them as one batch when the snooze ends. `/triggers` lists them.
`ADMIN_USER_ID` (`Telegram:AdminUserId`) may `/impersonate <user_id>` (`Chats/Impersonation`, in memory, `0` stops): the
router then handles the admin's updates as the target's, and the target chat's renderer delivers to the admin's chat
(`ChatRenderer.DeliveryChatId`); the target's own updates get `Impersonation.BlockedNotice` instead. Answers go out complete, not streamed (`Rendering/`). Tests drive the host through `Support/FakeBotClient` and `ChatHost`.

View File

@@ -21,6 +21,8 @@ services:
# Optional features; empty means off / default.
- OPENAI_API_KEY=${OPENAI_API_KEY:-} # speech to text (attachments spec)
- STT_MODEL=${STT_MODEL:-}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} # group topic triggers via Jev (group triggers spec)
- TRIGGER_MODEL=${TRIGGER_MODEL:-}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} # Google Calendar (calendar spec)
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- ADMIN_USER_ID=${ADMIN_USER_ID:-} # may /impersonate other users (telegram-bot.md)

View File

@@ -74,6 +74,8 @@ The compose file maps them into the container.
| `LOG_LEVEL` | `Information` | Passed as `Logging__LogLevel__Default` | Yes |
| `OPENAI_API_KEY` | — | Turns on speech to text for voice notes | No, and redacted |
| `STT_MODEL` | `gpt-4o-transcribe` | Transcription model | Yes |
| `OPENROUTER_API_KEY` | — | Turns on group watching with topic triggers, classified by Jev through OpenRouter ([telegram-bot.md](telegram-bot.md#groups-and-triggers)) | No, and redacted |
| `TRIGGER_MODEL` | `typesafe/jev-1.13` | The Jev model triggers use | Yes |
| `GOOGLE_CLIENT_ID` | — | With the secret, turns on Google Calendar ([google-calendar.md](google-calendar.md)) | Yes |
| `GOOGLE_CLIENT_SECRET` | — | See above | No, and redacted |
| `DEFAULT_TIMEZONE` | the container's `TZ` | IANA zone for chats that never ran `/timezone`, e.g. `Europe/Berlin`. An unknown name stops startup | Yes |
@@ -101,7 +103,7 @@ MCP servers:
- anything matching the default deny rules: `*_API_KEY`, `*_APIKEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD` and their
`Section__Key` forms (`*__APIKEY`, `*__*TOKEN`, `*__*SECRET`, `*__*PASSWORD`), plus `TELEGRAM_BOT_TOKEN`,
`OPENAI_API_KEY`, `GOOGLE_CLIENT_SECRET`, `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN` and `CLAUDE_CODE_OAUTH_TOKEN`;
- any variable whose value is the bot token, the speech API key or the Google client secret, whatever its name.
- any variable whose value is the bot token, the speech or OpenRouter API key or the Google client secret, whatever its name.
Those values are also replaced by `[REDACTED]` in tool output. The startup log names the rules, never values:
`Process environment: 15 deny rules, allow: none`.

View File

@@ -1,7 +1,7 @@
# Telegram groups and topic triggers (design)
Date: 2026-10-05
Status: approved, not implemented
Status: implemented
Depends on: `2026-09-26-scheduler-design.md` (the tool / store / follow-up delivery pattern this copies);
`2026-09-26-telegram-message-context-design.md` (`MessageContext.TimestampHeader`).
@@ -72,7 +72,7 @@ Content-Type: application/json
- A `noul` is the probability (0..1) that the answer is yes. A trigger matches when `noul >= threshold`.
- `questions` must not be empty (400 `At least one question is required`). Question ids are ours and are not sent to
the model; we use the trigger id.
the model; we use `q0..qn` and map them back (two owners can both have a `t1` on one group).
- `state` may be a string or any JSON. Context is 32k tokens.
- One request per group message carries **every** active trigger watching that group as separate questions
(TypeSafe's fan-out pattern: same answers, one call).

View File

@@ -2,7 +2,8 @@
`hosts/Volcanic.AgenticCore.Telegram` lets you chat with the agent from Telegram. It uses long polling (no public
URL, no webhook), gives every private chat its own agent session and workspace, and runs the model through the
Claude Code provider (`claude-code/claude-opus-5-5`) on your Claude subscription.
Claude Code provider (`claude-code/claude-opus-5-5`) on your Claude subscription. In groups it stays silent and only
watches for the topics its users set up as [triggers](#groups-and-triggers).
## Setup
@@ -45,6 +46,16 @@ settings use the `GoogleCalendar` section, the scheduler the `Scheduler` section
| `STT_MODEL` / `Telegram:Speech:Model` | `gpt-4o-transcribe` | `gpt-4o-mini-transcribe` and `whisper-1` also work |
| `Telegram:Speech:BaseUrl` | `https://api.openai.com/v1` | Any OpenAI-compatible transcription endpoint |
| `Telegram:Speech:Language` | — | Optional ISO-639-1 hint, e.g. `de` |
| `OPENROUTER_API_KEY` / `Telegram:Triggers:ApiKey` | — | Turns on [groups and triggers](#groups-and-triggers). Hidden from `bash` and redacted from tool output |
| `TRIGGER_MODEL` / `Telegram:Triggers:Model` | `typesafe/jev-1.13` | The Jev model that classifies group messages |
| `Telegram:Triggers:BaseUrl` | `https://openrouter.ai/api/v1` | Requests go to `{BaseUrl}/systemone` |
| `Telegram:Triggers:DefaultThreshold` | `0.7` | Yes-probability a message needs when the trigger sets none |
| `Telegram:Triggers:Debounce` | `00:00:10` | Matches of one trigger within this window (opened by the first) reach the chat as one prompt |
| `Telegram:Triggers:MaxRetries` | `3` | Retries after a failed Jev call (network errors, timeouts, 408, 429, 5xx) |
| `Telegram:Triggers:MaxConcurrency` | `4` | Jev calls in flight at once |
| `Telegram:Triggers:MaxTriggersPerChat` | `20` | Triggers per private chat |
| `Telegram:Triggers:MaxSnooze` | `30.00:00:00` | Longest snooze |
| `Telegram:Triggers:MaxQueuedPerTrigger` | `5000` | Messages kept per snoozed trigger; past it the oldest are dropped |
| `Telegram:InlineImageMaxBytes` | `3750000` | Image files up to this size are also shown to the model; must be positive |
| `GOOGLE_CLIENT_ID` / `GoogleCalendar:ClientId` | — | Optional. With the secret, every chat gets the `calendar` tool and the `/calendar_*` commands; see [google-calendar.md](google-calendar.md) |
| `GOOGLE_CLIENT_SECRET` / `GoogleCalendar:ClientSecret` | — | Optional; redacted from tool output and hidden from `bash` |
@@ -206,6 +217,50 @@ the current format, and keeps the original next to it as `jobs.json.secretary-<t
Copy the workspace's `scripts/` into the owner's workspace too: watcher commands such as `python scripts/check.py` run
from there. A one-off that is already due fires on the first start.
### Groups and triggers
With `OPENROUTER_API_KEY` set, the bot can sit in Telegram groups and tell you, in your private chat, when something
you care about comes up. It never writes in a group and never answers there; a group gets no agent session.
**Setup, once per bot and group**
1. In [@BotFather](https://t.me/BotFather), `/setprivacy` → your bot → **Disable**. With privacy mode on, a bot only
sees commands, mentions and replies in groups (the startup log warns about it). Alternatively make the bot a group
admin. Telegram applies the change to groups the bot joins afterwards, so re-add it to groups it is already in.
2. Add the bot to the group. It watches a group only if an allowed user added it, or an allowed user posts there once
while it is a member; groups anyone else adds it to are ignored. Removing the bot pauses the group's triggers.
**Triggers**
Ask the bot in your private chat, for example "tell me when someone in Dev Team talks about deploys or outages". The
agent uses its `trigger` tool: it lists the watched groups, writes a yes/no question about one message with what counts
and what doesn't, and confirms it with you before saving. You must be a member of the group. `/triggers` lists your
triggers.
Every message in a watched group (text or caption; voice notes, files and stickers without a caption are skipped) goes
to TypeSafe's [Jev](https://openrouter.ai/typesafe/jev-1.13), a fast classifier, through OpenRouter's `/systemone`
endpoint: one call per message carries every trigger on that group as its own question, with the message, its sender,
the group and the message it replies to. Jev returns the probability that the answer is yes; a trigger matches at or
above its threshold (0.7 unless set). Matches of one trigger within 10 s reach your chat as one prompt listing each
message with its score and a link, and the agent decides what to tell you. A failed call is retried 3 times.
Matches carry their scores, so tuning is conversational: say a match wasn't relevant and the agent tightens the
question or raises the threshold; describe a message it missed and it lowers the threshold or broadens the question.
**Snooze.** "Keep quiet until tonight" snoozes a trigger (or all of yours) for a duration or until a time. Meanwhile
messages are collected in your workspace (`.volcanic/triggers/snoozed/`), and when the snooze ends they go to Jev as
one batch; if anything is related you get one prompt with the whole batch (a large one is saved under
`.volcanic/triggers/digests/` and the prompt points to it). Unrelated batches stay silent. A snooze that ends while
the bot is down is handled when it starts.
**Logs.** Every group message gets one log line with its Jev results, for example
`Group "Dev Team" (-100123…) msg 789 from Alice (@alice): "prod deploy is failing…" → 1001/t1 deploys=0.93 MATCH`,
or why it wasn't classified (unknown group, no text, no triggers, snoozed, Jev failed). Message texts are cut to 200
characters there.
Watched groups live in `<agent dir>/telegram/groups.json`; each chat's triggers in its workspace under
`.volcanic/triggers/triggers.json`.
### Impersonation
The admin (`ADMIN_USER_ID`) can act as another user with `/impersonate <user_id>`; `/impersonate 0` (or the admin's
@@ -232,7 +287,9 @@ storage and time zones are in [google-calendar.md](google-calendar.md).
## Security
The agent has the built-in tools, including `bash`, and runs them as the bot's OS user. Only allow-listed user ids
are served (everyone else is ignored and logged), and only in private chats. `ADMIN_USER_ID` can act as any
are served (everyone else is ignored and logged), and only in private chats. In groups the bot only classifies
messages for triggers: it never runs the agent for a group member, and the messages of everyone in a watched group
(not only allowed users) are sent to OpenRouter and logged. `ADMIN_USER_ID` can act as any
allow-listed user with `/impersonate`, so it gets that user's workspace, sessions, jobs and connected calendar. Run it in the container image
([docker.md](docker.md)), which is the recommended setup, or at least as a dedicated user, and treat the workspace root
as writable by anyone on the allow-list.