Files
Playa/docs/superpowers/specs/2026-10-05-telegram-group-triggers-design.md
2026-10-05 16:29:07 +00:00

291 lines
17 KiB
Markdown

# Telegram groups and topic triggers (design)
Date: 2026-10-05
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`).
## 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 `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).
- 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.