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`)
|
### 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
|
(`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
|
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
|
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
|
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)
|
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.
|
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
|
`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
|
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`.
|
(`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.
|
# Optional features; empty means off / default.
|
||||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-} # speech to text (attachments spec)
|
- OPENAI_API_KEY=${OPENAI_API_KEY:-} # speech to text (attachments spec)
|
||||||
- STT_MODEL=${STT_MODEL:-}
|
- 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_ID=${GOOGLE_CLIENT_ID:-} # Google Calendar (calendar spec)
|
||||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||||
- ADMIN_USER_ID=${ADMIN_USER_ID:-} # may /impersonate other users (telegram-bot.md)
|
- 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 |
|
| `LOG_LEVEL` | `Information` | Passed as `Logging__LogLevel__Default` | Yes |
|
||||||
| `OPENAI_API_KEY` | — | Turns on speech to text for voice notes | No, and redacted |
|
| `OPENAI_API_KEY` | — | Turns on speech to text for voice notes | No, and redacted |
|
||||||
| `STT_MODEL` | `gpt-4o-transcribe` | Transcription model | Yes |
|
| `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_ID` | — | With the secret, turns on Google Calendar ([google-calendar.md](google-calendar.md)) | Yes |
|
||||||
| `GOOGLE_CLIENT_SECRET` | — | See above | No, and redacted |
|
| `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 |
|
| `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
|
- 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`,
|
`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`;
|
`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:
|
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`.
|
`Process environment: 15 deny rules, allow: none`.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Telegram groups and topic triggers (design)
|
# Telegram groups and topic triggers (design)
|
||||||
|
|
||||||
Date: 2026-10-05
|
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);
|
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`).
|
`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`.
|
- 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
|
- `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.
|
- `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
|
- One request per group message carries **every** active trigger watching that group as separate questions
|
||||||
(TypeSafe's fan-out pattern: same answers, one call).
|
(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
|
`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
|
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
|
## 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 |
|
| `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:BaseUrl` | `https://api.openai.com/v1` | Any OpenAI-compatible transcription endpoint |
|
||||||
| `Telegram:Speech:Language` | — | Optional ISO-639-1 hint, e.g. `de` |
|
| `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 |
|
| `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_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` |
|
| `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
|
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.
|
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
|
### Impersonation
|
||||||
|
|
||||||
The admin (`ADMIN_USER_ID`) can act as another user with `/impersonate <user_id>`; `/impersonate 0` (or the admin's
|
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
|
## Security
|
||||||
|
|
||||||
The agent has the built-in tools, including `bash`, and runs them as the bot's OS user. Only allow-listed user ids
|
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
|
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
|
([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.
|
as writable by anyone on the allow-list.
|
||||||
|
|||||||
Reference in New Issue
Block a user