feat(telegram): trigger options and Jev client for OpenRouter systemone
This commit is contained in:
111
docs/superpowers/plans/2026-10-05-telegram-group-triggers.md
Normal file
111
docs/superpowers/plans/2026-10-05-telegram-group-triggers.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Telegram groups and topic triggers Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** The Telegram bot watches groups silently and, through per-owner topic triggers classified by Jev (OpenRouter
|
||||
`/systemone`), prompts the owner's private session when a group message is relevant; triggers can be snoozed.
|
||||
|
||||
**Architecture:** Everything in `hosts/Volcanic.AgenticCore.Telegram`: a `Groups/` slice (known groups, the group
|
||||
message feed) and a `Triggers/` slice (options, Jev client, store, snooze queue, engine, debounce dispatcher, delivery,
|
||||
tool, `/triggers`). The router splits group messages off before the allow-list. Spec:
|
||||
`docs/superpowers/specs/2026-10-05-telegram-group-triggers-design.md`.
|
||||
|
||||
**Tech Stack:** .NET 10, Telegram.Bot 22.10, xunit 2, `FakeTimeProvider`, the host test harness (`ChatHost`,
|
||||
`FakeBotClient`, `FauxProvider`).
|
||||
|
||||
Conventions for every task: `TreatWarningsAsErrors` is on for the host (use `ConfigureAwait(false)`, culture-invariant
|
||||
formatting, `ArgumentNullException.ThrowIfNull` on public entry points). Run tests with
|
||||
`dotnet test tests/Volcanic.AgenticCore.Telegram.Tests --filter "FullyQualifiedName~<Class>"`. Commit after each task
|
||||
with a Conventional Commit scoped `telegram`.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `Triggers/TriggerOptions.cs` | `Telegram:Triggers` settings, env, validation, `Describe()` |
|
||||
| `Triggers/JevClient.cs` | `IJevClient`, `JevQuestion`, `JevException`, HTTP client with retries |
|
||||
| `Groups/GroupMessage.cs` | `GroupMessage`, `GroupReply` records, `From(Message)` builder, link |
|
||||
| `Groups/GroupRegistry.cs` | `KnownGroup`, `groups.json` persistence, vouch / inactive / migrate |
|
||||
| `Triggers/TriggerStore.cs` | `Trigger`, `WatchingTrigger`, per-owner `triggers.json`, group index |
|
||||
| `Triggers/SnoozeQueue.cs` | per-trigger `snoozed/<id>.jsonl`: append (capped), take-all, count, delete |
|
||||
| `Triggers/JevStates.cs` | Jev `state` JSON for one message / a batch, chunking, batch question wrapping |
|
||||
| `Triggers/TriggerPrompts.cs` | owner prompts (live, snooze), trigger list lines, durations |
|
||||
| `Triggers/TriggerDispatcher.cs` | 10 s fixed-window buffering per (owner, trigger) |
|
||||
| `Triggers/ITriggerDelivery.cs` + `TelegramTriggerDelivery.cs` | follow-up prompt into the owner's session |
|
||||
| `Triggers/TriggerEngine.cs` | classify + log each message, snooze queueing, snooze timer and flush |
|
||||
| `Groups/GroupFeed.cs` | router entry for group messages and `my_chat_member`; concurrency gate |
|
||||
| `Groups/GroupMembership.cs` | `IGroupMembership` (`getChatMember`) |
|
||||
| `Triggers/TriggerTool.cs` + `TriggerExtension.cs` | the `trigger` tool per private chat |
|
||||
| `Triggers/TriggerServices.cs` | the bundle the registry, commands and tool take |
|
||||
| `Triggers/TriggersSetup.cs` | DI registration, menu entry, help line, hosted service |
|
||||
| modified | `TelegramOptions`, `TelegramProcessEnvironment`, `PollingService`, `UpdateRouter`, `ChatRegistry`, `ChatCommands`, `BotSetupService`, `Program.cs` |
|
||||
| tests | `TriggerOptionsTests`, `JevClientTests`, `GroupRegistryTests`, `TriggerStoreTests`, `TriggerEngineTests`, `TriggerToolTests`, `GroupChatTests`; `FakeBotClient` answers `getChatMember`; `ChatHost` takes `triggers:` |
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Options and secrets
|
||||
- `TriggerOptions` (defaults from the spec table; `internal TimeSpan RetryDelay = 1 s`, `MaxRetryAfter = 30 s`,
|
||||
`ChunkChars = 40_000`, `DigestChars = 30_000`), `IsEnabled`, `ResolvedModel`, `Describe()`, `Validate(List<string>)`.
|
||||
- `TelegramOptions.Triggers`; `ApplyEnvironment` reads `OPENROUTER_API_KEY`, `TRIGGER_MODEL`; `Validate` calls it.
|
||||
- `TelegramProcessEnvironment`: the key goes to `SecretValues`.
|
||||
- Tests: env fills only empty settings; validation errors for bad URL / threshold / non-positive values; secret added.
|
||||
|
||||
### Task 2: Jev client
|
||||
- `IJevClient.AskAsync(JsonNode state, IReadOnlyList<JevQuestion>, ct) → IReadOnlyDictionary<string,double>`.
|
||||
- POST `{BaseUrl}/systemone`, body `{state, model, questions:{id:{type:"noul", instructions, criteria?}}}`; criteria only
|
||||
when both set. 1 + `MaxRetries` attempts on network error, timeout, 408, 429, 5xx; backoff `RetryDelay·2^(n-1)`;
|
||||
`Retry-After` honoured up to `MaxRetryAfter`. `JevException.Attempts`. Missing answer = failure. Key redacted.
|
||||
- Tests with a scripted `HttpMessageHandler`: request shape, parsing, 3 retries then failure (4 calls), 400 not retried,
|
||||
missing answer, key never in the message.
|
||||
|
||||
### Task 3: Group registry and messages
|
||||
- `GroupMessage.From(Message, DateTimeOffset)`: sender `First Last (@user)` or `SenderChat.Title`; text or caption;
|
||||
reply sender/text; `Link` (supergroup `t.me/c/<id-without--100>/<mid>`, username `t.me/<user>/<mid>`, else null).
|
||||
- `GroupRegistry` (`<agentDir>/telegram/groups.json`): `Find`, `All`, `VouchAsync(chat, by)`, `RefreshAsync(chat)`,
|
||||
`SetInactiveAsync(id)`, `MigrateAsync(from, to)`; corrupt file set aside like `ChatSettingsStore`.
|
||||
- Tests: persistence round trip, vouch reactivates, refresh title, migrate, links.
|
||||
|
||||
### Task 4: Trigger store and snooze queue
|
||||
- `Trigger` record (spec fields), `TriggerStore(workspaceRoot, maxPerChat, logger, time)`: loads every
|
||||
`<root>/<chatId>/.volcanic/triggers/triggers.json`; `List(owner)`, `Find(owner,id)`, `Watching(groupId)`,
|
||||
`AddAsync`, `UpdateAsync(owner,id,Func<Trigger,Trigger>)`, `RemoveAsync`, `MoveGroupAsync`, `NextSnoozeEnd()`,
|
||||
`SnoozeDue(now)`, `WorkspaceOf(owner)`. Ids `t<max+1>`.
|
||||
- `SnoozeQueue(maxPerTrigger)`: `AppendAsync → (count, dropped)`, `TakeAllAsync`, `Count`, `Delete`.
|
||||
- Tests: ids, limits, reload from disk, index after move/remove, queue cap drops oldest, take-all empties.
|
||||
|
||||
### Task 5: Jev states, prompts, dispatcher, delivery
|
||||
- `JevStates.Single`, `JevStates.Batches(group, messages, zone, maxChars)`, `JevStates.Question(id, trigger, batch)`.
|
||||
- `TriggerPrompts.Live`, `TriggerPrompts.Snoozed`, `TriggerPrompts.Line`, `TriggerPrompts.TryParseDuration`,
|
||||
`TriggerPrompts.TryParseUntil`.
|
||||
- `TriggerDispatcher.Add(owner, trigger, message, score)`; window opens on first hit, `Debounce` later one prompt.
|
||||
- `ITriggerDelivery { DeliverAsync(owner, prompt, ct); TimeZoneFor(owner) }`, `TelegramTriggerDelivery` (header +
|
||||
`FollowUp`, errors reported in the chat).
|
||||
- Tests: prompt text, chunking, durations, window not reset by later hits.
|
||||
|
||||
### Task 6: Engine
|
||||
- `TriggerEngine.ProcessAsync(GroupMessage)`: the log line per message; snoozed triggers queue; one Jev call with
|
||||
question ids `q0..qn` mapped back to (owner, trigger); matches to the dispatcher.
|
||||
- Snooze: `SnoozeAsync(owner, id?, until)`, `UnsnoozeAsync(owner, id?)`, timer on `TimeProvider` (≤ 1 h), `StartAsync`
|
||||
flushes overdue, `FlushAsync(owner, trigger)` (take-all, chunks, wrapped question, best score, digest file).
|
||||
- Tests with a fake Jev and fake delivery: match/no match, two owners same group, failure logged, snooze queue then
|
||||
expiry → one prompt, no match → silence, unsnooze, overdue at start, digest file, disabled dropped.
|
||||
|
||||
### Task 7: Feed, router, polling, membership
|
||||
- `GroupFeed.OnMessageAsync` / `OnMyChatMemberAsync`; `UpdateRouter` routes group messages and `MyChatMember` first
|
||||
(feature off: debug log, drop). `PollingService.AllowedUpdates` += `MyChatMember`.
|
||||
- `IGroupMembership` via `bot.GetChatMember`. `FakeBotClient` answers `GetChatMemberRequest` (non-members set).
|
||||
- Tests (`GroupChatTests`): stranger's group message is classified, nothing sent to the group, unknown group ignored,
|
||||
allowed poster vouches, `my_chat_member` vouch/leave, migration, feature off.
|
||||
|
||||
### Task 8: Tool, extension, commands, wiring
|
||||
- `TriggerTool` actions groups/create/list/update/delete/snooze/unsnooze; group resolution; membership and limits.
|
||||
- `TriggerExtension` registered by `ChatRegistry` when `TriggerServices` present; `/triggers` in `ChatCommands`, menu
|
||||
and help; `BotSetupService` privacy warning; `Program.cs` registration and startup log line; hosted service starts
|
||||
the engine.
|
||||
- Tests: tool actions, end-to-end in `ChatHost` (group message → owner session prompt through the faux provider).
|
||||
|
||||
### Task 9: Docs
|
||||
- `docs/telegram-bot.md` "Groups and triggers", `docs/docker.md` env vars, `CLAUDE.md` host summary, spec status.
|
||||
- Full `dotnet build` and `dotnet test`.
|
||||
Reference in New Issue
Block a user