forked from admin/VolcanicAgenticCore
docs(telegram): groups and Jev topic triggers
This commit is contained in:
10
CLAUDE.md
10
CLAUDE.md
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user