diff --git a/CLAUDE.md b/CLAUDE.md index 074537f..5f90469 100644 --- a/CLAUDE.md +++ b/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 `/` and session scope `telegram-`. 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` (`/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`, `/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 ` (`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`. diff --git a/docker-compose.yml b/docker-compose.yml index 3aac073..3101e1f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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) diff --git a/docs/docker.md b/docs/docker.md index 21de767..38486e0 100644 --- a/docs/docker.md +++ b/docs/docker.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`. diff --git a/docs/superpowers/specs/2026-10-05-telegram-group-triggers-design.md b/docs/superpowers/specs/2026-10-05-telegram-group-triggers-design.md index bc614ec..12c16c0 100644 --- a/docs/superpowers/specs/2026-10-05-telegram-group-triggers-design.md +++ b/docs/superpowers/specs/2026-10-05-telegram-group-triggers-design.md @@ -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). diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index 3e72ae4..f34d2bd 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -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-/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 `; `/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.