docs(spec): Telegram groups and Jev topic triggers
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
# Telegram groups and topic triggers (design)
|
||||
|
||||
Date: 2026-10-05
|
||||
Status: approved, not 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`).
|
||||
|
||||
## Problem
|
||||
|
||||
The bot only works in private chats: `UpdateRouter` drops every message from a group. The owner wants the bot to sit
|
||||
in groups silently and tell them, in their private chat, when something they care about comes up ("anything about
|
||||
deploys or outages in Dev Team"). The bot must never react in a group by default; it only watches. Whether a message
|
||||
matters is decided by a cheap classifier, TypeSafe's Jev (`typesafe/jev-1.13`) through OpenRouter, not by the agent's
|
||||
model. Matches go through the owner's agent, which decides how to tell them.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Matches go to the trigger owner's private chat**, never to the group. The bot never posts in a group. Groups get
|
||||
no `AgentSession`, no `ChatRegistry` entry and no workspace.
|
||||
- **Groups are known when an allowed user vouches for them**: an allowed user adds the bot (a `my_chat_member` update
|
||||
whose `from` is allow-listed), or an allowed user posts in a group the bot is already in. Messages from unknown
|
||||
groups are dropped.
|
||||
- **Triggers are set up in conversation** in the owner's private chat through a `trigger` tool, like `schedule`. The
|
||||
owner must be a member of the group (`getChatMember`) to create a trigger on it.
|
||||
- **Classification input is one message plus its immediate context**: text or caption, the replied-to message's
|
||||
sender and text, the sender's name and the group title. Messages without text (voice, files, stickers without a
|
||||
caption) are skipped; group voice notes are not transcribed.
|
||||
- **Live matches are debounced per trigger** for 10 s (a fixed window opened by the first match, so a busy group
|
||||
can't hold a match back forever) and delivered as one follow-up prompt.
|
||||
- **No history buffer**: group messages are not stored for testing triggers. Tuning happens on live matches (each
|
||||
carries its score) and on misses the owner describes. The only group messages on disk are snooze queues (below),
|
||||
deleted after each flush.
|
||||
- **Snooze**: the agent can snooze one trigger or all of the chat's triggers for any duration. Messages arriving
|
||||
during a snooze are queued, and when it ends they go to Jev as **one** batch; a match delivers one prompt with the
|
||||
whole batch.
|
||||
- **Every group message is logged** with its Jev results (or why it wasn't classified), in the application log.
|
||||
- **Jev calls retry 3 times** (4 attempts) before failing.
|
||||
- **Everything lives in the Telegram host** (`Groups/` and `Triggers/` slices), not in an extension: it is
|
||||
Telegram-specific and has one consumer.
|
||||
- **No key, no feature**: without `OPENROUTER_API_KEY` group messages are dropped as today and the tool isn't
|
||||
registered.
|
||||
|
||||
## Jev through OpenRouter
|
||||
|
||||
TypeSafe's SDK lists OpenRouter as a gateway; the HTTP shape is TypeSafe's `POST /v1/systemone`, verified live on
|
||||
2026-10-05:
|
||||
|
||||
```http
|
||||
POST https://openrouter.ai/api/v1/systemone
|
||||
Authorization: Bearer $OPENROUTER_API_KEY
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"state": { "group": "Dev Team", "sender": "Alice (@alice)", "text": "prod deploy is failing again",
|
||||
"reply_to": { "sender": "Bob", "text": "did the migration run?" } },
|
||||
"model": "typesafe/jev-1.13",
|
||||
"questions": {
|
||||
"t3": { "type": "noul",
|
||||
"instructions": "Is this message about software deployments, releases or production outages?",
|
||||
"criteria": { "true": "Mentions deploying, releasing, rollbacks or production incidents",
|
||||
"false": "Unrelated chat, lunch, small talk" } },
|
||||
"t7": { "type": "noul", "instructions": "Is this message about lunch plans?" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{ "model": "typesafe/jev-1.13-20260917",
|
||||
"answers": { "t3": { "type": "noul", "noul": 0.99 }, "t7": { "type": "noul", "noul": 0.01 } },
|
||||
"usage": { "input_tokens": 387, "output_tokens": 38, "cost": 0.000016254 }, "id": "gen-…", "provider": "TypeSafe" }
|
||||
```
|
||||
|
||||
- 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.
|
||||
- `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).
|
||||
- A snooze batch uses `state = { "group": …, "messages": [ { "time", "sender", "text", "reply_to"? }, … ] }` and wraps
|
||||
the trigger's single-message question in a fixed batch phrasing:
|
||||
`instructions = "Answer yes if at least one message in the batch passes this check: " + instructions`,
|
||||
`criteria.true = "At least one message: " + true`, `criteria.false = "No message does; all are like: " + false`.
|
||||
Verified: a batch with one outage message among chatter scored 0.99; chatter mentioning a game "release" scored 0.12
|
||||
(the unwrapped question scored 0.92 and 0.04, so wrapping matters).
|
||||
|
||||
## Configuration
|
||||
|
||||
`Telegram:Triggers` (`TriggerOptions`, shaped like `SpeechOptions`):
|
||||
|
||||
| Setting | Default | Env |
|
||||
|---|---|---|
|
||||
| `ApiKey` | — (feature off when empty) | `OPENROUTER_API_KEY` |
|
||||
| `Model` | `typesafe/jev-1.13` | `TRIGGER_MODEL` |
|
||||
| `BaseUrl` | `https://openrouter.ai/api/v1` | |
|
||||
| `DefaultThreshold` | `0.7` | |
|
||||
| `Debounce` | `00:00:10` | |
|
||||
| `MaxRetries` | `3` (4 attempts) | |
|
||||
| `MaxConcurrency` | `4` parallel Jev calls | |
|
||||
| `MaxTriggersPerChat` | `20` | |
|
||||
| `MaxSnooze` | `30.00:00:00` | |
|
||||
| `MaxQueuedPerTrigger` | `5000` messages | |
|
||||
|
||||
The environment fills only empty settings. `Validate()` checks `BaseUrl` is absolute http(s), thresholds in (0, 1),
|
||||
durations and counts positive. `ApiKey` is added to the process environment's `SecretValues`, so tool output never
|
||||
shows it. `BotSetupService` logs a warning at startup when `getMe().CanReadAllGroupMessages` is false (privacy mode
|
||||
on: the bot only sees commands, mentions and replies in groups).
|
||||
|
||||
## Groups (`Groups/`)
|
||||
|
||||
- **Polling** adds `UpdateType.MyChatMember` to `AllowedUpdates`.
|
||||
- **`GroupRegistry`** persists known groups in `<agentDir>/telegram/groups.json`:
|
||||
`{ "version": 1, "groups": [ { "id", "title", "username"?, "type", "addedBy", "since", "active" } ] }`, written
|
||||
atomically like `ChatSettingsStore`.
|
||||
- `my_chat_member` with new status `member`/`administrator` from an allowed user: add (or reactivate).
|
||||
- `my_chat_member` with `left`/`kicked`: mark inactive. Its triggers are kept but nothing is classified.
|
||||
- A message in a group from an allowed user: add if unknown. Any message refreshes `title`/`username`.
|
||||
- `migrate_to_chat_id` (group → supergroup): the group's id changes in the registry and in every trigger watching
|
||||
it (`TriggerStore.MoveGroup`).
|
||||
- **`UpdateRouter`** splits `Group`/`Supergroup` messages off at the top of `OnMessageAsync`, **before** the
|
||||
allow-list check (non-allowed members' messages must be classified too), and hands them to `GroupFeed`. Nothing from
|
||||
a group reaches the inbox, commands, the UI or a reply. `my_chat_member` updates go to `GroupRegistry`. Channels stay
|
||||
ignored. Callback queries are unchanged.
|
||||
- **`GroupFeed`** builds the `GroupMessage` (`groupId`, `messageId`, `time`, `sender` "First Last (@username)",
|
||||
`text`, `replyTo`), skips the bot's own messages, vouches the group if the sender is allowed, and passes the message
|
||||
to `TriggerEngine` without blocking polling.
|
||||
|
||||
Private-chat assumptions (`TelegramJobDelivery.IsDeliverable`, `Impersonation`) are untouched: groups never become
|
||||
chats.
|
||||
|
||||
## Triggers (`Triggers/`)
|
||||
|
||||
### Model and store
|
||||
|
||||
```json
|
||||
{ "version": 1, "triggers": [ {
|
||||
"id": "t3", "name": "deploys", "groupId": -1001234567890, "groupTitle": "Dev Team",
|
||||
"instructions": "Is this message about software deployments, releases or production outages?",
|
||||
"criteriaTrue": "…", "criteriaFalse": "…",
|
||||
"threshold": 0.7, "enabled": true, "snoozedUntil": null, "createdAt": "2026-10-05T12:00:00Z" } ] }
|
||||
```
|
||||
|
||||
- `TriggerStore` keeps each owner's triggers in `<ownerWorkspace>/.volcanic/triggers/triggers.json` (atomic writes).
|
||||
Ids are `t<n>`, unique per owner chat. On startup it scans `<WorkspaceRoot>/*/.volcanic/triggers/` and builds the
|
||||
in-memory index `groupId → [(ownerChatId, trigger)]`; every change updates file and index under one lock.
|
||||
- `criteriaTrue`/`criteriaFalse` are optional; Jev gets `criteria` only when both are set.
|
||||
|
||||
### Classification (`JevClient`, `TriggerEngine`)
|
||||
|
||||
- `JevClient` is a typed `HttpClient` (registered like `OpenAiSpeechToText`), `Task<IReadOnlyDictionary<string,
|
||||
double>> AskAsync(JsonNode state, IReadOnlyList<JevQuestion> questions, ct)`.
|
||||
- Retries: 1 attempt + `MaxRetries` (3) on network errors, timeouts, 408, 429 and 5xx, backoff 1 s, 2 s, 4 s; a
|
||||
429's `Retry-After` is honoured (capped at 30 s). Other 4xx fail at once. A 200 missing an answer for a question is
|
||||
a failure.
|
||||
- Errors never contain the key (redacted like `OpenAiSpeechToText.Hide`).
|
||||
- `TriggerEngine.OnMessage(GroupMessage)` (concurrency limited by `MaxConcurrency`):
|
||||
1. No text → log `no text`, stop. Unknown or inactive group → log, stop.
|
||||
2. Split the group's triggers: disabled are ignored; snoozed get the message appended to their queue; the rest are
|
||||
asked in one Jev call.
|
||||
3. For each answer with `noul >= threshold`, hand `(owner, trigger, message, score)` to `TriggerDispatcher`.
|
||||
- Failure after all attempts: the message is logged as failed and dropped; triggers are not retried later.
|
||||
|
||||
### Logging
|
||||
|
||||
One `Information` line per group message from `TriggerEngine`, structured (scope properties `GroupId`, `MessageId`):
|
||||
|
||||
```
|
||||
Group "Dev Team" (-1001234567890) msg 789 from Alice (@alice): "prod deploy is failing again…" → t3 deploys=0.93 MATCH, t7 lunch=0.04
|
||||
Group "Dev Team" (-1001234567890) msg 790 from Bob: "ok" → t3 snoozed (queued 12), t7 lunch=0.02
|
||||
Group "Dev Team" (-1001234567890) msg 791 from Carol: (no text) → skipped
|
||||
Group "Dev Team" (-1001234567890) msg 792 from Dan: "…" → Jev failed after 4 attempts: HTTP 503 …
|
||||
Group "Other" (-1009) msg 5 from Eve: skipped, unknown group
|
||||
```
|
||||
|
||||
Text is cut to 200 characters. Batch (snooze) evaluations log one line per chunk with the trigger, message count and
|
||||
score. The key is never logged.
|
||||
|
||||
### Live delivery (`TriggerDispatcher`)
|
||||
|
||||
- Matches are buffered per `(ownerChatId, triggerId)`. The first match arms a `Debounce` (10 s) timer on
|
||||
`TimeProvider`; later matches join the buffer without resetting it. On fire, the buffer becomes one prompt.
|
||||
- `TelegramTriggerDelivery` delivers like `TelegramJobDelivery.RunPromptAsync`: `chats.GetAsync(owner)`, prefix
|
||||
`MessageContext.TimestampHeader`, `Session.PromptAsync(text, null, StreamingBehavior.FollowUp)`.
|
||||
- Prompt:
|
||||
|
||||
```
|
||||
[Trigger "deploys" (t3) matched 2 messages in "Dev Team"]
|
||||
14:02 Alice (@alice), score 0.93: prod deploy is failing again
|
||||
in reply to Bob: did the migration run?
|
||||
https://t.me/c/1234567890/789
|
||||
14:03 Bob, score 0.88: rolling back now
|
||||
https://t.me/c/1234567890/790
|
||||
```
|
||||
|
||||
Times are in the owner's zone. Links: supergroups `https://t.me/c/<id without -100>/<messageId>`, groups with a
|
||||
username `https://t.me/<username>/<messageId>`, basic groups none.
|
||||
|
||||
### Snooze
|
||||
|
||||
- `snoozedUntil` (UTC) is stored on the trigger. `snooze` with `id` snoozes one trigger, without `id` every trigger of
|
||||
the chat (triggers created later are not snoozed). `duration` (`90m`, `2h`, `1d`, `1h30m`) or `until` (local
|
||||
date-time in the chat's zone); capped at `MaxSnooze`. Snoozing an already snoozed trigger moves its end; the queue
|
||||
is kept.
|
||||
- While snoozed, messages for that trigger go to `<ownerWorkspace>/.volcanic/triggers/snoozed/<triggerId>.jsonl`
|
||||
(one `GroupMessage` per line). Past `MaxQueuedPerTrigger` the oldest are dropped, logged once per snooze.
|
||||
- One timer on `TimeProvider` is armed for the soonest `snoozedUntil` (capped at 1 h, re-armed on every change, as in
|
||||
`JobScheduler`). On expiry, or `unsnooze`, the trigger's snooze is cleared and its queue flushed:
|
||||
- Empty queue: nothing.
|
||||
- Otherwise the messages are split into chunks of at most 40,000 characters of serialised JSON (well under 32k
|
||||
tokens even for Cyrillic), each chunk one batch Jev call (see "Jev through OpenRouter"). The batch matches when
|
||||
any chunk's `noul >= threshold`.
|
||||
- Match: one prompt per trigger, bypassing the 10 s debounce:
|
||||
|
||||
```
|
||||
[Trigger "deploys" (t3): snooze ended, 37 messages from "Dev Team" since 12:00 are related (score 0.97)]
|
||||
12:04 Carol: anyone for pizza?
|
||||
…
|
||||
```
|
||||
|
||||
It contains the whole batch (the agent picks out the relevant part). Above 30,000 characters the batch is written
|
||||
to `<ownerWorkspace>/.volcanic/triggers/digests/<triggerId>-<yyyyMMddHHmmss>.md` and the prompt gives its path and
|
||||
message count instead.
|
||||
- No match: silence (logged).
|
||||
- The queue file is deleted after the flush whether it matched or not, and after a Jev failure (logged with the
|
||||
count lost).
|
||||
- On startup, snoozes that ended while the host was down are flushed at once.
|
||||
- A deleted trigger's queue is deleted; a disabled trigger's queue is kept until its snooze ends, then dropped.
|
||||
|
||||
## The `trigger` tool
|
||||
|
||||
Registered per private chat by `TriggerExtension(chatId, …)` in `ChatRegistry.CreateAsync`'s extra extensions (like
|
||||
`ChatSettingsExtension`), only when the feature is on. `Tool<TriggerArgs>`:
|
||||
|
||||
| Action | Arguments | Result |
|
||||
|---|---|---|
|
||||
| `groups` | — | known active groups: title, id, whether the owner has triggers there |
|
||||
| `create` | `group` (title or id), `name`, `instructions`, `criteria_true`?, `criteria_false`?, `threshold`? | the trigger |
|
||||
| `list` | — | the chat's triggers with threshold, enabled, snooze end and queued count |
|
||||
| `update` | `id` + any of `name`, `instructions`, `criteria_true`, `criteria_false`, `threshold`, `enabled` | the trigger |
|
||||
| `delete` | `id` | — |
|
||||
| `snooze` | `id`?, `duration` or `until` | the snoozed triggers and their end |
|
||||
| `unsnooze` | `id`? | flushes now; reports whether the batch matched |
|
||||
|
||||
- `group` resolves by id, then exact title, then case-insensitive unique substring; ambiguity lists candidates.
|
||||
- `create` refuses if the owner isn't a member of the group (`getChatMember` status `left`/`kicked`, or the call
|
||||
fails), when the chat already has `MaxTriggersPerChat`, or when the group is inactive.
|
||||
- Errors are `ToolExecutionException`s like `ScheduleTool`.
|
||||
- Prompt guidelines:
|
||||
- Turn the owner's request into a yes/no question about **one message**, with concrete `true`/`false` criteria;
|
||||
restate it in plain language and confirm before `create`.
|
||||
- Matches arrive as prompts with scores. If the owner says one wasn't relevant: when it scored well above the
|
||||
threshold, tighten the criteria (the question is ambiguous); when it was near the threshold, raise the threshold.
|
||||
If the owner reports a miss, lower the threshold or broaden the criteria. Always say what changed.
|
||||
- Snooze when the owner asks for quiet ("not until tonight", "only once an hour"); for "once an hour" re-snooze after
|
||||
each digest.
|
||||
- Never post in a group.
|
||||
|
||||
`/triggers` lists the chat's triggers (same text as `list`), registered in the bot menu and `/help`.
|
||||
|
||||
## Error handling summary
|
||||
|
||||
| Situation | Behaviour |
|
||||
|---|---|
|
||||
| No `OPENROUTER_API_KEY` | feature off, group messages dropped, logged once at startup |
|
||||
| Privacy mode on | startup warning; works on what Telegram delivers |
|
||||
| Jev fails after 4 attempts (live) | log, drop the message |
|
||||
| Jev fails on a snooze flush | log with the count lost, delete the queue |
|
||||
| Owner chat delivery fails | logged (as `TelegramJobDelivery`) |
|
||||
| Bot removed from group | group inactive, triggers kept and silent |
|
||||
| Group upgraded to supergroup | registry and triggers move to the new id |
|
||||
|
||||
## Testing
|
||||
|
||||
- `GroupRegistry`: vouching by `my_chat_member` and by an allowed poster, unknown group dropped, leave/kick,
|
||||
migration, persistence.
|
||||
- `UpdateRouter` via `FakeBotClient`: group messages from non-allowed users reach the feed, nothing is ever sent to a
|
||||
group, private chats unchanged.
|
||||
- `JevClient` against a fake `HttpMessageHandler`: request shape (fan-out, criteria only when both set), noul parsing,
|
||||
3 retries then failure, `Retry-After`, no retry on 400, key never in errors.
|
||||
- `TriggerEngine`/`TriggerDispatcher` on a fake `TimeProvider`: threshold, fixed 10 s window, one prompt per trigger,
|
||||
log lines.
|
||||
- Snooze: queueing, chunking, batch phrasing, match and no-match, deletion, overdue flush at startup, `MaxQueued`.
|
||||
- Tool: every action, group resolution, membership refusal, limits.
|
||||
- No test calls OpenRouter.
|
||||
|
||||
## Docs
|
||||
|
||||
- `docs/telegram-bot.md`: "Groups and triggers" (BotFather `/setprivacy` → Disable, or make the bot an admin; adding
|
||||
the bot; setting triggers in conversation; snooze; logs), and the private-only paragraph updated.
|
||||
- `docs/docker.md`: `OPENROUTER_API_KEY`, `TRIGGER_MODEL`.
|
||||
- `CLAUDE.md`: the Telegram host summary.
|
||||
Reference in New Issue
Block a user