diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml index abac00670..45e4b4ff9 100644 --- a/.github/workflows/create-tag.yml +++ b/.github/workflows/create-tag.yml @@ -28,6 +28,7 @@ on: - provider-anthropic - provider-openai - session-manager + - telegram-bot - shell - storage bump: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 801a496df..3b2ece9a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,7 @@ on: - 'provider-anthropic/v*' - 'provider-openai/v*' - 'session-manager/v*' + - 'telegram-bot/v*' - 'shell/v*' - 'storage/v*' workflow_dispatch: diff --git a/README.md b/README.md index b893616ff..296fa4327 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ npx skills add iii-hq/iii --all | [`codex`](codex/) | Rust | OpenAI Codex as an iii worker — `codex::*` spawn the codex CLI for headless turns, mirror raw thread events onto `codex::events`, and stream AgentEvent frames onto `agent::events`. | | [`claude-code`](claude-code/) | Node | Claude Code as an iii worker — `claude::*` runs headless Claude Code turns, mirrors raw messages onto `claude::events`, and streams AgentEvent frames onto `agent::events`. | | [`session-manager`](session-manager/) | Rust | Durable, reactive, branching conversation store — fourteen `session::*` functions plus six trigger types; the transcript backend for `harness` and `console`. See [`session-manager/architecture/`](session-manager/architecture/). | +| [`telegram-bot`](telegram-bot/) | Rust | Telegram webhook bridge to the harness stack — live message edits, inline approval keyboards, and configurable verbosity. | | [`context-manager`](context-manager/) | Rust | Model-ready context assembly — four `context::*` functions for token counting, function-result pruning, and history compaction over caller-supplied messages. Storage-agnostic; summarisation via `llm-router` when installed. | | [`database`](database/) | Rust | PostgreSQL, MySQL, and SQLite client — query, execute, transactions, prepared statements, and change feeds. | | [`iii-directory`](iii-directory/) | Rust | Engine introspection (functions / triggers / workers), workers-registry proxy, and filesystem-backed skill + prompt reader. | diff --git a/console/Cargo.lock b/console/Cargo.lock index 3477f0ddc..f530d70ae 100644 --- a/console/Cargo.lock +++ b/console/Cargo.lock @@ -257,7 +257,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "console" -version = "0.1.5" +version = "0.2.2" dependencies = [ "anyhow", "async-trait", diff --git a/harness/architecture/integration.md b/harness/architecture/integration.md deleted file mode 100644 index 7e167875b..000000000 --- a/harness/architecture/integration.md +++ /dev/null @@ -1,398 +0,0 @@ -# Integrating with harness - -The handoff contract for workers and clients that build a front end on the -harness — chat UIs, Telegram / WhatsApp / Slack bridges, cron and webhook -workers, event-driven agent loops, notification siblings. It is self-contained: -everything needed to integrate is here, with the -[spec](../../tech-specs/2026-06-agentic/harness.md) as the design rationale and -the golden schemas under [../tests/golden/schemas/](../tests/golden/schemas) as -the wire truth. - -Contents: [mental model](#1-mental-model) · -[prerequisites](#2-prerequisites--topology) · [conventions](#3-conventions) · -[functions](#4-function-catalog) · [what to bind](#5-reactive-integration--what-to-bind) · -[patterns](#6-canonical-consumer-patterns) · -[send cookbook](#7-harnesssend-request-cookbook) · [approvals](#8-approvals) · -[compaction](#9-compaction) · [hooks](#10-hooks-sibling-workers-only) · -[errors & recovery](#11-errors-stop-and-recovery) · -[boundaries](#12-boundaries--anti-patterns) · -[reference consumer](#13-console-as-reference-implementation) - -## 1. Mental model - -The harness is the durable turn loop; **a consumer is thin**. Every integration -is the same triangle: - -1. **Kick off** a turn with [`harness::send`](#harnesssend) (fire-and-return) - or [`harness::run`](#harnessrun) (held open until the turn ends, returns the - result). -2. **Render** the conversation by binding `session-manager`'s transcript - events (`session::message-added` / `message-updated` / `status-changed`) and - reconciling by `revision`. The harness streams the assistant message into - the session as it generates; you watch the session, not the harness. -3. **React** to turn boundaries (`harness::turn-completed`) and human-gated - calls (approval-gate's `approval::pending-created` / `pending-resolved`). - -```mermaid -sequenceDiagram - participant C as consumer - participant H as harness - participant S as session-manager - participant G as approval-gate - - C->>H: trigger harness::send {message, model, options} - H-->>C: {session_id, turn_id, accepted} - H->>S: append user, set-status working, append/update assistant - S-->>C: session::message-added / message-updated (render) - opt a call is gated - H->>G: pre-trigger hook → hold - G-->>C: approval::pending-created (render the prompt) - C->>G: trigger approval::resolve {decision} - end - H->>S: set-status done - H-->>C: harness::turn-completed {status, result?} -``` - -**There is no `agent::events` stream.** That was the turn-orchestrator era. The -transcript *is* the stream. Do not poll `harness::status` for transcript -content — it is a point-in-time turn-state read for recovery and guards, not a -render feed. - -## 2. Prerequisites & topology - -The harness depends on three siblings; install the ones your loop needs: - -| Worker | Role | Without it | -|---|---|---| -| [`session-manager`](../../session-manager) | Transcript store + change feed (required) | The loop has nowhere to persist or stream. | -| [`llm-router`](../../llm-router) | Generation + the model catalog (required) | No `router::chat`; nothing to generate. | -| [`context-manager`](../../context-manager) | Token budgeting + compaction (soft) | The harness sends raw history (no compaction). | -| [`approval-gate`](../../approval-gate) | Human-in-the-loop gate (optional) | No `pre-trigger` hold; calls run un-gated under the allow policy. | - -The harness enqueues turn steps on the engine's built-in `default` queue, -provided by `iii-queue` (see [`engine.config.yaml`](../engine.config.yaml)). -Per-session ordering is enforced in-process via session locks, not by the queue. - -**State** the harness keeps (you never touch it directly): `harness_turn/` -(the turn record) and `harness_idem/` (webhook dedupe, TTL-bound). - -## 3. Conventions - -- **Invocation is always a trigger.** In the iii ecosystem every bus call is a - *trigger*: `iii.trigger({ function_id, payload, timeout_ms })` from a worker, - `client.trigger(functionId, payload)` from the browser SDK. There is no - separate "call" verb. -- **Wire ids are kebab-case** in every multi-word segment: `harness::turn-completed`, - `harness::hook::pre-trigger`, `harness::sweep-pending`, `harness::on-config-change`. - Single-word verbs stay bare (`harness::send`, `harness::run`, `harness::stop`, - `harness::status`, `harness::spawn`). -- **Ids** are opaque strings: sessions you supply (`harness::send` `session_id`) - or the harness mints (`s_` / `t_`). Entry ids are deterministic - within a turn (see [§7 idempotency](#7-harnesssend-request-cookbook)). -- **Errors** are strings beginning with a stable code: `harness/: message` - (e.g. `harness/invalid_message_role`). Match on the code substring. -- **Dispatch is deny-by-default.** A send with no `options.functions.allow` is a - plain chat loop — every model-requested call is refused. Allow globs per send; - the harness's [`iii-permissions.yaml`](../iii-permissions.yaml) and the - approval-gate remain the safety floor. - -## 4. Function catalog - -Consumer-facing functions (full request/response in the linked golden schema): - -| Function | Trigger it to | Notes | -|---|---|---| -| `harness::send` | Start (or steer) a turn; return immediately | The entry point. [`harness.send.json`](../tests/golden/schemas/harness.send.json) | -| `harness::run` | Call an agent like a function: held open until the turn ends, returns the result | Same seed path as send + an output contract. [`harness.run.json`](../tests/golden/schemas/harness.run.json) | -| `harness::stop` | Cancel the session's in-flight turn | Sets the abort flag + `router::abort`. [`harness.stop.json`](../tests/golden/schemas/harness.stop.json) | -| `harness::status` | Read a point-in-time turn state (recovery, guards) | Returns `null` when no turn ever ran. **Not a render feed.** [`harness.status.json`](../tests/golden/schemas/harness.status.json) | -| `harness::spawn` | Start a sub-agent in a child session | Usually called *by the model* through `agent_trigger`, not by consumers. [`harness.spawn.json`](../tests/golden/schemas/harness.spawn.json) | - -**Internal — never trigger directly** (the harness drives these): `harness::turn` -(the durable loop step), `harness::function::trigger` / `harness::function::resolve` -(dispatch + parked-call settle), `harness::sweep-pending` (cron), and -`harness::on-config-change` (hot-reload). They forge call ids, parked results, -and turn progress; calling them out of band corrupts the turn record. - -## 5. Reactive integration — what to bind - -Bind with the standard two-step pattern: register a handler function, then -`registerTrigger` of that type pointed at it. Delivery is fire-and-forget, -at-least-once, and unordered — reconcile by `revision` (transcript) or treat the -trigger as an edge to act on. - -| Trigger | Bind for | Config filter | -|---|---|---| -| `session::message-added` / `message-updated` | Live transcript: assistant text, thinking, function-call blocks, function results | `{ session_id }` | -| `session::status-changed` | Spinner / composer state (`working` / `done` / `error`) | `{}` or tenancy `metadata` | -| `harness::turn-completed` | Turn outcomes: toasts on failure, auto-titling, chaining, result delivery | `{ session_id?, parent_session_id? }` | -| `harness::turn-started` | Optional observability (a turn began) | same as completed | -| `approval::pending-created` / `pending-resolved` | Human-in-the-loop prompts (see [§8](#8-approvals)) | `{ session_id?, metadata? }` | - -`harness::turn-completed` payload: `{ session_id, turn_id, status, result?, result_error?, reason?, timestamp, parent? }` -where `status` is `completed` | `cancelled` | `failed`. `harness::turn-started`: -`{ session_id, turn_id, timestamp, parent? }`. - -```ts -// Two-step binding (browser SDK shape): -const off = client.on('iii::myapp::turn_done', (evt) => onTurnDone(evt)) -client.registerTrigger({ - type: 'harness::turn-completed', - function_id: `iii::myapp::turn_done::${client.browserId}`, - config: { session_id: sessionId }, -}) -``` - -**Reconnect recovery.** Nothing replays automatically. After a reconnect or a -fresh attach to a live session, re-seed from reads: `harness::status` for the -coarse turn state, and `approval::list-pending { session_id }` to rebuild any -held-call prompts. Then resume binding the triggers above. - -## 6. Canonical consumer patterns - -### Interactive chat UI - -`send` → render from session events → approval triggers for holds → -`turn-completed` ends the turn. This is exactly what the console does (see -[§13](#13-console-as-reference-implementation) and the use-case walkthrough in -[`tech-specs/.../ConsolePage.tsx`](../../tech-specs/2026-06-agentic/presentation/src/pages/ConsolePage.tsx)). - -```mermaid -sequenceDiagram - participant UI - participant H as harness - participant S as session-manager - UI->>H: harness::send {session_id, message, model, options:{functions, system_prompt}} - loop until turn-completed - H->>S: append/update assistant + function_result - S-->>UI: message-added / message-updated - end - H-->>UI: harness::turn-completed -``` - -### Messaging bridge (Telegram / WhatsApp / Slack) - -Map each chat to a **stable `session_id`** (e.g. `tg:`). Use the -update/message id as the `idempotency_key` so webhook redeliveries dedupe. -Stamp tenancy in `session.metadata` (the field session triggers and -`approval::list-pending` filter on). Reply by binding `harness::turn-completed` -and pushing the final assistant message back to the channel; for long turns, -also push intermediate assistant text from `session::message-updated`. - -```mermaid -sequenceDiagram - participant TG as Telegram webhook - participant B as bridge worker - participant H as harness - participant S as session-manager - TG->>B: update {chat_id, message_id, text} - B->>H: harness::send {session_id tg:CHAT_ID, message, idempotency_key MESSAGE_ID} - H->>S: stream the turn - H-->>B: harness::turn-completed {session_id} - B->>TG: sendMessage(final assistant text) -``` - -A reply that arrives while the turn is still running is **steering**: send it the -same way and the harness folds it into the running turn (`merged: true`) — no -"busy" error, no second turn. - -### Held-open RPC (`harness::run`) - -When the caller wants the turn's result inline — a backend classifier, a -structured extraction, an agent-as-a-function — use `harness::run` with an -[output contract](#7-harnesssend-request-cookbook). The trigger stays open until -the turn ends and returns `{ status, result, ... }`. Give it a generous -`timeout_ms`. - -### Event-driven loop (chaining turns) - -Bind `harness::turn-completed`; in the handler, decide whether to start the next -hop with `harness::send` / `harness::run`. **The loop guard is yours:** -`max_turns` bounds one turn, not a chain. Carry a hop counter in -`session.metadata`, lean on a budget sibling, or check a terminal condition in -the handler — otherwise completed → send → completed is an infinite loop. - -```mermaid -sequenceDiagram - participant W as loop worker - participant H as harness - H-->>W: harness::turn-completed {session_id, result} - W->>W: terminal? (hop counter / budget / goal check) - alt continue - W->>H: harness::send {session_id, message: nextStep(result)} - else stop - W->>W: done - end -``` - -### Arbitrary inbound events → an agent - -Two supported paths: - -- **Preferred:** translate the event into a `harness::send` (a sensor reading, a - cron tick, a GitHub webhook → a user-or-custom message). Merge/steering and - durability are handled for you — a send into a running turn folds in, a send - into an idle session kicks a fresh turn. -- **Opt-in steering bridge:** if messages already land via raw `session::append` - (some other writer owns the transcript), register *your own* handler bound to - `session::message-added` `{ roles: ["user"] }` that checks `harness::status` - and calls `harness::send` when no turn is running (the spec calls this the - `on-steering` pattern; the harness ships no such function — you bind it). Prefer - routing through `harness::send` directly where you can: its merge path - double-checks the turn record after appending and closes the read/complete - race this bridge has between `harness::status` and `harness::send`. - -### Sub-agent observer - -To watch children a turn spawns via `harness::spawn`, bind -`harness::turn-completed` with `config: { parent_session_id: }`. Each -child turn's completion (and its `parent` linkage) lets a dashboard render the -spawn tree without polling. - -## 7. `harness::send` request cookbook - -Shapes mirror [`harness.send.json`](../tests/golden/schemas/harness.send.json). -Minimal: - -```json -{ "message": "Summarise the repo README", "model": "claude-sonnet-4", "provider": "anthropic" } -``` - -Full options: - -```jsonc -{ - "session_id": "tg:42", // omit to create a new session - "message": "Refactor the auth module", // string sugar, or a full AgentMessage (role user|custom) - "model": "claude-sonnet-4", - "provider": "anthropic", - "idempotency_key": "tg-update-9981", // repeated key → original {session_id,turn_id}, appends nothing - "session": { // applied only when this send creates/ensures the session - "title": "auth refactor", - "metadata": { "owner": "u_1", "chat_id": 42 } // tenancy: session triggers + list filter on it - }, - "options": { - "mode": "agent", // plan | ask | agent — prepends a mode paragraph - "system_prompt": "…", // optional override; omit for the built-in identity prompt - "max_turns": 16, - "thinking_level": "medium", // minimal | low | medium | high | xhigh - "functions": { "allow": ["shell::*", "coder::*"], "deny": ["shell::rm"], "expose": "agent_trigger" }, - "output": { "type": "json", "schema": { "type": "object", "required": ["category"] } }, - "metadata": { "trace": "abc" } // tracing passthrough - } -} -``` - -Response: `{ session_id, turn_id, accepted, merged?, deduplicated? }`. - -- **Steering** — `merged: true` means the message folded into a running turn. - This is success, not an error; do not start a second turn. -- **Dedupe** — `deduplicated: true` means the `idempotency_key` matched an - earlier send; nothing was appended. -- **Deterministic user entry id** — when `idempotency_key` is set, the harness - derives the user entry id `e_idem_`. A consumer that optimistically - renders the user message can predict that id so the `message-added` snapshot - reconciles in place (the console does exactly this — see - [§13](#13-console-as-reference-implementation)). - -## 8. Approvals - -The harness ships the *mechanics* of a human gate (a `pre-trigger` hook that can -*hold* a call, and `harness::function::resolve` to release it). The **policy, -the decision RPCs, the inbox, and the notification triggers live in the -[approval-gate](../../approval-gate) sibling** — see its -[integration contract](../../approval-gate/architecture/integration.md). - -For a consumer that means: - -- Bind `approval::pending-created` / `approval::pending-resolved` (scoped by - `session_id` or tenancy `metadata`) to render and clear prompts. -- Resolve with `approval::resolve { session_id, function_call_id, decision }`. -- Catch up after a reconnect with `approval::list-pending { session_id }`. -- **Never** trigger `harness::function::resolve` yourself — that is the gate's - private channel to the parked turn. The released call's result arrives in the - transcript like any other `function_result`. - -## 9. Compaction - -`context-manager` is stateless; **the caller owns when to compact and persisting -the result**. During a turn the harness does this automatically (it reads the -latest compaction entry, calls `context::assemble`, and on compaction appends a -`custom_type: "compaction"` session entry). A consumer offering a manual -`/compact` follows the same round trip: - -1. Guard: `harness::status` — refuse while a turn is active. -2. Read the transcript (`session::messages`). -3. `context::compact { messages, model, options:{ lease_key: session_id } }`. -4. On `ok`, append a `compaction` custom entry whose `data` carries - `{ summary, tail_start_entry_id, tokens_before }` — the same shape the harness - writes, so the next turn's assemble anchors on it. - -See [context-manager integration](../../context-manager/architecture/integration.md) -for the compaction round trip in full. - -## 10. Hooks (sibling workers only) - -The five `harness::hook::*` types (`pre-turn`, `pre-generate`, `post-generate`, -`pre-trigger`, `post-trigger`) are **synchronous** extension points: binding one -puts your function in-path, and the harness acts on its return value -(veto / hold / mutate) under a per-binding timeout and `on_error` policy. - -Consumers do **not** bind hooks. They are for operator-trusted *policy siblings* -— approval-gate binds `pre-trigger`, a redactor binds `post-trigger`, a budget -worker binds `post-generate`. Hook *logic* always lives in the sibling. See -[harness.md § Hooks](../../tech-specs/2026-06-agentic/harness.md) for the -contract and chain semantics before building one. - -## 11. Errors, stop, and recovery - -- **Stop a turn:** `harness::stop { session_id }` (omit `turn_id` for the current - turn). It sets an abort flag the next step checks and calls `router::abort` on - any live stream; the partial assistant message finalises with - `stop_reason: "aborted"` and the turn ends `cancelled`. -- **Failure surfaces three ways:** the session flips to `status: error` (with a - short reason), `harness::turn-completed` carries `status: "failed"` + - `result_error`, and the assistant message (if any) carries the error. Render - whichever your UI already watches; they agree. -- **Recovery playbook** (reconnect / fresh attach): `harness::status` for coarse - state, `approval::list-pending` for held calls, `session::messages` to hydrate - the transcript. Then bind the live triggers (§5). Nothing replays on its own. - -## 12. Boundaries & anti-patterns - -- **Do not trigger internal functions** (`harness::turn`, - `harness::function::trigger` / `resolve`, `harness::sweep-pending`, - `harness::on-config-change`). They corrupt the turn record out of band. -- **Do not append user messages with `session::append`** when `harness::send` is - available — the send merge path double-checks the running turn and closes a - steering race; a raw append needs the steering bridge from [§6](#6-canonical-consumer-patterns) - to be safe. -- **Do not expect `agent::events`** or a `started: false` busy signal. The - transcript is the stream; a concurrent send merges (steering) rather than - rejecting. -- **An in-run agent cannot start turns.** `harness::send` / `run` / `turn` / - `stop` and the dispatch internals are denied to the model by - [`iii-permissions.yaml`](../iii-permissions.yaml); `harness::spawn` is the only - model-reachable way to start new turns, and it self-enforces depth / fan-out / - policy subsetting. -- **Terminology:** it is always *trigger* for a bus invocation — never *call* - (except domain nouns like "function call"). - -## 13. Console as reference implementation - -The [console](../../console) chat backend is a worked TypeScript consumer of -this contract: - -| Concern | File | -|---|---| -| `harness::send` / `stop` / `status` wire helpers | [`console/web/src/lib/backend/harness-send.ts`](../../console/web/src/lib/backend/harness-send.ts) | -| Per-mode + identity system prompt | Built into the harness (`harness/src/prompt/`); pass `options.mode` and omit `options.system_prompt` for the default | -| `harness::turn-completed` subscription | [`console/web/src/lib/backend/turn-events-live.ts`](../../console/web/src/lib/backend/turn-events-live.ts) | -| `approval::pending-*` subscription + `list-pending` catch-up | [`console/web/src/lib/backend/approval-events-live.ts`](../../console/web/src/lib/backend/approval-events-live.ts) | -| Kickoff loop + recovery + `/compact` | [`console/web/src/lib/backend/real.ts`](../../console/web/src/lib/backend/real.ts) | -| Trigger payloads → UI stream events | [`console/web/src/lib/backend/translate.ts`](../../console/web/src/lib/backend/translate.ts) | - -It renders the transcript entirely from `session-manager` events (see -[session-manager integration](../../session-manager/architecture/integration.md)), -surfaces approvals from the gate's triggers, and ends each turn on -`harness::turn-completed` — the triangle in [§1](#1-mental-model). diff --git a/harness/skills/SKILL.md b/harness/skills/SKILL.md new file mode 100644 index 000000000..d8a9bf965 --- /dev/null +++ b/harness/skills/SKILL.md @@ -0,0 +1,126 @@ +--- +name: harness +description: >- + The durable agent turn loop — kick off a turn with `harness::send` or + `harness::run`, render it from session-manager transcript events, react to + `harness::turn-completed`, with deny-by-default tool dispatch and synchronous + hook extension points for policy siblings. +--- + +# harness + +The harness is the durable turn loop that wires `session-manager`, `llm-router`, +and `context-manager` into an agent. A consumer stays thin: it kicks off a turn, +renders the conversation from the session transcript, and reacts to turn +boundaries and human-gated calls. The harness streams the assistant message into +the session as it generates, so you watch the session, not the harness — there +is no `agent::events` stream, and `harness::status` is a point-in-time recovery +read, not a render feed. + +Every invocation is a trigger (`iii.trigger({ function_id, payload })`); there is +no separate "call" verb. Tool dispatch is deny-by-default: a send with no +`options.functions.allow` is a plain chat loop and every model-requested call is +refused until you allow globs per send. Sessions are minted by the harness +(`s_`) or supplied by you; a send into a running turn folds in as steering +(`merged: true`) instead of erroring, and a repeated `idempotency_key` returns +the original turn without appending. + +Prerequisites: `session-manager` (required — transcript store and change feed) +and `llm-router` (required — generation and the model catalog) must be present. +`context-manager` (token budgeting and compaction) is a soft dependency — absent +it, the harness sends raw history. `approval-gate` (the human-in-the-loop gate) +is optional; without it no call is held and every allowed call runs un-gated. + +## When to Use + +- Start or steer an agent turn and return immediately (`harness::send`). +- Call an agent like a function, held open until the turn ends with the result + returned inline and an optional output contract (`harness::run`). +- Cancel an in-flight turn (`harness::stop`) or read coarse turn state for + recovery and guards (`harness::status`). +- Chain turns or react to outcomes by binding `harness::turn-completed`. +- Drive a turn from an arbitrary inbound event (cron tick, webhook, sensor) by + translating it into a `harness::send`. + +## Boundaries + +- Not a transcript feed. Render from `session-manager`'s `session::message-added` + / `message-updated` / `status-changed` (reconcile by `revision`); do not poll + `harness::status` for content. +- Not the approvals engine. The harness ships only the gate mechanics; the + policy, decision RPCs (`approval::resolve`), inbox (`approval::list-pending`), + and prompt triggers live in `approval-gate`. +- Not a chain guard. `options.max_turns` bounds a single turn, not a + send-completed-send loop; carry your own stop condition. +- Do not trigger the internal functions (below) — they forge call ids and turn + progress, so calling them out of band corrupts the turn record. +- An in-run agent cannot start turns: `send` / `run` / `turn` / `stop` are denied + to the model by policy. `harness::spawn` is the only model-reachable way to + start a new turn, and it self-enforces depth, fan-out, and policy subsetting. + +## Functions + +Consumer-facing: + +- `harness::send` — ensure the session, persist the incoming message, and kick + off a turn; returns fast or merges into a running turn (steering). +- `harness::run` — `send` held open until the turn ends; returns the turn result. + The backend/automation entry point; supports an output contract. +- `harness::stop` — request cancellation of an in-flight turn; cascades to + spawned children. +- `harness::status` — read the current turn state for a session; `null` when no + turn ever ran. For recovery and guards, not rendering. +- `harness::spawn` — spawn a sub-agent in a child session. Model-facing (invoked + through `agent_trigger`), not a consumer entry point. + +Internal — the harness drives these; never trigger them directly: +`harness::turn` (the durable loop step), `harness::function::trigger` / +`harness::function::resolve` (dispatch and parked-call settle), +`harness::sweep-pending` (cron expiry), and `harness::on-config-change` +(hot-reload). + +## Reactive triggers + +The harness emits two async turn-boundary trigger types so consumers and siblings +react without polling `harness::status`: + +- `harness::turn-started` — a turn began executing (first loop step). +- `harness::turn-completed` — a turn reached a terminal status + (`completed` / `cancelled` / `failed`), carrying the result or error for + chaining, failure toasts, auto-titling, and result delivery. + +Bind `turn-completed` for outcomes and to chain the next hop; bind `turn-started` +only for observability. Delivery is fire-and-forget, at-least-once, and unordered +— treat each event as an edge. Nothing replays on reconnect: re-seed with +`harness::status` and `approval::list-pending`, then rebind. Do not bind these for +live transcript rendering — that is `session-manager`'s job. + +Binding `config` filters delivery by `session_id`, or by `parent_session_id` to +watch the children a turn `spawn`s. + +### How to bind + +1. Register a handler: `registerFunction('myapp::on-turn-done', handler)`. +2. Register the trigger: + +```typescript +iii.registerTrigger({ + type: 'harness::turn-completed', + function_id: 'myapp::on-turn-done', + config: { session_id: sessionId }, +}) +``` + +For the event payload shape, call `get function info` on the trigger type. + +### Hooks (policy siblings only) + +The harness also registers five synchronous, in-path hook trigger types: +`harness::hook::pre-turn`, `harness::hook::pre-generate`, +`harness::hook::post-generate`, `harness::hook::pre-trigger`, and +`harness::hook::post-trigger`. A bound hook runs in the turn's critical path and +the harness acts on its return value (veto / hold / mutate) under a per-binding +`timeout_ms` and `on_error` policy; `pre-trigger` / `post-trigger` bindings take a +`functions` glob list to scope which calls they gate. These are for +operator-trusted policy siblings (`approval-gate` binds `pre-trigger`); ordinary +consumers do not bind hooks. diff --git a/iii-permissions.yaml b/iii-permissions.yaml index 4441ef4c0..12727429e 100644 --- a/iii-permissions.yaml +++ b/iii-permissions.yaml @@ -100,6 +100,13 @@ rules: - '!harness::sweep-pending' - '!context::on-config-change' - '!approval::on-config-change' + - '!telegram-bot::on-config-change' + - '!telegram-bot::on-message-added' + - '!telegram-bot::on-message-updated' + - '!telegram-bot::on-status-changed' + - '!telegram-bot::on-turn-completed' + - '!telegram-bot::on-pending-created' + - '!telegram-bot::on-pending-resolved' # Read-only / introspection (extend below for your tools). - state::get diff --git a/tech-specs/2026-06-agentic/README.md b/tech-specs/2026-06-agentic/README.md index b3fe8fac3..28b0227a7 100644 --- a/tech-specs/2026-06-agentic/README.md +++ b/tech-specs/2026-06-agentic/README.md @@ -15,7 +15,7 @@ or a single worker like `llm-router` directly. flowchart LR %% Nodes chat["chat"] - tg["telegram-worker"] + tg["telegram-bot"] funcs["trigger functions
as needed"] ctx["context-manager"] harness["harness"] @@ -71,7 +71,7 @@ flowchart LR ### How to read the diagram -- **Green** (`chat`, `telegram-worker`, `third-party-worker`) are *example consumers*. They are not +- **Green** (`chat`, `telegram-bot`, `third-party-worker`) are *example consumers*. They are not part of this spec; they show who calls in and how. Any worker or client can take their place. - **Red** (`context-manager`, `session-manager`, `llm-router`, `harness`) are the four workers this spec defines. Each is **standalone**: installable and useful on its own, with no hard dependency on diff --git a/tech-specs/2026-06-agentic/presentation/src/components/diagrams/SystemMap.tsx b/tech-specs/2026-06-agentic/presentation/src/components/diagrams/SystemMap.tsx index e5599c472..c13e1f15d 100644 --- a/tech-specs/2026-06-agentic/presentation/src/components/diagrams/SystemMap.tsx +++ b/tech-specs/2026-06-agentic/presentation/src/components/diagrams/SystemMap.tsx @@ -33,7 +33,7 @@ interface MapEdge { const NODES: MapNode[] = [ { id: 'chat', x: 30, y: 60, w: 180, h: 56, title: 'chat', sub: 'console web app', kind: 'consumer' }, - { id: 'telegram-worker', x: 30, y: 185, w: 180, h: 56, title: 'telegram-worker', sub: 'webhook bridge', kind: 'consumer' }, + { id: 'telegram-bot', x: 30, y: 185, w: 180, h: 56, title: 'telegram-bot', sub: 'webhook bridge', kind: 'consumer' }, { id: 'third-party', x: 30, y: 310, w: 180, h: 56, title: 'third-party-worker', sub: 'any worker', kind: 'consumer' }, { id: 'session-manager', x: 425, y: 28, w: 210, h: 60, title: 'session-manager', sub: 'session::*', kind: 'core' }, { id: 'harness', x: 425, y: 168, w: 210, h: 92, title: 'harness', sub: 'harness::* — the loop', kind: 'core' }, @@ -45,7 +45,7 @@ const NODES: MapNode[] = [ const EDGES: MapEdge[] = [ { id: 'chat-send', from: 'chat', to: 'harness', d: 'M 210 92 C 300 92, 340 196, 425 196', label: 'harness::send', lx: 308, ly: 132, dur: 2.2 }, { id: 'session-events', from: 'session-manager', to: 'chat', d: 'M 425 50 C 340 50, 300 76, 214 84', label: 'live session events', lx: 318, ly: 42, dur: 2.2 }, - { id: 'tg-send', from: 'telegram-worker', to: 'harness', d: 'M 210 213 C 300 213, 340 214, 425 214', label: 'harness::send', lx: 304, ly: 206, dur: 2.2 }, + { id: 'tg-send', from: 'telegram-bot', to: 'harness', d: 'M 210 213 C 300 213, 340 214, 425 214', label: 'harness::send', lx: 304, ly: 206, dur: 2.2 }, { id: 'persist', from: 'harness', to: 'session-manager', d: 'M 460 168 L 460 92', label: 'append / stream deltas', lx: 452, ly: 136, anchor: 'end', dur: 1.6 }, { id: 'assemble', from: 'harness', to: 'context-manager', d: 'M 540 260 L 540 326', label: 'context::assemble', lx: 548, ly: 298, anchor: 'start', dur: 1.6 }, { id: 'generate', from: 'harness', to: 'llm-router', d: 'M 635 200 L 796 200', label: 'router::chat', lx: 712, ly: 192, dur: 1.8 }, diff --git a/tech-specs/2026-06-agentic/presentation/src/content/workers.ts b/tech-specs/2026-06-agentic/presentation/src/content/workers.ts index 41ee69f7b..e3fb4a9b6 100644 --- a/tech-specs/2026-06-agentic/presentation/src/content/workers.ts +++ b/tech-specs/2026-06-agentic/presentation/src/content/workers.ts @@ -155,8 +155,8 @@ export const WORKERS: Record = { ], notes: ['any worker or client can take this place — the surface is the contract.'], }, - 'telegram-worker': { - id: 'telegram-worker', + 'telegram-bot': { + id: 'telegram-bot', kind: 'consumer', kindLabel: 'example consumer', role: 'a webhook bridge: telegram updates in, live message edits out.', diff --git a/tech-specs/2026-06-agentic/presentation/src/pages/TelegramPage.tsx b/tech-specs/2026-06-agentic/presentation/src/pages/TelegramPage.tsx index 1da340c9e..5f2c3405d 100644 --- a/tech-specs/2026-06-agentic/presentation/src/pages/TelegramPage.tsx +++ b/tech-specs/2026-06-agentic/presentation/src/pages/TelegramPage.tsx @@ -6,7 +6,7 @@ import { UseCaseShell } from './UseCaseShell' const LANES: SeqLane[] = [ { id: 'user', label: 'telegram user', x: 90 }, - { id: 'tg', label: 'telegram-worker', x: 300 }, + { id: 'tg', label: 'telegram-bot', x: 300 }, { id: 'harness', label: 'harness', x: 520 }, { id: 'session', label: 'session-manager', x: 720 }, { id: 'router', label: 'llm-router', x: 900 }, diff --git a/telegram-bot/.iii-worker.lock b/telegram-bot/.iii-worker.lock new file mode 100644 index 000000000..f94857a88 --- /dev/null +++ b/telegram-bot/.iii-worker.lock @@ -0,0 +1 @@ +pid=57177 diff --git a/telegram-bot/Cargo.lock b/telegram-bot/Cargo.lock new file mode 100644 index 000000000..21e2d2fe6 --- /dev/null +++ b/telegram-bot/Cargo.lock @@ -0,0 +1,2563 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "iii-observability" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71a3002534a53d85c86e4be167cf7ae8c1de809ab3130ecfcb2d85eb4afd1272" +dependencies = [ + "futures-util", + "opentelemetry", + "opentelemetry-http", + "opentelemetry_sdk", + "reqwest", + "serde_json", + "sysinfo", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "iii-sdk" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebda94ffd347b173746edaccb6e8767c1c2afc4c2663c3a01756396de2660c32" +dependencies = [ + "async-trait", + "futures-util", + "hostname", + "iii-observability", + "reqwest", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand", + "thiserror", + "tokio", + "tokio-stream", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "telegram-bot" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "dashmap", + "iii-observability", + "iii-sdk", + "pulldown-cmark", + "reqwest", + "schemars", + "serde", + "serde_json", + "serde_yaml", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "which", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d7cd18d4acb58fb3cdfe9ea54e6cd96a4e7d4cc45c56338b236e82dad47248" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/telegram-bot/Cargo.toml b/telegram-bot/Cargo.toml new file mode 100644 index 000000000..9b1376a95 --- /dev/null +++ b/telegram-bot/Cargo.toml @@ -0,0 +1,37 @@ +[workspace] + +[package] +name = "telegram-bot" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "telegram-bot" +path = "src/main.rs" + +[lib] +name = "telegram_bot" +path = "src/lib.rs" + +[dependencies] +iii-sdk = "=0.19.4" +iii-observability = "0.19.4" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +clap = { version = "4", features = ["derive"] } +schemars = "0.8" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +dashmap = "6" +tokio-util = "0.7" +pulldown-cmark = "0.13" + +[dev-dependencies] +serde_json = "1" +which = "8" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "signal"] } diff --git a/telegram-bot/README.md b/telegram-bot/README.md new file mode 100644 index 000000000..cb51eed34 --- /dev/null +++ b/telegram-bot/README.md @@ -0,0 +1,142 @@ +# telegram-bot + +A Telegram bridge to the harness agent stack. The worker owns Telegram UX — commands, inline keyboards, live message edits, and approval prompts — and delegates turns, streaming, and durability to `harness`, `session-manager`, and `approval-gate`. + +Update ingress uses a session-manager-style `updates` adapter: **polling** (default, no public URL) or **webhook** (HTTPS production). + +## Install + +```bash +iii worker add harness session-manager llm-router context-manager approval-gate +iii worker add telegram-bot +``` + +## Quickstart (polling — default) + +1. Set configuration (configuration id `telegram-bot`): + +```yaml +bot_token: "${TELEGRAM_BOT_TOKEN}" +verbosity: minimal +default_model: + provider: anthropic + id: claude-sonnet-4 +functions_allow: + - "shell::*" +``` + +2. Message the bot on Telegram. The worker long-polls `getUpdates` in the background; user text is sent to `harness::send` with update-id idempotency; assistant output streams back via `session::message-updated` edits. + +Commands: `/start` (new session + model picker or default model), `/stop` (cancel turn), `/model` (re-pick model), `/help`, `/thinking` (reasoning depth), `/verbosity` (transcript detail), `/settings`. + +Each `/start` clears the chat's active harness session. The next message after model selection creates a fresh session (harness-assigned ID); later messages continue that session until the next `/start`. + +Assistant output streams via `sendMessageDraft` when available (Bot API 9.3+), with `editMessageText` fallback. Model thinking blocks stream via `sendRichMessageDraft` (`RichBlockThinking`). Drafts are finalized to persistent messages on turn completion. + +The worker does not send a `sendChatAction(typing)` indicator: Telegram has no stop-typing API and each action lingers ~5s on clients, leaving the indicator visible for seconds after the answer arrives. Progress is shown by streamed thinking/answer drafts (draft transport) or the streamed message bubble (edit transport) instead. + +When a tool call needs approval, the bot posts an inline keyboard: **Approve**, **Reject**, and **Approve always** (per-session grant via `approval::approve-always`). + +## Webhook mode (production) + +```yaml +bot_token: "${TELEGRAM_BOT_TOKEN}" +updates: + name: webhook + config: + base_url: "https://your-engine.example" # iii engine root only + secret: "your-webhook-secret" # recommended +``` + +`base_url` is the **public root of your iii engine** — the bot appends its own +path (`/telegram-bot/webhook`) to build the URL handed to Telegram, so operators +never repeat the path. Selecting the `webhook` adapter (at boot or via +hot-reload) does everything automatically, with no restart: + +1. registers the `telegram-bot/webhook` HTTP route on the engine, then +2. calls Telegram `setWebhook` with `{base_url}/telegram-bot/webhook`. + +Switching back to `polling` reverses both — it calls `deleteWebhook` and then +removes the HTTP route. No manual step is required. + +> The legacy full-URL form (`url: https://…/telegram-bot/webhook`) is still +> accepted for backward compatibility. A `secret` is strongly recommended: +> without it, anyone who learns the URL can inject forged updates. + +`POST /telegram-bot/set-webhook` remains available to manually re-arm Telegram +(e.g. if it dropped the webhook) without changing configuration. + +## Configuration + +All fields hot-reload through the `configuration` worker — no restart required, including `bot_token` and `updates` adapter swaps. + +| Field | Description | +|---|---| +| `bot_token` | **Required.** Telegram Bot API token (`${TELEGRAM_BOT_TOKEN}`) | +| `updates` | Ingress adapter: `polling` (default) or `webhook` — see below | +| `default_model` | Skip model picker when set (`provider` + `id`) | +| `verbosity` | `none` \| `minimal` \| `high` \| `debug` — controls transcript mirroring | +| `default_thinking_level` | Optional harness reasoning depth: `minimal` \| `low` \| `medium` \| `high` \| `xhigh` | +| `streaming` | Draft streaming — `transport` (`auto`/`draft`/`edit`), `draft_id_seed`, `draft_throttle_ms`, `create_settle_ms` | +| `steering_mode` | `steering` (default, harness merge) or `fifo` (local queue) | +| `functions_allow` | Globs for `harness::send` `options.functions.allow` | +| `system_prompt` | Optional system prompt on every send | +| `timeout_ms` | Timeout for harness, approval, state, and configuration RPCs (default `10000`) | + +### `updates` adapter + +**Polling** (default): + +```yaml +updates: + name: polling + config: + timeout_seconds: 50 # optional, Telegram max 50 +``` + +**Webhook**: + +```yaml +updates: + name: webhook + config: + base_url: "https://your-engine.example" # iii engine root; required + secret: "your-webhook-secret" # recommended +``` + +The bot derives the Telegram webhook URL as `{base_url}/telegram-bot/webhook` +and registers/removes the HTTP route as the adapter is switched to/from +`webhook` — see [Webhook mode](#webhook-mode-production). + +Verbosity levels: + +- `none` — final assistant text, errors, approvals only (thinking still shown via native rich draft) +- `minimal` — same as `none` for transcript mirroring; thinking uses RichBlockThinking regardless +- `high` — also mirrors function call blocks +- `debug` — also mirrors function result entries + +Persisted at `./data/configuration/telegram-bot.yaml` when using the default fs adapter. + +Final assistant messages are rendered as Telegram HTML (LLM markdown converted to bold, code, links, lists). Live streaming edits stay plain text until finalization to avoid broken partial markup. + +## Trace correlation + +Telegram ingress and harness bindings share OpenTelemetry baggage so the console can group traces by session and turn: + +- **`iii.session.id`** — harness session id (or `pending-{chat_id}` before the first send in a chat) +- **`iii.message.id`** — `tg-{update_id}` at ingress; harness `turn_id` after `harness::send` returns and for binding handlers + +`harness::send` receives `options.metadata` with `{ session_id, message_id, surface: "telegram" }` for engine passthrough. Polling mode stamps baggage per update in the poller; webhook mode stamps it in the HTTP handler. + +## Local development & testing + +```bash +cargo run --release -- --url ws://127.0.0.1:49134 --config ./tests/fixtures/config.yaml +cargo test +UPDATE_GOLDENS=1 cargo test # after schema changes +``` + +HTTP endpoints (engine default `http://127.0.0.1:3000`): + +- `POST /telegram-bot/webhook` — Telegram update ingress. Registered **only while the `webhook` adapter is active**; removed in polling mode. +- `POST /telegram-bot/set-webhook` — manually (re-)register the Telegram webhook from config. Always available. diff --git a/telegram-bot/architecture/README.md b/telegram-bot/architecture/README.md new file mode 100644 index 000000000..ee86ddae9 --- /dev/null +++ b/telegram-bot/architecture/README.md @@ -0,0 +1,110 @@ +# telegram-bot architecture + +`telegram-bot` is a binary-deployed Rust worker that bridges a Telegram bot to +the iii harness/agent stack. It owns the **Telegram surface** — commands, inline +keyboards, live message edits, draft streaming, approval prompts — and delegates +the **agent loop** (turns, streaming, durability, approvals) to its siblings: +`harness`, `session-manager`, `approval-gate`, and `llm-router`. Durable +bookkeeping lives in the external `state` worker; all operator configuration +lives in the `configuration` worker and hot-reloads without a restart. + +## Document map + +| Doc | What it covers | +|---|---| +| [README.md](README.md) (this file) | The system in one paragraph and one diagram; the worker's place in the stack; vocabulary. | +| [telegram-api.md](telegram-api.md) | All communication with the **Telegram Bot API**: ingress (polling loop / webhook), the outbound method surface, draft vs edit streaming, secret validation, throttling, file handling, the HTTP client. | +| [configuration.md](configuration.md) | The **configuration** model: schema, the `configuration`-worker integration, boot sequence, hot-reload, and the `updates` adapter lifecycle (polling ↔ webhook and the dynamic HTTP-trigger registration). | +| [internals.md](internals.md) | Everything else: the reactive bindings to siblings, the render/streaming pipeline, the durable KV schema, the per-chat FSM, preference resolution, telemetry/trace correlation, and the concurrency model. | + +## The system in one paragraph + +A Telegram update arrives — by long-poll (`getUpdates`) in **polling** mode or +by HTTP POST to the engine route in **webhook** mode — and both paths funnel +into one sink (`webhook::process_update`). Commands (`/start`, `/model`, …) and +inline-keyboard callbacks are handled locally; plain user text is forwarded to +`harness::send`, which assigns a session id and runs the agent turn. As the turn +runs, `session-manager` and `harness` emit events (`message-added`, +`message-updated`, `status-changed`, `turn-completed`) that the engine routes +back to this worker's **bindings**. The render pipeline turns each session entry +into Telegram output — streaming live via ephemeral **drafts** +(`sendMessageDraft`) or in-place **edits** (`editMessageText`), then finalizing +to persistent messages at end of turn. When a tool call needs human approval, +`approval-gate` emits `pending-created`, the bot posts an Approve/Reject/Approve- +always keyboard, and the button press resolves it. Per-chat mappings (chat ↔ +session, selected model, preferences) and per-entry render bookkeeping are +persisted in the `state` worker so the bridge survives restarts and out-of-order +event delivery. + +## The system in one diagram + +``` + Telegram Bot API (api.telegram.org) + ▲ │ + outbound methods │ │ updates + sendMessage / editMessageText │ (message, callback_query) + sendMessageDraft / sendRichMessageDraft │ + answerCallbackQuery / sendChatAction │ + setWebhook / deleteWebhook / getUpdates │ + │ ┌──────┴───────┐ + │ │ POLLING: │ long-poll getUpdates + │ │ background │ in a bg task + │ │ loop │ + │ │ WEBHOOK: │ engine HTTP route + │ │ HTTP POST │ /telegram-bot/webhook + │ └──────┬───────┘ + ┌───────────────────────────────┴─────────────┴───────────────────────────┐ + │ telegram-bot worker │ + │ │ + │ ingress ──► webhook::process_update ──► commands / callbacks / user text │ + │ │ │ + │ render pipeline ◄── bindings ◄── engine ◄──┤ harness::send (drive turn) │ + │ (draft / edit, chunk, │ harness::stop / status │ + │ verbosity, ordering) │ router::models::list │ + │ │ │ approval::resolve / … │ + └────────┼──────────────────────────────────────┼───────────────────────────┘ + │ │ + │ durable bookkeeping │ events (triggers) + ▼ ▼ + ┌─────────────┐ ┌──────────┐ ┌─────────────────┐ ┌──────────────┐ ┌─────────────┐ + │ state (KV) │ │ harness │ │ session-manager │ │ approval-gate│ │ llm-router │ + │ scope= │ │ send/stop│ │ message-added/ │ │ pending-* │ │ models::list│ + │ telegram-bot│ │ turn-* │ │ updated/status │ │ resolve │ │ │ + └─────────────┘ └──────────┘ └─────────────────┘ └──────────────┘ └─────────────┘ + + configuration worker ──(configuration:updated)──► hot-reload (bot_token, adapter, …) +``` + +## Vocabulary + +- **Chat** — a Telegram conversation, keyed by `chat_id` (i64). The unit of + per-user state. +- **Session** — a harness/`session-manager` conversation, keyed by a + harness-assigned `session_id` (string). One active session per chat; `/start` + clears it. Mapped both ways in KV (`chat:{id}:session` ⇄ `session:{sid}:chat`). +- **Turn** — one agent run inside a session, identified by `turn_id`. Started by + `harness::send`, ended by `harness::turn-completed`. +- **Entry** — one item in a session transcript (assistant message, function call, + function result), keyed by `entry_id`. Streams in via `message-added` / + `message-updated`. +- **Revision** — a monotonically increasing version of an entry. The render + pipeline drops stale (lower) revisions and reconciles higher ones. +- **Adapter** (`updates`) — how Telegram updates reach the worker: **polling** + (default, no public URL) or **webhook** (engine HTTP route + `setWebhook`). +- **Draft transport** — streaming via ephemeral `sendMessageDraft` / + `sendRichMessageDraft` previews (Bot API 9.3+), finalized to real messages. +- **Edit transport** — streaming via a persistent `sendMessage` + repeated + `editMessageText`; the fallback when drafts are unsupported. +- **order_key** — a per-entry append timestamp (ms) used to post new Telegram + bubbles in transcript order even when events race. +- **Finalize** — the end-of-turn flush that converts live/draft state into + persistent messages and stops the typing indicator. +- **Verbosity** — how much of the transcript is mirrored: `none` / `minimal` / + `high` / `debug`. +- **Steering mode** — `steering` folds mid-turn messages into the running turn + (harness merge); `fifo` queues them locally and drains one per turn. +- **Binding** — a registered handler bound to a sibling's trigger + (`session::message-added`, `harness::turn-completed`, `approval::pending-created`, …). +- **HTTP trigger** — an engine route (`api_path` + method) that invokes a worker + function. The webhook ingress route is one; it is registered/removed as the + `updates` adapter is switched (see [configuration.md](configuration.md)). diff --git a/telegram-bot/architecture/configuration.md b/telegram-bot/architecture/configuration.md new file mode 100644 index 000000000..b8fc5d891 --- /dev/null +++ b/telegram-bot/architecture/configuration.md @@ -0,0 +1,217 @@ +# Configuration and the update-adapter lifecycle + +Every operator-facing setting lives in the external `configuration` worker and +**hot-reloads without a restart** — including `bot_token` and the choice between +polling and webhook ingress. This document covers the config schema, the +`configuration`-worker integration, the boot sequence, the hot-reload path, and +the `updates` adapter lifecycle (including how the webhook HTTP route is created +and removed on demand). + +Source: [`src/config.rs`](../src/config.rs) (schema + types), +[`src/configuration.rs`](../src/configuration.rs) (worker integration + reload), +[`src/ingress.rs`](../src/ingress.rs) (adapter lifecycle), +[`src/functions/mod.rs`](../src/functions/mod.rs) (trigger registration), +[`src/main.rs`](../src/main.rs) (boot). + +## 1. Mental model + +`WorkerConfig` is the single source of truth. It is: + +- **Serialized to JSON Schema** and registered with the `configuration` worker so + the console can render an editor (the `updates` field becomes a `oneOf` + variant picker for polling/webhook). +- **Held in a `ConfigCell`** = `Arc>>` — an atomically + swappable snapshot read everywhere via `deps.cfg().await`. +- **Reloaded reactively**: a `configuration:updated` event for this worker's id + re-fetches, re-validates, swaps the cell, and re-applies any side effects + (ingress, command menu). + +## 2. The config schema + +| Field | Type / default | Meaning | +|---|---|---| +| `bot_token` | string, **required** | Telegram Bot API token. Env-expandable (`"${TELEGRAM_BOT_TOKEN}"`). Empty is fatal at boot, rejected (keep-previous) on reload. | +| `updates` | adapter, default `polling` | Ingress selection — `polling` or `webhook` (see [§4](#4-the-updates-adapter-lifecycle)). | +| `default_model` | `{provider, id}`, optional | When set, `/start` skips the model picker and uses this model. | +| `verbosity` | `none`\|`minimal`\|`high`\|`debug`, default `none` | How much transcript is mirrored to Telegram. | +| `default_thinking_level` | `minimal`\|`low`\|`medium`\|`high`\|`xhigh`, optional | Default harness reasoning depth (`options.thinking_level`). | +| `streaming` | object | Transport (`auto`/`draft`/`edit`), `draft_id_seed`, `draft_throttle_ms`, `create_settle_ms`. | +| `steering_mode` | `steering`\|`fifo`, default `steering` | `steering` merges mid-turn messages into the running turn; `fifo` queues locally and drains one per turn. | +| `functions_allow` | `[glob]` | Passed to `harness::send` `options.functions.allow`. | +| `system_prompt` | string, optional | System prompt added to every send. | +| `timeout_ms` | u64, default `10000` | Timeout for all harness/approval/state/configuration RPCs. | + +The `updates` adapter is **adjacently tagged** (`name` discriminator + nested +`config`), mirroring session-manager's storage adapter shape: + +```yaml +updates: + name: polling # or: webhook + config: + timeout_seconds: 50 # polling: long-poll seconds (Telegram max 50) +``` + +```yaml +updates: + name: webhook + config: + base_url: "https://engine.example" # iii engine root; bot appends the path + secret: "your-webhook-secret" # recommended (header validation) +``` + +### Migration tolerance + +`WorkerConfigRaw` (`#[serde(deny_unknown_fields)]`) absorbs legacy keys so old +stored configs keep parsing: deprecated timeout aliases +(`harness_send_timeout_ms`, `approval_timeout_ms`, `state_timeout_ms`) fold into +`timeout_ms`; removed display fields (`thinking_display`, `use_rich`, +`edit_throttle_ms`) are ignored. The webhook `base_url` field accepts the legacy +`url` key via `#[serde(alias = "url")]`. + +## 3. The configuration-worker integration + +| Function | Role | +|---|---| +| `register_config` | Registers the schema + id/name/description via `configuration::register` (with retry). Seeds `initial_value` from the `--config` seed if given, else from defaults **only when the store has no value yet**. | +| `fetch_config` | `configuration::get` for this id; a `null` value → built-in defaults, missing → error. | +| `apply_config` | Validates a candidate snapshot, logs token rotation, atomically swaps the `ConfigCell`. Returns `false` (keep previous) on validation failure. | +| `register_config_trigger` | Registers the `on-config-change` function and binds it to a `configuration` trigger filtered to `configuration:updated` for this id. | +| `on_config_change` | The reload handler (see [§5](#5-hot-reload)). | + +All `configuration::*` RPCs go through `trigger_with_retry` (3 attempts, linear +250 ms × attempt backoff). + +## 4. The `updates` adapter lifecycle + +This is the core of how ingress is wired, and where the webhook HTTP route is +created and torn down. The whole flow runs through one function, +`ingress::apply_updates_adapter`, which is reached both at boot +(`ingress::start`, with `prev = None`) and on every adapter-changing reload +(`ingress::apply_config_change`). It is **idempotent** and **restart-free**. + +### Why `base_url` and not a full URL + +The iii SDK gives a worker no way to discover the engine's **public** base URL — +there is no `public_url`/`base_url` accessor and no env var for it (the only URL +the SDK exposes is the WebSocket control address, `ws://127.0.0.1:49134`). The +worker therefore cannot self-derive the endpoint Telegram should POST to, so the +operator supplies the **iii engine root** (`base_url`) and the worker appends its +own route path. The path is a single constant, +`config::WEBHOOK_API_PATH = "telegram-bot/webhook"`, reused for **both** the +engine route registration and the Telegram URL — so the two can never drift: + +``` +endpoint_url = {base_url trimmed of trailing '/'} + "/telegram-bot/webhook" +``` + +`WebhookConfig::endpoint_url()` returns `None` for an empty `base_url`, and +returns the value as-is if it already ends with the path (so a legacy full-URL +config is not double-suffixed). + +### Switching to **webhook** + +`apply_updates_adapter` (webhook branch): + +1. `stop_poller` — cancel any running poller and await its handle. +2. Compute `endpoint_url()`. If `None` (no `base_url`), warn and return — + ingress is left inactive (no route, no `setWebhook`). +3. **`ensure_webhook_trigger`** — if no webhook route handle is held yet, + `register_trigger` the `telegram-bot/webhook` HTTP POST route and **retain the + returned `Trigger` handle** in `RuntimeState.webhook_trigger`. The engine route + must exist *before* Telegram is told to POST, or early updates would hit an + unregistered path. If registration fails, the handle is left unset (so the next + reload retries) and `setWebhook` is skipped. +4. `setWebhook` with the derived URL and the optional `secret`. + +### Switching to **polling** + +`apply_updates_adapter` (polling branch): + +1. `stop_poller`. +2. `deleteWebhook` — tell Telegram to **stop POSTing first**. +3. **`unregister_webhook_trigger`** — `take()` the retained handle and call + `.unregister()`. (Order matters: removing the route before `deleteWebhook` + would leave a window where Telegram POSTs to a dead path.) +4. `start_poller` — spawn the background `getUpdates` loop. + +### Idempotency and no route leaks + +- `ensure_webhook_trigger` guards on "handle already held", so re-entering the + webhook branch (e.g. a webhook→webhook change of `secret`) does **not** + re-register the route — it only re-issues `setWebhook` with the new value. This + matters because `register_trigger` mints a fresh UUID per call, so blind + re-registration would accumulate duplicate engine routes. +- `ingress_changed` short-circuits no-op reloads: `polling → polling` is treated + as unchanged (the poller picks up a new `timeout_seconds` live), and + `webhook → webhook` only re-applies when the config actually differs. +- The `Trigger` handle has **no `Drop`**: dropping it does *not* unregister. The + code always `take()`s and explicitly `.unregister()`s — never overwrites the + `Option` — so the route can never silently linger. + +### Boot and shutdown + +- At **boot**, only the always-on control route `telegram-bot/set-webhook` is + registered statically (`bind_http_triggers`). The webhook ingress route is + registered by `ingress::start` → `apply_updates_adapter` **only if** the + configured adapter is webhook. So a polling deployment never creates the route. +- At **shutdown**, `ingress::shutdown` stops the poller and unregisters the + webhook route, leaving a clean slate for the next start rather than relying on + the engine to garbage-collect a disconnected worker's triggers. + +### The `set-webhook` control endpoint + +`POST /telegram-bot/set-webhook` (function `telegram-bot::set-webhook`) is a +manual re-arm: it re-issues `setWebhook` with the derived `endpoint_url()` from +current config. It is redundant in steady state (switching adapters already does +this) but useful to re-register if Telegram drops the webhook. Its route is +always registered; it errors if the active adapter is not webhook or `base_url` +is unset. + +## 5. Hot-reload + +``` +configuration worker emits configuration:updated (id = telegram-bot) + │ + ▼ +on-config-change fn fires ─► on_config_change: + 1. read prev snapshot (for its timeout_ms) + 2. fetch_config_with_timeout (re-read authoritative value) + └─ fetch failure → keep previous, return + 3. apply_config (validate + swap the ConfigCell) + └─ empty bot_token → keep previous (non-fatal) + 4. ingress::apply_config_change(prev, next) + └─ only if ingress_changed → apply_updates_adapter (see §4) + 5. set_my_commands (refresh the command menu) +``` + +Functions and sibling/HTTP triggers are **not** re-registered on reload — only +the config cell is swapped, ingress conditionally re-applied, and the command +menu refreshed. `bot_token` rotation is picked up by the next API call (the token +is read from the snapshot per call). + +## 6. Boot sequence (`main.rs`) + +Exact order, all once at boot: + +1. init tracing/telemetry; parse CLI (`--config` seed, `--url`, `--manifest`). +2. `register_worker` over the engine WebSocket. +3. `configuration::register_config` — schema + conditional seed. +4. `configuration::fetch_config` — load the authoritative value. +5. `cfg.validate()` — **empty `bot_token` here is fatal** and aborts boot. +6. build `ConfigCell` + `Deps`. +7. `functions::register_all` — register all handler functions. +8. `functions::bind_triggers` — best-effort bind the six sibling-event handlers + (missing siblings only warn). +9. `functions::bind_http_triggers` — register the always-on `set-webhook` route. +10. `configuration::register_config_trigger` — bind the reload handler. +11. `ingress::start` — apply the adapter (registers the webhook route iff + webhook; otherwise starts the poller). +12. `set_my_commands` — publish the command menu. +13. await Ctrl-C, then `ingress::shutdown` + `iii.shutdown_async`. + +## 7. Environment expansion + +`${VAR}` references in a YAML config seed are expanded from the process +environment at parse time (`expand_env`), so `bot_token: "${TELEGRAM_BOT_TOKEN}"` +works. (The authoritative store value from the `configuration` worker is JSON and +is not env-expanded — expansion applies to file/YAML seeds.) diff --git a/telegram-bot/architecture/internals.md b/telegram-bot/architecture/internals.md new file mode 100644 index 000000000..433d695ba --- /dev/null +++ b/telegram-bot/architecture/internals.md @@ -0,0 +1,265 @@ +# telegram-bot internals + +This is the deep tour of everything that isn't ingress or configuration (covered +in [telegram-api.md](telegram-api.md) and [configuration.md](configuration.md)): +the reactive bindings to sibling workers, the render/streaming state machine, the +durable KV schema, the per-chat FSM, preference resolution, trace correlation, +and the concurrency model that holds it all together. + +## 1. Crate layout + +``` +src/ +├── main.rs boot: register, fetch config, wire functions/triggers, start ingress +├── lib.rs module exports +├── config.rs WorkerConfig schema + WebhookConfig::endpoint_url +├── configuration.rs configuration-worker integration + hot-reload +├── ingress.rs polling loop + updates-adapter / webhook-route lifecycle +├── deps.rs Deps + RuntimeState (all in-memory concurrency state) +├── kv.rs durable state (the state-worker key schema) +├── types.rs Telegram + harness/approval wire types; ChatFsm +├── preferences.rs per-chat override > global config resolution +├── telemetry.rs OpenTelemetry baggage + correlation ids +├── surface.rs RPC function catalog (schemas, golden-tested) +├── text.rs text utilities (UTF-8-safe truncation, etc.) +├── clients/ outbound RPC clients +│ ├── telegram.rs Telegram Bot API client +│ ├── harness.rs harness::send / stop / status +│ ├── approval.rs approval::resolve / approve-always / list-pending +│ ├── router.rs router::models::list +│ └── state.rs state::get / set / delete (typed wrappers) +├── functions/ +│ ├── mod.rs function registration; trigger binding; webhook-route helpers +│ ├── webhook.rs ingress sink + command/callback router + harness drive +│ ├── set_webhook.rs manual setWebhook re-arm endpoint +│ └── bindings/ the six sibling-event handlers +└── render/ outbound streaming pipeline + ├── stream.rs the render state machine (the heart) + ├── verbosity.rs phase classification + verbosity gating + ├── format.rs markdown → Telegram HTML + ├── chunk.rs ≤4096-byte UTF-8-safe splitting + ├── throttle.rs edit/draft rate limiting + revision freshness + └── typing.rs typing-indicator lifecycle +``` + +## 2. Two lifecycles + +**Inbound (user → agent).** An update reaches `webhook::process_update`. A +command runs locally; a callback resolves a model pick or approval; plain text is +forwarded to `harness::send` (with the chat's selected model, effective thinking +level, allowed-functions policy, and system prompt). The first send in a chat has +no session id — harness assigns one, which the worker persists as the chat's +session. Subsequent sends reuse it until `/start` resets the chat. + +**Outbound (agent → user).** Driving a turn makes `session-manager`/`harness` +emit events, which the engine routes to this worker's bindings, which call into +the render pipeline, which calls the Telegram API. See [§3](#3-reactive-bindings) +and [§5](#5-the-render-state-machine). + +## 3. Reactive bindings + +`functions::bind_triggers` binds six handlers to sibling triggers (best-effort — +a missing sibling only warns, so a binding can silently never fire): + +| Trigger | Handler | Role filter | On fire | +|---|---|---|---| +| `session::message-added` | `on-message-added` | `assistant`, `function_result` | Start rendering a new entry (assistant text/thinking, or a verbosity-gated function-result bubble). | +| `session::message-updated` | `on-message-updated` | `assistant` | Apply an incremental revision to an existing entry (or reconcile if already finalized). | +| `session::status-changed` | `on-status-changed` | — | React to session status (drives typing/working state). | +| `harness::turn-completed` | `on-turn-completed` | — | Finalize all entries, suppress typing, drain one FIFO-queued message. | +| `approval::pending-created` | `on-pending-created` | — | Post the Approve/Reject/Approve-always keyboard. | +| `approval::pending-resolved` | `on-pending-resolved` | — | Clear the keyboard once resolved (by any surface). | + +Two invariants: + +- **No chat → no-op.** Every binding resolves `chat_id` from + `kv::chat_id_for_session(session_id)`; if there's no mapping it returns + `ok: true` and does nothing. (Bindings only know `session_id`; the reverse + KV mapping is how they find the chat to post into.) +- **Role filtering is the sibling's job** (passed in the trigger config), but + `on-message-added` re-checks the role defensively. + +A subtle ordering rule: `on-turn-completed` **suppresses typing before it +finalizes**, because `turn-completed` can arrive before a stale +`status-changed(working)` — otherwise typing would restart after the final +answer. + +### Approval flow end to end + +``` +approval-gate emits pending-created ─► on-pending-created: + post inline keyboard [Approve a:] [Reject d:] [Approve always w:] + store cb: → ApprovalCallbackData{ session_id, function_call_id, function_id } + remember approval:::msg (the keyboard message id) + +user taps a button ─► webhook handle_callback: + a: → approval::resolve(Allow) + d: → approval::resolve(Deny, reason) + w: → approval::approve-always(function_id) THEN approval::resolve(Allow) + answerCallbackQuery(toast); delete cb token + +approval-gate emits pending-resolved ─► on-pending-resolved: + clear the inline keyboard (editMessageReplyMarkup) +``` + +The callback token is a non-cryptographic 32-bit hash of `function_call_id`; +`resolve_callback` is a silent no-op if the token was already consumed, so +double-taps and redelivery are safe. `catch_up_approvals` reconciles any pending +approvals that have no keyboard message yet (e.g. created while the bot was down). + +## 4. Sibling RPC clients + +| Client call | Sibling function | Purpose | +|---|---|---| +| `harness::send` | `harness::send` | Drive a turn; returns `{session_id, turn_id}`. Carries model, thinking level, functions policy, system prompt, session seed, idempotency key, trace metadata. | +| `harness::stop` | `harness::stop` | Cancel the active turn (`/stop`, `/start` reset). | +| `harness::status_active` | `harness::status` | Is a turn running? (FIFO steering gate.) | +| `router::list_models` | `router::models::list` | Populate the `/model` picker. | +| `approval::resolve` / `approve_always` / `list_pending` | `approval::*` | Resolve approvals; whitelist a tool; reconcile pending. | +| `state::get` / `set` / `delete` | `state::*` | All durable KV (see [§6](#6-durable-state-the-kv-schema)). | + +`harness::send` is given an `idempotency_key` (the Telegram `update_id`, or +`tg-fifo-{chat}` for queue drains) so webhook redelivery and restarts don't +double-send a turn. + +## 5. The render state machine + +The render pipeline ([`render/stream.rs`](../src/render/stream.rs)) turns session +entries into Telegram messages while surviving out-of-order delivery, +redelivery, and restarts. Entry points: `on_message_added`, `on_message_updated`, +`finalize_session`. + +**Per-entry serialization.** All events for an entry run under a per-`(session, +entry)` async mutex (`entry_lock`), so an `added`, an `updated`, and a finalize +can't interleave. The lock serializes but does **not** order — so: + +- **Revision freshness** (`revision_is_fresh`): an incoming revision must be + `>= last applied`, else it's dropped as stale. (`message-added` carries + revision 0, fresh only for a brand-new entry; `on_message_added` prefers an + existing in-memory session so a `message-updated` that raced ahead isn't + clobbered by the rev-0 add.) +- **Finalize reconciliation**: once an entry is finalized, later updates are + ignored unless they carry a **strictly higher** revision, in which case + `reconcile_finalized_update` edits the posted bubble. A finalize learned from + durable state after a restart is recorded as `u64::MAX` ("finalized, revision + unknown") so no late event reconciles it. + +**Per-chat message ordering.** New bubbles must post in transcript order even +when entries materialize concurrently. Each entry gets an `order_key` (earliest +append timestamp, min-merged and persisted in KV). `send_in_order` registers an +`(order_key, entry_id, chunk)` slot and waits in `await_create_slot` until it is +the earliest slot **and** no earlier entry is still unmaterialized, then takes the +per-chat create lock to actually `sendMessage`. Waiters hold no create lock while +waiting (avoids deadlock against per-entry locks) and are woken by a per-chat +`Notify`. Because DashMap iteration is unordered, finalize sorts entries +explicitly by `(order_key, entry_id)`. + +**Render step** (`apply_render`): freshness guard → classify `MessagePhase` +(`Empty`/`ThinkingOnly`/`Answering`) → compute effective verbosity → render +answer/thinking text → dispatch to draft or edit transport → record a +`PendingEntryState` snapshot (so the entry can be finalized even if its live +session was evicted). Transports, throttling, splitting, and the typing indicator +are detailed in [telegram-api.md §5–6](telegram-api.md#5-streaming-transports). + +**Verbosity gating** ([`render/verbosity.rs`](../src/render/verbosity.rs)): +answer text excludes thinking and only includes function-call blocks at `high`+; +thinking text is ungated (it streams as a native rich-thinking draft regardless); +function-result entries only render at `debug`. + +## 6. Durable state: the KV schema + +All durable state lives in the external `state` worker under scope +`telegram-bot` (`STATE_SCOPE`). Keys carry no TTL — the `timeout_ms` on each call +is the RPC timeout, not a key expiry; keys live until explicitly deleted. + +| Key | Value | Purpose | +|---|---|---| +| `chat:{chat_id}:session` | session_id | Forward chat → session mapping. | +| `session:{session_id}:chat` | chat_id | Reverse mapping (bindings resolve chat from session). | +| `chat:{chat_id}:fsm` | `idle`\|`awaiting_model` | Per-chat FSM ([§7](#7-the-per-chat-fsm)). | +| `chat:{chat_id}:model` | `{provider, id}` JSON | Selected model. | +| `chat:{chat_id}:verbosity` | verbosity string | Per-chat verbosity override. | +| `chat:{chat_id}:thinking_level` | level string | Per-chat thinking override (absent = inherit). | +| `entry:{sid}:{eid}:msg` | message_id | Which Telegram message an entry maps to. | +| `entry:{sid}:{eid}:chunk:{idx}:msg` | message_id | Continuation-chunk message ids. | +| `entry:{sid}:{eid}:order` | order_key | Append-order key for posting. | +| `entry:{sid}:{eid}:finalized` | bool | Finalized marker (survives restart). | +| `entry:{sid}:{eid}:thinking_msg` | message_id | The separate thinking-bubble message. | +| `approval:{sid}:{fcid}:msg` | message_id | The approval keyboard message. | +| `cb:{token}` | ApprovalCallbackData | Opaque callback token → approval target. | + +`set_chat_session` is best-effort transactional: it writes the forward key, then +the reverse, and rolls back the forward key if the reverse write fails. A crash +between the two leaves an inconsistent pair that `clear`/`reset_for_chat` paths +clean up. + +## 7. The per-chat FSM + +`ChatFsm` has two states: `Idle` and `AwaitingModel`. `/start` (without a +`default_model`) shows the model picker and sets `AwaitingModel`; a message while +`AwaitingModel` is bounced ("Pick a model first"); selecting a model binds it and +returns to `Idle`. `parse()` maps any unknown/missing string to `Idle`, so a +corrupt key fails safe. + +## 8. Preference resolution + +`effective_verbosity` / `effective_thinking_level` return the per-chat KV +override if present, else the global `WorkerConfig` value — strict precedence, +no merge. Setting a thinking level to "off" deletes the override key (reverting +to global), so "off at chat level" isn't distinguishable from "inherit" via the +override getter alone. + +## 9. Trace correlation + +Telegram ingress and harness bindings share OpenTelemetry baggage so the console +can group traces by session and turn: + +- **`iii.session.id`** — the harness session id, or `pending-{chat_id}` before + the first send in a chat. +- **`iii.message.id`** — `tg-{update_id}` at ingress; the harness `turn_id` after + `harness::send` returns and for binding handlers (resolved by + `message_id_for_binding`: `active_turns[session_id]` turn_id → entry_id → + session_id). + +`harness::send` receives `options.metadata = { session_id, message_id, surface: +"telegram" }` for engine passthrough. Polling stamps baggage per update in the +poller; webhook stamps it in the HTTP handler. + +## 10. Concurrency model + +`RuntimeState` ([`deps.rs`](../src/deps.rs)) is the single hub of in-memory +state, all built from `DashMap`s, async `Mutex`es, and `Notify`s: + +- **Streaming**: `stream_sessions`, `pending_entries`, `finalized_entries`, + `revisions`, `edit_times`, `draft_times`, `draft_disabled_chats`. +- **Per-entry / per-chat locks**: `entry_locks`, `chat_create_locks`, + `chat_create_notifies`. +- **Ordering**: `chat_create_order` (BTreeSet of slots), `chat_pending_materialization`, + `last_created_order`. +- **Steering**: `fifo_queues`. +- **Ingress**: `poll_offset`, `poller_cancel`, `poller_handle`, and + `webhook_trigger` (the retained HTTP-route handle — see + [configuration.md §4](configuration.md#4-the-updates-adapter-lifecycle)). +- **Typing**: `typing_tasks`, `typing_suppressed`, `typing_output_seen`, + `typing_generation`. +- **Tracing**: `active_turns` (session → latest turn_id). + +`reset_for_chat` (called by `/start`) drops all per-chat sequencing/streaming +state and, for the old session, its stream/pending/finalized/entry-lock/revision +entries plus typing and `active_turns` — and bumps the typing generation so +stale refresh ticks can't fire. + +## 11. Idempotency and sharp edges + +- **Redelivery / restart safety** comes from persisting entry message ids, chunk + ids, order keys, and finalized flags in KV; in-memory `finalized_entries` plus + the KV finalized flag gate re-rendering. `harness::send` dedupes via + `idempotency_key`. +- **Approval token collisions** are possible (32-bit hash of `function_call_id`) + across concurrent holds in one chat; resolution is idempotent so a stale token + is a no-op rather than a wrong-approval. +- **Best-effort bindings**: if a sibling worker is absent at boot, its trigger + binding only warns — that binding silently never fires. The webhook ingress + route, by contrast, is engine-native and not subject to sibling availability. +- **No key TTLs**: KV grows until explicitly cleared; long-lived chats accumulate + per-entry keys across sessions (cleared on `/start` for the prior session). diff --git a/telegram-bot/architecture/telegram-api.md b/telegram-bot/architecture/telegram-api.md new file mode 100644 index 000000000..5cc95c4ee --- /dev/null +++ b/telegram-bot/architecture/telegram-api.md @@ -0,0 +1,213 @@ +# Communication with the Telegram Bot API + +This document covers every interaction between `telegram-bot` and Telegram: how +updates come **in** (the two ingress adapters), how output goes **out** (the +method surface and the streaming transports), and the cross-cutting concerns — +the HTTP client, secret validation, throttling, formatting, and media. + +All of it lives in [`src/clients/telegram.rs`](../src/clients/telegram.rs) +(the API client), [`src/ingress.rs`](../src/ingress.rs) (the poller + adapter +supervisor), [`src/functions/webhook.rs`](../src/functions/webhook.rs) (the +webhook handler + update router), and the [`src/render/`](../src/render) pipeline +(outbound streaming). + +## 1. Mental model + +The worker is simultaneously: + +- a **Telegram Bot API client** — it POSTs to `https://api.telegram.org/bot/` + for every outbound action (`sendMessage`, `editMessageText`, `setWebhook`, …); and +- an **update receiver** — it consumes inbound `Update` objects either by + long-polling Telegram (`getUpdates`) or by receiving Telegram's HTTP POSTs + through an engine route. + +Both ingress paths converge on a single sink, so the rest of the worker never +needs to know which adapter delivered an update: + +``` +polling: bg task ─ getUpdates ─┐ + ├─► webhook::process_update_with_tracing ─► process_update +webhook: engine HTTP route ─────┘ +``` + +## 2. The HTTP client + +Every call goes through `api_call_with_timeout` in `telegram.rs`: + +``` +url = "https://api.telegram.org/bot" + bot_token + "/" + method +client.post(url).timeout(t).json(body).send() +``` + +- **Client** — a shared `reqwest::Client` held in `RuntimeState.http` + (connection pooling across calls). +- **Token** — read fresh from the hot-reloadable config snapshot + (`deps.cfg().await.bot_token`) on every call, so a rotated token takes effect + immediately without reconnecting. +- **Success contract** — Telegram replies `{ "ok": bool, "result"?, "description"? }`. + The client requires `ok == true` and returns `result`; otherwise it returns + `IIIError::Handler("telegram failed: ")`. +- **Token safety** — transport errors are reported via `e.without_url()` so the + bot token (embedded in the URL) is never logged. +- **Timeouts** — 30 s default. `getUpdates` overrides this to + `long_poll_timeout + 15 s` so the long-poll can run to completion. +- **Cancellation** — `api_call_cancellable` / `get_updates_with_cancel` race the + request against a `CancellationToken` via `tokio::select!`, so an in-flight + long-poll or typing ping aborts immediately on adapter switch or shutdown. + +## 3. Ingress + +Ingress is selected by the `updates` adapter in config (see +[configuration.md](configuration.md) for the full lifecycle). Both adapters are +supervised by `ingress::apply_updates_adapter`. + +### 3.1 Polling (default) + +```yaml +updates: { name: polling, config: { timeout_seconds: 50 } } +``` + +`run_poller` is a background Tokio task: + +1. Read the current config each iteration (so `timeout_seconds` changes apply + live, capped at Telegram's max of 50). +2. `getUpdates` with `{ offset, timeout, allowed_updates: ["message", "callback_query"] }`. +3. On success: reset backoff, process each update through + `process_update_with_tracing`, then advance `poll_offset` to + `last_update_id + 1` (the dedupe/acknowledge mechanism — Telegram won't + redeliver acknowledged updates). +4. On error: warn and back off exponentially (1 s → 30 s), abortable via the + cancellation token. + +The poller's `CancellationToken` and `JoinHandle` live in `RuntimeState`; an +adapter switch or shutdown cancels the token and awaits the handle before +starting anything new. Polling needs **no public URL** — ideal for local dev. + +### 3.2 Webhook (production) + +```yaml +updates: { name: webhook, config: { base_url: "https://engine.example", secret: "…" } } +``` + +Telegram POSTs each update to the engine route `POST /telegram-bot/webhook`, +which the engine dispatches to the `telegram-bot::webhook` function. The request +arrives as an `HttpTriggerRequest { body: Value, headers: Option }`. The +handler: + +1. Confirms the active adapter is `webhook` (else rejects — the route may still + exist briefly during a switch). +2. **Validates the secret token** when one is configured: the + `X-Telegram-Bot-Api-Secret-Token` header must equal `config.secret` + (case-insensitive header match). When no secret is configured, the update is + accepted with a warning — see [§8](#8-secret-validation-and-security). +3. Deserializes `body` into a `TelegramUpdate` and hands it to the shared sink. + +The worker never opens its own HTTP listener — the **engine** owns the socket; +the worker only registers the route. The Telegram-facing URL is derived as +`{base_url}/telegram-bot/webhook` (see [configuration.md](configuration.md#4-the-updates-adapter-lifecycle)). + +## 4. Outbound method surface + +Every Telegram method the worker calls, and why: + +| Method | Wrapper | Used for | +|---|---|---| +| `getUpdates` | `get_updates_with_cancel` | Polling ingress (long-poll). | +| `setWebhook` | `set_webhook` | Register the webhook URL + secret when switching to the webhook adapter (and via `set-webhook`). | +| `deleteWebhook` | `delete_webhook` | Unregister the webhook when switching to polling. | +| `setMyCommands` | `set_my_commands` | Publish the slash-command menu (`/start`, `/stop`, `/model`, `/help`, `/thinking`, `/verbosity`, `/settings`) at boot and after each config reload. | +| `sendMessage` | `send_message` | Post a new persistent message; returns its `message_id`. Carries optional `reply_markup` (keyboards) and `parse_mode`. | +| `editMessageText` | `edit_message_text` | In-place updates for the **edit** streaming transport and finalize. | +| `editMessageReplyMarkup` | `edit_message_reply_markup` | Clear an inline keyboard (e.g. after an approval is resolved). | +| `sendMessageDraft` | `send_message_draft` | Push an ephemeral live-preview draft (the **draft** transport). | +| `sendRichMessageDraft` | `send_rich_message_draft` | Stream model thinking as a rich `` draft block. | +| `sendRichMessage` | `send_rich_message` | Post a persistent rich message; returns `message_id`. | +| `answerCallbackQuery` | `answer_callback_query` | Acknowledge an inline-button tap (with optional toast text). | +| `sendChatAction` | `send_chat_action` / `_cancellable` | The "typing…" indicator (edit-transport fallback only). | +| `getFile` | `get_file` | Resolve a Telegram `file_id` to a downloadable path. | + +`inline_keyboard(rows)` builds the `reply_markup` payload (rows of +`{text, callback_data}` buttons) used by the model picker and approval prompts. + +### Update types handled + +`allowed_updates` is restricted to `["message", "callback_query"]`: + +- **message** — text/caption commands (`/start`…), plain text (→ `harness::send`), + or media. `extract_user_content` maps a photo/document/voice with no text to a + placeholder string (`[User sent a photo]`, etc.). +- **callback_query** — inline-button taps. The `callback_data` prefix routes the + action: `m:` model selection, `a:` approve, `d:` deny, `w:` approve-always. + +## 5. Streaming transports + +Assistant output is streamed as the turn runs, using one of two transports +resolved per session (`EffectiveTransport`): + +- **Draft** (preferred, Bot API 9.3+): `sendMessageDraft` pushes an ephemeral + "the bot is composing" preview that updates in place; model thinking streams + via `sendRichMessageDraft` as a `` block. On `turn-completed` the + draft is **finalized** into a persistent `sendMessage`/`sendRichMessage` and the + ephemeral draft is cleared. +- **Edit** (fallback): the first chunk is posted with `sendMessage`, then refined + with repeated `editMessageText`. Used when drafts are unsupported. + +**Per-chat auto-fallback**: if `sendMessageDraft` errors with a draft-unsupported +signal (`TEXTDRAFT_PEER_INVALID`, method-not-found, `Not Found`), the chat is +pinned to the edit transport (`draft_disabled_chats`) for the rest of its life, +and the current render falls back to edit. + +See [internals.md](internals.md) for the full render state machine (revision +freshness, finalize reconciliation, per-chat message ordering). + +## 6. Throttling, splitting, and the typing indicator + +- **Edit throttle** — `should_edit` rejects non-increasing revisions per + `(session, entry)` and rate-limits edits per `(chat, message)` by + `streaming.draft_throttle_ms`. +- **Draft throttle** — `should_draft` rate-limits draft pushes per + `(chat_id, draft_id)` by `streaming.draft_throttle_ms` (using + `RuntimeState.draft_times`). +- **Message splitting** — `split_message` cuts text into UTF-8-safe chunks of + ≤ 4096 bytes (`TELEGRAM_MAX_MESSAGE_LEN`); the first chunk edits/creates the + primary message and continuations post as separate ordered bubbles. +- **Typing indicator** — only used with the **edit** transport (drafts already + show progress). `sendChatAction(typing)` is deferred ~400 ms and re-pinged + every ~4 s by a cancellable loop guarded by a generation counter; it is + **suppressed** on first visible output and at `turn-completed` (Telegram has no + "stop typing" call — each action lasts ~5 s on clients unless a bot message + arrives). + +## 7. Formatting + +`format::format_outgoing` converts the LLM's markdown to the Telegram **HTML** +subset (`parse_mode: "HTML"`): bold/italic/strikethrough, inline `code` and +`pre` blocks, blockquotes, and links; lists are flattened to text, tables to +pipe-joined rows, horizontal rules to a divider. All literal text is +HTML-escaped. + +Live streaming edits stay **plain text** (no `parse_mode`) until finalization, so +a partially-streamed message can never produce broken/half-open HTML markup. The +final, persisted message is the one rendered as HTML. + +## 8. Secret validation and security + +In webhook mode the worker validates Telegram's `X-Telegram-Bot-Api-Secret-Token` +header against `config.secret` (set with `setWebhook`'s `secret_token`): + +- **secret configured** → the header must match, or the update is rejected with + `invalid webhook secret`. +- **no secret configured** → the update is accepted, but the worker logs a + warning recommending one. Without a secret, anyone who learns the public URL + can inject forged updates, so a secret is strongly recommended for any + internet-reachable deployment. + +Polling mode has no such exposure — the worker initiates all contact with +Telegram, so no inbound authentication is required. + +## 9. Media / files + +The worker resolves media via `getFile(file_id)` and represents text-less media +to the agent with placeholder strings (see `extract_user_content`). It does not +download or re-upload binary content itself; richer media handling is delegated +to the agent stack. diff --git a/telegram-bot/build.rs b/telegram-bot/build.rs new file mode 100644 index 000000000..81caa36d6 --- /dev/null +++ b/telegram-bot/build.rs @@ -0,0 +1,6 @@ +fn main() { + println!( + "cargo:rustc-env=TARGET={}", + std::env::var("TARGET").unwrap() + ); +} diff --git a/telegram-bot/config.collect.yaml b/telegram-bot/config.collect.yaml new file mode 100644 index 000000000..7c46f677c --- /dev/null +++ b/telegram-bot/config.collect.yaml @@ -0,0 +1,22 @@ +# Interface-collection config for the registry publish workflow. +# +# The publish job (.github/workflows/_publish-registry.yml) and PR interface +# boot smoke (.github/workflows/ci.yml) boot a throwaway copy of this worker +# purely to read back the functions and trigger types it registers with the +# engine — the published "interface". That interface is static: it does NOT +# depend on a real Telegram bot token. +# +# The worker refuses to start when bot_token is empty (see main.rs). On a clean +# CI runner there is no configuration store entry yet, so boot without a seed +# fails before functions register — exactly the failure seen in interface boot +# smoke. This config supplies a dummy token so validation passes; no Telegram +# API call succeeds during collection, and that is fine — collection only needs +# the worker to connect and register its surface. +# +# Kept in lockstep with _publish-registry.yml / ci.yml interface-smoke, which +# pass `--config config.collect.yaml` whenever this file is present. + +bot_token: "collect:interface-collection-token" +updates: + name: polling + config: {} diff --git a/telegram-bot/iii.worker.yaml b/telegram-bot/iii.worker.yaml new file mode 100644 index 000000000..8d74c1a09 --- /dev/null +++ b/telegram-bot/iii.worker.yaml @@ -0,0 +1,14 @@ +iii: v1 +name: telegram-bot +language: rust +deploy: binary +manifest: Cargo.toml +bin: telegram-bot +description: Telegram bridge to the harness stack — polling or webhook ingress, live message edits, approvals, and configurable verbosity. + +dependencies: + iii-state: "^0.19.0" + iii-queue: "^0.19.0" + configuration: "^0.19.0" + session-manager: "^1.0.0" + harness: "^1.0.0" \ No newline at end of file diff --git a/telegram-bot/skills/SKILL.md b/telegram-bot/skills/SKILL.md new file mode 100644 index 000000000..2445b7456 --- /dev/null +++ b/telegram-bot/skills/SKILL.md @@ -0,0 +1,97 @@ +--- +name: telegram-bot +description: >- + Bridge a harness agent onto Telegram — turn inbound chat messages into + `harness::send` turns and stream assistant output, model pickers, and inline + tool-call approvals back into the chat. +--- + +# telegram-bot + +The telegram-bot is a bridge between a Telegram bot and the harness agent +stack. It owns the Telegram UX — slash commands, model-picker keyboards, live +message edits, and approval prompts — and delegates turn execution, streaming, +and durability to `harness`, `session-manager`, and `approval-gate`. Inbound +text becomes a `harness::send` turn; the assistant's output flows back as +Telegram messages. It is not a general bot framework and exposes no scriptable +"send a message" surface of its own. + +Reach for it to put an existing harness agent in front of Telegram users. +Prerequisites: the sibling stack installed (`harness session-manager llm-router +context-manager approval-gate`) and a `telegram-bot` configuration entry with +a `bot_token`. A model is chosen per chat through the `/start` picker or fixed +with `default_model`; the agent only gets the tools you grant via +`functions_allow`. Update ingress is either long-polling (default, no public +URL) or an HTTPS webhook, and every config field hot-reloads. + +## When to Use + +- Expose a harness agent to Telegram users with streaming replies and live edits. +- Let a chat user pick or switch models and steer or cancel a running turn from chat. +- Approve or reject held tool calls inline from Telegram (Approve / Reject / Approve always). +- Mirror a session's transcript into a chat at a configurable verbosity. + +## Boundaries + +- No public "send arbitrary text to a chat" function. Output reaches a chat only + as the rendered result of a harness turn on a session mapped to that chat. +- No scheduler, cron, or timer. The worker never fires delayed or proactive + messages on its own — see Message flow for how a reminder is actually delivered. +- Renders output only for sessions it holds a `chat_id ↔ session_id` mapping for; + a turn on an unrelated session produces nothing in Telegram. +- Turn execution, tool-calling, and approval policy live in `harness` and + `approval-gate`; this worker is only the Telegram surface. Deploying it without + the sibling stack is unsupported. + +## Functions + +HTTP-triggered (operator/ingress only — not agent-callable): + +- `telegram-bot::webhook` — receive a Telegram update in webhook mode and + route commands, messages, and callback queries. +- `telegram-bot::set-webhook` — register the configured webhook URL with + Telegram. + +Internal bindings (auto-registered against sibling triggers; not called directly): + +- `telegram-bot::on-message-added` — post each new assistant or + function_result entry into the chat (from `session::message-added`). +- `telegram-bot::on-message-updated` — stream throttled assistant edits (from + `session::message-updated`). +- `telegram-bot::on-status-changed` — show a typing indicator while the + session is working (from `session::status-changed`). +- `telegram-bot::on-turn-completed` — finalize streaming, post the outcome + toast, and drain the FIFO queue (from `harness::turn-completed`). +- `telegram-bot::on-pending-created` — show an inline approval keyboard for a + held call (from `approval::pending-created`). +- `telegram-bot::on-pending-resolved` — clear the approval prompt once the + call resolves (from `approval::pending-resolved`). +- `telegram-bot::on-config-change` — hot-reload configuration. + +## Message flow + +The worker registers no trigger type for others to bind. It is a bidirectional +bridge between one Telegram chat and one harness session, and that round-trip is +the whole job. + +Inbound: a Telegram update arrives by long-poll or webhook and lands in the +`telegram-bot::webhook` handler. Slash commands are handled locally; any other +text is sent to `harness::send` with the chat's session, the selected model, the +configured `system_prompt`, and the `functions_allow` policy. The first message +in a chat creates the session and persists a `chat_id ↔ session_id` mapping; +later messages continue that session until the next `/start`. + +Outbound: as the turn runs, `session-manager`, `harness`, and `approval-gate` +emit events. The worker's bound handlers consume them and render into the +originating chat — resolving which chat by looking the session up in the +`chat_id ↔ session_id` mapping. Assistant text streams via message edits (or +native drafts) and is finalized on `harness::turn-completed`. + +Reminder round-trip: a user asking the bot to "remind me at 5pm" gets an +immediate reply in the same turn — that is just the normal inbound-to-outbound +path. The later notification is a *second* turn: when the time comes, something +must call `harness::send` on that same chat-bound `session_id`, and its assistant +output then streams back through the same bindings. Because this worker has no +scheduler and no push API, the timing lives elsewhere (an external scheduler, +cron, or another worker that holds the `session_id`); the bridge's only role is +routing that session's output back to the chat. diff --git a/telegram-bot/src/clients/approval.rs b/telegram-bot/src/clients/approval.rs new file mode 100644 index 000000000..d6dd40a50 --- /dev/null +++ b/telegram-bot/src/clients/approval.rs @@ -0,0 +1,90 @@ +use iii_sdk::{IIIError, TriggerRequest, III}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::types::{PendingApprovalRecord, ResolveDecision}; + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ResolveRequest { + pub session_id: String, + pub function_call_id: String, + pub decision: ResolveDecision, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct ResolveResponse { + pub resolved: bool, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ApproveAlwaysRequest { + pub session_id: String, + pub function_id: String, +} + +#[derive(Debug, Clone, Default, Serialize, JsonSchema)] +pub struct ListPendingRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct ListPendingResponse { + pub pending: Vec, +} + +pub async fn resolve( + iii: &III, + req: ResolveRequest, + timeout_ms: u64, +) -> Result { + let value = iii + .trigger(TriggerRequest { + function_id: "approval::resolve".into(), + payload: serde_json::to_value(&req).unwrap_or(json!({})), + action: None, + timeout_ms: Some(timeout_ms), + }) + .await?; + serde_json::from_value(value).map_err(|e| IIIError::Handler(format!("approval::resolve: {e}"))) +} + +pub async fn approve_always( + iii: &III, + session_id: &str, + function_id: &str, + timeout_ms: u64, +) -> Result<(), IIIError> { + iii.trigger(TriggerRequest { + function_id: "approval::approve-always".into(), + payload: json!({ + "session_id": session_id, + "function_id": function_id, + }), + action: None, + timeout_ms: Some(timeout_ms), + }) + .await?; + Ok(()) +} + +pub async fn list_pending( + iii: &III, + session_id: &str, + timeout_ms: u64, +) -> Result, IIIError> { + let value = iii + .trigger(TriggerRequest { + function_id: "approval::list-pending".into(), + payload: json!({ "session_id": session_id }), + action: None, + timeout_ms: Some(timeout_ms), + }) + .await?; + let resp: ListPendingResponse = serde_json::from_value(value) + .map_err(|e| IIIError::Handler(format!("approval::list-pending: {e}")))?; + Ok(resp.pending) +} diff --git a/telegram-bot/src/clients/harness.rs b/telegram-bot/src/clients/harness.rs new file mode 100644 index 000000000..4ba6fbf69 --- /dev/null +++ b/telegram-bot/src/clients/harness.rs @@ -0,0 +1,158 @@ +use iii_sdk::{IIIError, TriggerRequest, III}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::config::ModelRef; + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct HarnessSendRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + pub message: String, + pub model: String, + pub provider: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub options: Option, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct SessionSeed { + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum HarnessMode { + Plan, + Ask, + Agent, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct SendOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking_level: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub functions: Option, + /// Tracing passthrough for harness (session_id / message_id propagate as baggage). + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct FunctionsPolicy { + #[serde(skip_serializing_if = "Vec::is_empty")] + pub allow: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct HarnessSendResponse { + pub session_id: String, + pub turn_id: String, + pub accepted: bool, + #[serde(default)] + pub merged: Option, + #[serde(default)] + pub deduplicated: Option, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct HarnessStopRequest { + pub session_id: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct HarnessStopResponse { + pub stopped: bool, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct HarnessStatusRequest { + pub session_id: String, +} + +pub async fn send( + iii: &III, + req: HarnessSendRequest, + timeout_ms: u64, +) -> Result { + let value = iii + .trigger(TriggerRequest { + function_id: "harness::send".into(), + payload: serde_json::to_value(&req).unwrap_or(Value::Null), + action: None, + timeout_ms: Some(timeout_ms), + }) + .await?; + serde_json::from_value(value).map_err(|e| IIIError::Handler(format!("harness::send: {e}"))) +} + +pub async fn stop( + iii: &III, + session_id: &str, + timeout_ms: u64, +) -> Result { + let value = iii + .trigger(TriggerRequest { + function_id: "harness::stop".into(), + payload: json!({ "session_id": session_id }), + action: None, + timeout_ms: Some(timeout_ms), + }) + .await?; + serde_json::from_value(value).map_err(|e| IIIError::Handler(format!("harness::stop: {e}"))) +} + +pub async fn status_active(iii: &III, session_id: &str, timeout_ms: u64) -> Result { + let v = iii + .trigger(TriggerRequest { + function_id: "harness::status".into(), + payload: json!({ "session_id": session_id }), + action: None, + timeout_ms: Some(timeout_ms), + }) + .await?; + Ok(!v.is_null()) +} + +pub fn metadata_for_chat(chat_id: i64, model: &ModelRef) -> serde_json::Map { + let mut m = serde_json::Map::new(); + m.insert("telegram_chat_id".into(), json!(chat_id)); + m.insert("channel".into(), json!("telegram")); + m.insert("model".into(), json!(model.id)); + m.insert("provider".into(), json!(model.provider)); + m +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::telemetry; + + #[test] + fn send_options_serializes_metadata() { + let opts = SendOptions { + system_prompt: None, + mode: None, + thinking_level: None, + functions: None, + metadata: Some(telemetry::tracing_metadata("s1", "tg-42")), + }; + let v = serde_json::to_value(&opts).unwrap(); + assert_eq!(v["metadata"]["session_id"], "s1"); + assert_eq!(v["metadata"]["message_id"], "tg-42"); + assert_eq!(v["metadata"]["surface"], "telegram"); + } +} diff --git a/telegram-bot/src/clients/mod.rs b/telegram-bot/src/clients/mod.rs new file mode 100644 index 000000000..51989c3e3 --- /dev/null +++ b/telegram-bot/src/clients/mod.rs @@ -0,0 +1,5 @@ +pub mod approval; +pub mod harness; +pub mod router; +pub mod state; +pub mod telegram; diff --git a/telegram-bot/src/clients/router.rs b/telegram-bot/src/clients/router.rs new file mode 100644 index 000000000..02139b740 --- /dev/null +++ b/telegram-bot/src/clients/router.rs @@ -0,0 +1,39 @@ +use iii_sdk::{IIIError, TriggerRequest, III}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +pub struct ModelsListRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub capability: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct Model { + pub id: String, + pub provider: String, + #[serde(default)] + pub display_name: Option, + #[serde(default)] + pub supports_thinking: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct ModelsListResponse { + pub models: Vec, +} + +pub async fn list_models(iii: &III, timeout_ms: u64) -> Result, IIIError> { + let value = iii + .trigger(TriggerRequest { + function_id: "router::models::list".into(), + payload: json!({ "capability": "tools" }), + action: None, + timeout_ms: Some(timeout_ms), + }) + .await?; + let resp: ModelsListResponse = serde_json::from_value(value) + .map_err(|e| IIIError::Handler(format!("router::models::list: {e}")))?; + Ok(resp.models) +} diff --git a/telegram-bot/src/clients/state.rs b/telegram-bot/src/clients/state.rs new file mode 100644 index 000000000..9e7ec9441 --- /dev/null +++ b/telegram-bot/src/clients/state.rs @@ -0,0 +1,63 @@ +use iii_sdk::{IIIError, TriggerRequest, III}; +use serde_json::{json, Value}; + +pub async fn get( + iii: &III, + scope: &str, + key: &str, + timeout_ms: Option, +) -> Result { + iii.trigger(TriggerRequest { + function_id: "state::get".into(), + payload: json!({ "scope": scope, "key": key }), + action: None, + timeout_ms, + }) + .await +} + +pub async fn set( + iii: &III, + scope: &str, + key: &str, + value: Value, + timeout_ms: Option, +) -> Result { + iii.trigger(TriggerRequest { + function_id: "state::set".into(), + payload: json!({ "scope": scope, "key": key, "value": value }), + action: None, + timeout_ms, + }) + .await +} + +pub async fn delete( + iii: &III, + scope: &str, + key: &str, + timeout_ms: Option, +) -> Result { + iii.trigger(TriggerRequest { + function_id: "state::delete".into(), + payload: json!({ "scope": scope, "key": key }), + action: None, + timeout_ms, + }) + .await +} + +pub async fn get_string(iii: &III, scope: &str, key: &str, timeout_ms: u64) -> Option { + match get(iii, scope, key, Some(timeout_ms)).await { + Ok(v) if v.is_string() => v.as_str().map(str::to_string), + _ => None, + } +} + +pub async fn get_i64(iii: &III, scope: &str, key: &str, timeout_ms: u64) -> Option { + match get(iii, scope, key, Some(timeout_ms)).await { + Ok(v) if v.is_i64() => v.as_i64(), + Ok(v) if v.is_u64() => v.as_u64().and_then(|n| i64::try_from(n).ok()), + _ => None, + } +} diff --git a/telegram-bot/src/clients/telegram.rs b/telegram-bot/src/clients/telegram.rs new file mode 100644 index 000000000..ab6a26fdb --- /dev/null +++ b/telegram-bot/src/clients/telegram.rs @@ -0,0 +1,310 @@ +use std::time::Duration; + +use iii_sdk::IIIError; +use serde_json::{json, Value}; +use tokio_util::sync::CancellationToken; + +use crate::deps::Deps; +use crate::types::TelegramUpdate; + +const API_BASE: &str = "https://api.telegram.org/bot"; + +pub async fn send_message( + deps: &Deps, + chat_id: i64, + text: &str, + reply_markup: Option, + parse_mode: Option<&str>, +) -> Result { + let mut body = json!({ + "chat_id": chat_id, + "text": text, + }); + if let Some(markup) = reply_markup { + body["reply_markup"] = markup; + } + if let Some(mode) = parse_mode { + body["parse_mode"] = json!(mode); + } + let message_id = api_call(deps, "sendMessage", body).await?; + message_id + .get("message_id") + .and_then(Value::as_i64) + .ok_or_else(|| IIIError::Handler("telegram sendMessage: missing message_id".into())) +} + +pub async fn edit_message_text( + deps: &Deps, + chat_id: i64, + message_id: i64, + text: &str, + parse_mode: Option<&str>, +) -> Result<(), IIIError> { + let mut body = json!({ + "chat_id": chat_id, + "message_id": message_id, + "text": text, + }); + if let Some(mode) = parse_mode { + body["parse_mode"] = json!(mode); + } + api_call(deps, "editMessageText", body).await?; + Ok(()) +} + +pub async fn edit_message_reply_markup( + deps: &Deps, + chat_id: i64, + message_id: i64, +) -> Result<(), IIIError> { + let body = json!({ + "chat_id": chat_id, + "message_id": message_id, + "reply_markup": json!({ "inline_keyboard": [] }), + }); + api_call(deps, "editMessageReplyMarkup", body).await?; + Ok(()) +} + +pub async fn send_message_draft( + deps: &Deps, + chat_id: i64, + draft_id: i32, + text: &str, + message_thread_id: Option, +) -> Result<(), IIIError> { + let mut body = json!({ + "chat_id": chat_id, + "draft_id": draft_id, + "text": text, + }); + if let Some(thread) = message_thread_id { + body["message_thread_id"] = json!(thread); + } + api_call(deps, "sendMessageDraft", body).await?; + Ok(()) +} + +pub async fn send_rich_message_draft( + deps: &Deps, + chat_id: i64, + draft_id: i32, + rich_message: &Value, + message_thread_id: Option, +) -> Result<(), IIIError> { + let mut body = json!({ + "chat_id": chat_id, + "draft_id": draft_id, + "rich_message": rich_message, + }); + if let Some(thread) = message_thread_id { + body["message_thread_id"] = json!(thread); + } + api_call(deps, "sendRichMessageDraft", body).await?; + Ok(()) +} + +pub async fn send_rich_message( + deps: &Deps, + chat_id: i64, + rich_message: &Value, + message_thread_id: Option, +) -> Result { + let mut body = json!({ + "chat_id": chat_id, + "rich_message": rich_message, + }); + if let Some(thread) = message_thread_id { + body["message_thread_id"] = json!(thread); + } + let result = api_call(deps, "sendRichMessage", body).await?; + result + .get("message_id") + .and_then(Value::as_i64) + .ok_or_else(|| IIIError::Handler("telegram sendRichMessage: missing message_id".into())) +} + +pub fn rich_thinking_draft(thinking_text: &str) -> Value { + let content = if thinking_text.is_empty() { + "Thinking…" + } else { + thinking_text + }; + let escaped = escape_rich_html(content); + json!({ + "html": format!("{escaped}") + }) +} + +fn escape_rich_html(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for ch in text.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + _ => out.push(ch), + } + } + out +} + +pub async fn answer_callback_query( + deps: &Deps, + callback_id: &str, + text: Option<&str>, +) -> Result<(), IIIError> { + let mut body = json!({ "callback_query_id": callback_id }); + if let Some(t) = text { + body["text"] = json!(t); + } + api_call(deps, "answerCallbackQuery", body).await?; + Ok(()) +} + +pub async fn set_my_commands(deps: &Deps) -> Result<(), IIIError> { + let commands = json!([ + { "command": "start", "description": "Start or pick a model" }, + { "command": "stop", "description": "Stop the current turn" }, + { "command": "model", "description": "Change model" }, + { "command": "help", "description": "Show available commands" }, + { "command": "thinking", "description": "Set reasoning depth" }, + { "command": "verbosity", "description": "Set transcript verbosity" }, + { "command": "settings", "description": "Show current bot settings" }, + ]); + api_call(deps, "setMyCommands", json!({ "commands": commands })).await?; + Ok(()) +} + +pub async fn set_webhook( + deps: &Deps, + url: &str, + secret_token: Option<&str>, +) -> Result<(), IIIError> { + let mut body = json!({ "url": url }); + if let Some(secret) = secret_token { + body["secret_token"] = json!(secret); + } + api_call(deps, "setWebhook", body).await?; + Ok(()) +} + +pub async fn delete_webhook(deps: &Deps) -> Result<(), IIIError> { + api_call(deps, "deleteWebhook", json!({})).await?; + Ok(()) +} + +pub async fn get_file(deps: &Deps, file_id: &str) -> Result { + api_call(deps, "getFile", json!({ "file_id": file_id })).await +} + +pub async fn get_updates( + deps: &Deps, + offset: i64, + timeout_secs: u64, +) -> Result, IIIError> { + get_updates_with_cancel(deps, offset, timeout_secs, None).await +} + +pub async fn get_updates_with_cancel( + deps: &Deps, + offset: i64, + timeout_secs: u64, + cancel: Option<&CancellationToken>, +) -> Result, IIIError> { + let body = json!({ + "offset": offset, + "timeout": timeout_secs, + "allowed_updates": ["message", "callback_query"], + }); + let timeout = Duration::from_secs(timeout_secs.saturating_add(15)); + let result = if let Some(cancel) = cancel { + let mut call = Box::pin(api_call_with_timeout(deps, "getUpdates", body, timeout)); + tokio::select! { + _ = cancel.cancelled() => { + return Err(IIIError::Handler("getUpdates cancelled".into())); + } + result = &mut call => result?, + } + } else { + api_call_with_timeout(deps, "getUpdates", body, timeout).await? + }; + let updates = result + .as_array() + .ok_or_else(|| IIIError::Handler("telegram getUpdates: expected array".into()))?; + let mut out = Vec::with_capacity(updates.len()); + for item in updates { + let update: TelegramUpdate = serde_json::from_value(item.clone()) + .map_err(|e| IIIError::Handler(format!("telegram getUpdates parse: {e}")))?; + out.push(update); + } + Ok(out) +} + +async fn api_call(deps: &Deps, method: &str, body: Value) -> Result { + api_call_with_timeout(deps, method, body, Duration::from_secs(30)).await +} + +async fn api_call_with_timeout( + deps: &Deps, + method: &str, + body: Value, + timeout: Duration, +) -> Result { + let cfg = deps.cfg().await; + let token = cfg.bot_token.clone(); + let url = format!("{API_BASE}{token}/{method}"); + let resp = deps + .runtime + .http + .post(&url) + .timeout(timeout) + .json(&body) + .send() + .await + .map_err(|e| IIIError::Handler(format!("telegram http: {}", e.without_url())))?; + let payload: Value = resp + .json() + .await + .map_err(|e| IIIError::Handler(format!("telegram json: {e}")))?; + if payload.get("ok").and_then(Value::as_bool) != Some(true) { + return Err(IIIError::Handler(format!( + "telegram {method} failed: {payload}" + ))); + } + Ok(payload.get("result").cloned().unwrap_or(Value::Null)) +} + +pub fn inline_keyboard(rows: Vec>) -> Value { + let keyboard: Vec> = rows + .into_iter() + .map(|row| { + row.into_iter() + .map(|(text, data)| json!({ "text": text, "callback_data": data })) + .collect() + }) + .collect(); + json!({ "inline_keyboard": keyboard }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rich_thinking_draft_uses_input_rich_message_html() { + let draft = rich_thinking_draft(""); + let html = draft.get("html").and_then(Value::as_str).unwrap(); + assert!(html.contains("")); + assert!(html.contains("Thinking…")); + assert!(html.contains("")); + assert!(draft.get("blocks").is_none()); + } + + #[test] + fn rich_thinking_draft_escapes_html_in_content() { + let draft = rich_thinking_draft("a < b & c"); + let html = draft.get("html").and_then(Value::as_str).unwrap(); + assert!(html.contains("a < b & c")); + } +} diff --git a/telegram-bot/src/config.rs b/telegram-bot/src/config.rs new file mode 100644 index 000000000..7c83dce84 --- /dev/null +++ b/telegram-bot/src/config.rs @@ -0,0 +1,615 @@ +//! Operator-facing runtime configuration — fully hot-reloadable via the +//! `configuration` worker (see [`crate::configuration`]). +//! +//! Telegram update ingress is selected by an `updates` block: a `name` +//! discriminator plus a nested, adapter-specific `config` object (the same +//! shape session-manager uses for its storage `adapter`): +//! +//! ```yaml +//! updates: +//! name: polling +//! config: +//! timeout_seconds: 50 +//! ``` +//! +//! or +//! +//! ```yaml +//! updates: +//! name: webhook +//! config: +//! base_url: https://your-engine.example # iii engine root; the bot +//! # appends /telegram-bot/webhook +//! secret: optional-secret +//! ``` + +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; + +/// How much of the agent transcript is mirrored to Telegram. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum Verbosity { + #[default] + None, + Minimal, + High, + Debug, +} + +/// Whether mid-turn messages merge into the running turn or queue locally. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum SteeringMode { + #[default] + Steering, + Fifo, +} + +/// Model reasoning depth forwarded to `harness::send` `options.thinking_level`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum ThinkingLevel { + Minimal, + Low, + Medium, + High, + Xhigh, +} + +/// Streaming transport for assistant output. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum StreamTransport { + #[default] + Auto, + Draft, + Edit, +} + +#[derive(Serialize, Debug, Clone, PartialEq, JsonSchema)] +pub struct StreamingConfig { + #[serde(default)] + pub transport: StreamTransport, + #[serde(default = "default_draft_id_seed")] + pub draft_id_seed: i32, + #[serde(default = "default_draft_throttle_ms")] + pub draft_throttle_ms: u64, + /// Settle window (ms) a new-message creation waits before claiming its + /// ordering slot, so near-simultaneous sibling entries register first + /// and post in append order. + #[serde(default = "default_create_settle_ms")] + pub create_settle_ms: u64, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(dead_code)] +struct StreamingConfigRaw { + #[serde(default)] + transport: StreamTransport, + #[serde(default = "default_draft_id_seed")] + draft_id_seed: i32, + #[serde(default = "default_draft_throttle_ms")] + draft_throttle_ms: u64, + #[serde(default = "default_create_settle_ms")] + create_settle_ms: u64, + #[serde(default, deserialize_with = "deserialize_ignored")] + use_rich: IgnoredField, +} + +impl<'de> Deserialize<'de> for StreamingConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = StreamingConfigRaw::deserialize(deserializer)?; + Ok(Self { + transport: raw.transport, + draft_id_seed: raw.draft_id_seed, + draft_throttle_ms: raw.draft_throttle_ms, + create_settle_ms: raw.create_settle_ms, + }) + } +} + +impl Default for StreamingConfig { + fn default() -> Self { + Self { + transport: StreamTransport::default(), + draft_id_seed: default_draft_id_seed(), + draft_throttle_ms: default_draft_throttle_ms(), + create_settle_ms: default_create_settle_ms(), + } + } +} + +/// Provider + model id pair used in harness sends and session metadata. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ModelRef { + /// Provider id (e.g. `anthropic`). + pub provider: String, + /// Model id (e.g. `claude-sonnet-4`). + pub id: String, +} + +/// Update ingress selection. Adjacently tagged (`name` + `config`) so the +/// wire shape mirrors session-manager and `schemars` emits a `oneOf` the +/// console renders as a variant picker. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] +#[serde(tag = "name", content = "config", rename_all = "snake_case")] +pub enum UpdatesAdapter { + /// Long-poll `getUpdates` in a background task (default; no public URL needed). + Polling(PollingConfig), + /// Telegram POSTs updates to the engine HTTP trigger. + Webhook(WebhookConfig), +} + +impl Default for UpdatesAdapter { + fn default() -> Self { + UpdatesAdapter::Polling(PollingConfig::default()) + } +} + +/// Settings for the `polling` adapter. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PollingConfig { + /// `getUpdates` long-poll timeout in seconds (Telegram max: 50). + #[serde(default = "default_poll_timeout_seconds")] + pub timeout_seconds: u64, +} + +impl Default for PollingConfig { + fn default() -> Self { + Self { + timeout_seconds: default_poll_timeout_seconds(), + } + } +} + +/// Engine HTTP `api_path` the webhook ingress route is registered under, and +/// the suffix appended to the operator-supplied iii root to build the Telegram +/// webhook URL. Single source of truth for both the trigger registration +/// (see [`crate::functions::register_webhook_trigger`]) and URL derivation +/// (see [`WebhookConfig::endpoint_url`]). +pub const WEBHOOK_API_PATH: &str = "telegram-bot/webhook"; + +/// Settings for the `webhook` adapter. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WebhookConfig { + /// Public iii engine root (e.g. `https://your-engine.example`). The bot + /// appends its own webhook path (`/telegram-bot/webhook`) when registering + /// with Telegram — operators do not repeat the path. The legacy `url` key + /// (a full endpoint) is still accepted via alias for backward compatibility. + #[serde(alias = "url")] + pub base_url: String, + + /// Optional `X-Telegram-Bot-Api-Secret-Token` header value for validation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub secret: Option, +} + +impl WebhookConfig { + /// The full endpoint Telegram should POST updates to: + /// `{base_url}/telegram-bot/webhook`. Returns `None` when `base_url` is + /// empty/whitespace (no ingress to register). If `base_url` already ends + /// with the webhook path (a legacy full-URL config), it is used as-is so + /// existing deployments keep working without double-appending the path. + pub fn endpoint_url(&self) -> Option { + let base = self.base_url.trim().trim_end_matches('/'); + if base.is_empty() { + return None; + } + if base.ends_with(WEBHOOK_API_PATH) { + Some(base.to_string()) + } else { + Some(format!("{base}/{WEBHOOK_API_PATH}")) + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkerConfig { + /// Required. Telegram Bot API token. Env-expandable: `"${TELEGRAM_BOT_TOKEN}"`. + #[serde(default)] + pub bot_token: String, + + /// Update ingress adapter (`polling` or `webhook`) plus its settings. + #[serde(default)] + pub updates: UpdatesAdapter, + + /// When set, `/start` skips the model picker and uses this model for new chats. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_model: Option, + + /// How much transcript detail is sent to Telegram. + #[serde(default)] + pub verbosity: Verbosity, + + /// Default model reasoning depth for harness sends (omit = provider default). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_thinking_level: Option, + + /// Assistant output streaming settings. + #[serde(default)] + pub streaming: StreamingConfig, + + /// `steering` folds mid-turn messages into the running turn; `fifo` queues locally. + #[serde(default)] + pub steering_mode: SteeringMode, + + /// Globs passed to `harness::send` `options.functions.allow`. + #[serde(default)] + pub functions_allow: Vec, + + /// Optional system prompt for every harness send. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + + /// Timeout for harness, approval, state, and configuration RPCs (ms). + #[serde(default = "default_timeout_ms")] + pub timeout_ms: u64, +} + +/// Signature of the adapter-defining config fields (see [`WorkerConfig::boot_signature`]). +#[derive(Clone, Debug, PartialEq)] +pub struct BootSignature { + pub updates: UpdatesAdapter, +} + +impl WorkerConfig { + pub fn from_yaml(yaml: &str) -> Result { + let expanded = expand_env(yaml); + let raw: WorkerConfigRaw = + serde_yaml::from_str(&expanded).map_err(|e| format!("yaml parse: {e}"))?; + Ok(raw.into()) + } + + pub fn from_file(path: &str) -> Result { + let raw = std::fs::read_to_string(path).map_err(|e| format!("read {path}: {e}"))?; + Self::from_yaml(&raw) + } + + pub fn from_json(value: &Value) -> Result { + let raw: WorkerConfigRaw = + serde_json::from_value(value.clone()).map_err(|e| format!("json parse: {e}"))?; + Ok(raw.into()) + } + + pub fn to_json(&self) -> Value { + serde_json::to_value(self).expect("WorkerConfig serializes") + } + + pub fn json_schema() -> Value { + let root = schemars::schema_for!(WorkerConfig); + let mut schema = + serde_json::to_value(&root.schema).expect("WorkerConfig JSON Schema serializes"); + if let Some(obj) = schema.as_object_mut() { + if !root.definitions.is_empty() { + obj.insert( + "definitions".into(), + serde_json::to_value(&root.definitions).expect("definitions serialize"), + ); + } + obj.insert("example".into(), WorkerConfig::default().to_json()); + } + schema + } + + /// The updates-adapter-defining fields. The reload path compares this to + /// decide whether ingress must be restarted (adapter swap) or only the + /// shared config snapshot changes (list limits, token, etc.). + pub fn boot_signature(&self) -> BootSignature { + BootSignature { + updates: self.updates.clone(), + } + } + + pub fn resolve_updates(&self) -> UpdatesAdapter { + self.updates.clone() + } + + /// Returns an error when `bot_token` is empty — used at boot and on hot-reload. + pub fn validate(&self) -> Result<(), String> { + if self.bot_token.trim().is_empty() { + return Err("bot_token must be a non-empty string".into()); + } + Ok(()) + } +} + +/// Wire shape accepting deprecated timeout and display fields for migration. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(dead_code)] +struct WorkerConfigRaw { + #[serde(default)] + bot_token: String, + #[serde(default)] + updates: UpdatesAdapter, + #[serde(default, skip_serializing_if = "Option::is_none")] + default_model: Option, + #[serde(default)] + verbosity: Verbosity, + #[serde(default, skip_serializing_if = "Option::is_none")] + default_thinking_level: Option, + #[serde(default)] + streaming: StreamingConfig, + #[serde(default)] + steering_mode: SteeringMode, + #[serde(default)] + functions_allow: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + system_prompt: Option, + #[serde(default, deserialize_with = "deserialize_optional_u64")] + timeout_ms: Option, + #[serde(default, deserialize_with = "deserialize_optional_u64")] + harness_send_timeout_ms: Option, + #[serde(default, deserialize_with = "deserialize_optional_u64")] + approval_timeout_ms: Option, + #[serde(default, deserialize_with = "deserialize_optional_u64")] + state_timeout_ms: Option, + #[serde(default, deserialize_with = "deserialize_ignored")] + thinking_display: IgnoredField, + #[serde(default, deserialize_with = "deserialize_ignored")] + use_rich: IgnoredField, + #[serde(default, deserialize_with = "deserialize_ignored")] + edit_throttle_ms: IgnoredField, +} + +#[derive(Default)] +struct IgnoredField; + +fn deserialize_ignored<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let _ = Value::deserialize(deserializer)?; + Ok(IgnoredField) +} + +fn deserialize_optional_u64<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Option::::deserialize(deserializer) +} + +impl From for WorkerConfig { + fn from(raw: WorkerConfigRaw) -> Self { + let timeout_ms = raw + .timeout_ms + .or(raw.harness_send_timeout_ms) + .or(raw.approval_timeout_ms) + .or(raw.state_timeout_ms) + .unwrap_or_else(default_timeout_ms); + Self { + bot_token: raw.bot_token, + updates: raw.updates, + default_model: raw.default_model, + verbosity: raw.verbosity, + default_thinking_level: raw.default_thinking_level, + streaming: raw.streaming, + steering_mode: raw.steering_mode, + functions_allow: raw.functions_allow, + system_prompt: raw.system_prompt, + timeout_ms, + } + } +} + +fn default_poll_timeout_seconds() -> u64 { + 50 +} + +fn default_timeout_ms() -> u64 { + 10_000 +} + +fn default_draft_id_seed() -> i32 { + 1 +} + +fn default_draft_throttle_ms() -> u64 { + 300 +} + +fn default_create_settle_ms() -> u64 { + 50 +} + +impl Default for WorkerConfig { + fn default() -> Self { + Self { + bot_token: String::new(), + updates: UpdatesAdapter::default(), + default_model: None, + verbosity: Verbosity::default(), + default_thinking_level: None, + streaming: StreamingConfig::default(), + steering_mode: SteeringMode::default(), + functions_allow: Vec::new(), + system_prompt: None, + timeout_ms: default_timeout_ms(), + } + } +} + +fn expand_env(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut rest = input; + while let Some(start) = rest.find("${") { + out.push_str(&rest[..start]); + rest = &rest[start + 2..]; + if let Some(end) = rest.find('}') { + let name = &rest[..end]; + let val = std::env::var(name).unwrap_or_default(); + out.push_str(&val); + rest = &rest[end + 1..]; + } else { + out.push_str("${"); + break; + } + } + out.push_str(rest); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn defaults_from_empty_json() { + let cfg = WorkerConfig::from_json(&json!({})).unwrap(); + assert_eq!(cfg.verbosity, Verbosity::None); + assert_eq!(cfg.steering_mode, SteeringMode::Steering); + assert_eq!(cfg.timeout_ms, 10_000); + assert!(matches!(cfg.updates, UpdatesAdapter::Polling(_))); + } + + #[test] + fn parses_polling_adapter() { + let yaml = "updates:\n name: polling\n config:\n timeout_seconds: 30"; + let cfg = WorkerConfig::from_yaml(yaml).unwrap(); + let UpdatesAdapter::Polling(p) = cfg.updates else { + panic!("expected polling"); + }; + assert_eq!(p.timeout_seconds, 30); + } + + #[test] + fn parses_webhook_adapter() { + let yaml = + "updates:\n name: webhook\n config:\n base_url: https://engine.example\n secret: s"; + let cfg = WorkerConfig::from_yaml(yaml).unwrap(); + let UpdatesAdapter::Webhook(w) = cfg.updates else { + panic!("expected webhook"); + }; + assert_eq!(w.base_url, "https://engine.example"); + assert_eq!(w.secret.as_deref(), Some("s")); + } + + #[test] + fn webhook_legacy_url_alias_still_parses() { + let yaml = "updates:\n name: webhook\n config:\n url: https://engine.example/telegram-bot/webhook"; + let cfg = WorkerConfig::from_yaml(yaml).unwrap(); + let UpdatesAdapter::Webhook(w) = cfg.updates else { + panic!("expected webhook"); + }; + assert_eq!(w.base_url, "https://engine.example/telegram-bot/webhook"); + } + + #[test] + fn endpoint_url_appends_path_to_root_and_preserves_full_url() { + let root = WebhookConfig { + base_url: "https://engine.example/".into(), + secret: None, + }; + assert_eq!( + root.endpoint_url().as_deref(), + Some("https://engine.example/telegram-bot/webhook") + ); + + let full = WebhookConfig { + base_url: "https://engine.example/telegram-bot/webhook".into(), + secret: None, + }; + assert_eq!( + full.endpoint_url().as_deref(), + Some("https://engine.example/telegram-bot/webhook") + ); + + let empty = WebhookConfig { + base_url: " ".into(), + secret: None, + }; + assert_eq!(empty.endpoint_url(), None); + } + + #[test] + fn boot_signature_detects_adapter_swap() { + let polling = WorkerConfig { + bot_token: "t".into(), + updates: UpdatesAdapter::Polling(PollingConfig::default()), + ..WorkerConfig::default() + }; + let webhook = WorkerConfig { + updates: UpdatesAdapter::Webhook(WebhookConfig { + base_url: "https://x".into(), + secret: None, + }), + ..polling.clone() + }; + assert_ne!(polling.boot_signature(), webhook.boot_signature()); + assert_eq!(polling.boot_signature(), polling.boot_signature()); + } + + #[test] + fn validate_rejects_empty_token() { + assert!(WorkerConfig::default().validate().is_err()); + } + + #[test] + fn validate_accepts_nonempty_token() { + let cfg = WorkerConfig { + bot_token: "123:ABC".into(), + ..WorkerConfig::default() + }; + assert!(cfg.validate().is_ok()); + } + + #[test] + fn parses_streaming_config() { + let yaml = r#" +default_thinking_level: medium +streaming: + transport: draft +"#; + let cfg = WorkerConfig::from_yaml(yaml).unwrap(); + assert_eq!(cfg.default_thinking_level, Some(ThinkingLevel::Medium)); + assert_eq!(cfg.streaming.transport, StreamTransport::Draft); + } + + #[test] + fn timeout_ms_accepts_deprecated_field_names() { + let from_harness = + WorkerConfig::from_json(&json!({ "harness_send_timeout_ms": 8000 })).unwrap(); + assert_eq!(from_harness.timeout_ms, 8000); + + let from_approval = + WorkerConfig::from_json(&json!({ "approval_timeout_ms": 7000 })).unwrap(); + assert_eq!(from_approval.timeout_ms, 7000); + + let from_state = WorkerConfig::from_json(&json!({ "state_timeout_ms": 6000 })).unwrap(); + assert_eq!(from_state.timeout_ms, 6000); + + let explicit = WorkerConfig::from_json(&json!({ "timeout_ms": 5000 })).unwrap(); + assert_eq!(explicit.timeout_ms, 5000); + } + + #[test] + fn ignores_removed_display_fields() { + let cfg = WorkerConfig::from_json(&json!({ + "thinking_display": "separate", + "use_rich": true, + "edit_throttle_ms": 500, + "streaming": { "use_rich": true } + })) + .unwrap(); + assert_eq!(cfg.timeout_ms, 10_000); + } + + #[test] + fn shipped_collect_config_boots_for_interface_collection() { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/config.collect.yaml"); + let cfg = WorkerConfig::from_file(path).expect("config.collect.yaml parses"); + cfg.validate() + .expect("config.collect.yaml must boot for CI interface collection"); + } +} diff --git a/telegram-bot/src/configuration.rs b/telegram-bot/src/configuration.rs new file mode 100644 index 000000000..11a9ae421 --- /dev/null +++ b/telegram-bot/src/configuration.rs @@ -0,0 +1,234 @@ +//! Integration with the `configuration` worker — all fields hot-reload +//! without restart, including `bot_token`. + +use std::sync::Arc; +use std::time::Duration; + +use iii_sdk::{IIIError, RegisterFunction, RegisterTriggerInput, TriggerRequest, III}; +use serde_json::{json, Value}; +use tokio::sync::RwLock; + +use crate::config::WorkerConfig; +use crate::deps::Deps; + +pub type ConfigCell = Arc>>; + +pub const CONFIG_ID: &str = "telegram-bot"; +const CONFIG_FN_ID: &str = "telegram-bot::on-config-change"; +const CONFIG_RETRIES: u32 = 3; +const CONFIG_RETRY_BACKOFF_MS: u64 = 250; + +fn config_rpc_timeout_ms(seed: Option<&WorkerConfig>) -> u64 { + seed.map(|s| s.timeout_ms) + .unwrap_or_else(|| WorkerConfig::default().timeout_ms) +} + +pub async fn register_config(iii: &III, seed: Option<&WorkerConfig>) -> Result<(), String> { + let mut payload = json!({ + "id": CONFIG_ID, + "name": "Telegram Worker", + "description": "Telegram bot bridge: bot token, updates adapter (polling/webhook), verbosity, default model, and harness send options.", + "schema": WorkerConfig::json_schema(), + }); + if let Some(seed) = seed { + payload["initial_value"] = seed.to_json(); + } else if should_seed_default_value(iii).await? { + payload["initial_value"] = WorkerConfig::default().to_json(); + } + trigger_with_retry( + iii, + "configuration::register", + payload, + config_rpc_timeout_ms(seed), + ) + .await?; + Ok(()) +} + +pub async fn fetch_config(iii: &III) -> Result { + fetch_config_with_timeout(iii, WorkerConfig::default().timeout_ms).await +} + +async fn fetch_config_with_timeout(iii: &III, timeout_ms: u64) -> Result { + let value = try_get_config_value(iii, timeout_ms) + .await? + .ok_or_else(|| format!("configuration `{CONFIG_ID}` not found"))?; + if value.is_null() { + tracing::info!("no configuration value found; using built-in default configuration"); + return Ok(WorkerConfig::default()); + } + WorkerConfig::from_json(&value) +} + +async fn should_seed_default_value(iii: &III) -> Result { + match try_get_config_value(iii, WorkerConfig::default().timeout_ms).await? { + None => Ok(true), + Some(value) if value.is_null() => Ok(true), + Some(_) => Ok(false), + } +} + +async fn try_get_config_value(iii: &III, timeout_ms: u64) -> Result, String> { + match trigger_with_retry( + iii, + "configuration::get", + json!({ "id": CONFIG_ID }), + timeout_ms, + ) + .await + { + Ok(resp) => Ok(resp.get("value").cloned()), + Err(e) if e.to_ascii_uppercase().contains("NOT_FOUND") => Ok(None), + Err(e) => Err(e), + } +} + +/// Apply a validated config snapshot. Returns false when rejected (empty token). +pub async fn apply_config(cell: &ConfigCell, cfg: WorkerConfig) -> bool { + if let Err(reason) = cfg.validate() { + tracing::warn!(reason = %reason, "config reload rejected; keeping previous config"); + return false; + } + let rotated = { + let prev = cell.read().await; + prev.bot_token != cfg.bot_token + }; + if rotated { + tracing::info!("bot_token rotated (value not logged)"); + } + *cell.write().await = Arc::new(cfg); + true +} + +pub fn register_config_trigger( + iii: &III, + cell: ConfigCell, + deps: Arc, +) -> Result<(), IIIError> { + let cell_for_fn = cell.clone(); + let engine = iii.clone(); + let deps_for_fn = deps.clone(); + iii.register_function( + CONFIG_FN_ID, + RegisterFunction::new_async(move |_payload: ConfigChangeRequest| { + let cell = cell_for_fn.clone(); + let engine = engine.clone(); + let deps = deps_for_fn.clone(); + async move { + on_config_change(&engine, &cell, &deps).await; + Ok::<_, IIIError>(ConfigChangeAck { ok: true }) + } + }) + .description( + "Internal: reload telegram-bot configuration from the authoritative store on change.", + ), + ); + + iii.register_trigger(RegisterTriggerInput { + trigger_type: "configuration".to_string(), + function_id: CONFIG_FN_ID.to_string(), + config: json!({ + "configuration_id": CONFIG_ID, + "event_types": ["configuration:updated"], + }), + metadata: None, + })?; + Ok(()) +} + +async fn on_config_change(iii: &III, cell: &ConfigCell, deps: &Arc) { + let prev = cell.read().await.clone(); + let timeout_ms = prev.timeout_ms; + let cfg = match fetch_config_with_timeout(iii, timeout_ms).await { + Ok(cfg) => cfg, + Err(e) => { + tracing::error!( + error = %e, + "config-change: failed to fetch authoritative configuration; keeping previous config" + ); + return; + } + }; + if apply_config(cell, cfg.clone()).await { + crate::ingress::apply_config_change(deps, &prev, &cfg).await; + if let Err(e) = crate::clients::telegram::set_my_commands(deps).await { + tracing::warn!(error = %e, "failed to refresh bot commands after config reload"); + } + tracing::info!("telegram-bot configuration reloaded"); + } +} + +async fn trigger_with_retry( + iii: &III, + function_id: &str, + payload: Value, + timeout_ms: u64, +) -> Result { + let mut last_err = String::new(); + for attempt in 1..=CONFIG_RETRIES { + match iii + .trigger(TriggerRequest { + function_id: function_id.to_string(), + payload: payload.clone(), + action: None, + timeout_ms: Some(timeout_ms), + }) + .await + { + Ok(v) => return Ok(v), + Err(e) => { + last_err = e.to_string(); + if attempt < CONFIG_RETRIES { + tokio::time::sleep(Duration::from_millis( + CONFIG_RETRY_BACKOFF_MS * u64::from(attempt), + )) + .await; + } + } + } + } + Err(format!( + "{function_id} failed after {CONFIG_RETRIES} attempts: {last_err}" + )) +} + +#[derive( + Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema, +)] +pub struct ConfigChangeAck { + pub ok: bool, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Deserialize, schemars::JsonSchema)] +pub struct ConfigChangeRequest {} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn apply_config_swaps_all_fields() { + let cell: ConfigCell = Arc::new(RwLock::new(Arc::new(WorkerConfig::default()))); + let next = WorkerConfig { + bot_token: "1:token".into(), + verbosity: crate::config::Verbosity::Debug, + timeout_ms: 500, + ..WorkerConfig::default() + }; + assert!(apply_config(&cell, next).await); + let snap = cell.read().await.clone(); + assert_eq!(snap.bot_token, "1:token"); + assert_eq!(snap.verbosity, crate::config::Verbosity::Debug); + assert_eq!(snap.timeout_ms, 500); + } + + #[tokio::test] + async fn apply_config_rejects_empty_token_keeps_previous() { + let cell: ConfigCell = Arc::new(RwLock::new(Arc::new(WorkerConfig { + bot_token: "keep:me".into(), + ..WorkerConfig::default() + }))); + assert!(!apply_config(&cell, WorkerConfig::default()).await); + assert_eq!(cell.read().await.bot_token, "keep:me"); + } +} diff --git a/telegram-bot/src/deps.rs b/telegram-bot/src/deps.rs new file mode 100644 index 000000000..10595e7f8 --- /dev/null +++ b/telegram-bot/src/deps.rs @@ -0,0 +1,302 @@ +use std::collections::BTreeSet; +use std::sync::atomic::AtomicI64; +use std::sync::Arc; +use std::time::Instant; + +use dashmap::DashMap; +use iii_sdk::{Trigger, III}; +use reqwest::Client; +use tokio::sync::{Mutex, Notify}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +use crate::configuration::ConfigCell; +use crate::render::stream::EffectiveTransport; +use crate::render::verbosity::MessagePhase; + +pub const STATE_SCOPE: &str = "telegram-bot"; + +#[derive(Clone)] +pub struct Deps { + pub iii: Arc, + pub config: ConfigCell, + pub runtime: Arc, +} + +impl Deps { + pub fn new(iii: Arc, config: ConfigCell) -> Self { + Self { + iii, + config, + runtime: Arc::new(RuntimeState::new()), + } + } + + pub async fn cfg(&self) -> Arc { + self.config.read().await.clone() + } +} + +/// Latest render state for an assistant entry (used for final flush). +#[derive(Debug, Clone)] +pub struct PendingEntryState { + pub chat_id: i64, + pub revision: u64, + pub text: String, + pub thinking_text: String, + pub phase: MessagePhase, + pub message_id: Option, + pub thinking_message_id: Option, + pub finalized: bool, + /// Whether a draft was already pushed for this entry, so finalize knows it + /// must clear the ephemeral draft even when rebuilding from this snapshot. + pub draft_started: bool, + /// Per-entry ordering key (append time in ms); lower posts first. + pub order_key: i64, +} + +/// Per-entry streaming session state. +#[derive(Debug, Clone)] +pub struct StreamSession { + pub draft_id: i32, + pub chat_id: i64, + pub transport: EffectiveTransport, + pub message_id: Option, + pub thinking_message_id: Option, + pub last_revision: u64, + pub last_text: String, + pub last_thinking_text: String, + pub phase: MessagePhase, + pub finalized: bool, + pub draft_started: bool, + /// Per-entry ordering key (append time in ms); lower posts first. + pub order_key: i64, +} + +pub struct RuntimeState { + pub http: Client, + /// Last accepted revision per (session_id, entry_id). + pub revisions: DashMap<(String, String), u64>, + /// Last edit time per (chat_id, message_id). + pub edit_times: DashMap<(i64, i64), Instant>, + /// Last draft update time per (chat_id, draft_id). + pub draft_times: DashMap<(i64, i32), Instant>, + /// Latest render snapshot per entry (for turn-end flush). + pub pending_entries: DashMap<(String, String), PendingEntryState>, + /// Active stream sessions per entry. + pub stream_sessions: DashMap<(String, String), StreamSession>, + /// Entries whose turn has been finalized, mapped to the revision at which + /// they were finalized. A later, higher-revision `message-updated` may still + /// reconcile the persisted message; `u64::MAX` means "finalized, revision + /// unknown" (e.g. learned from durable state after a restart). + pub finalized_entries: DashMap<(String, String), u64>, + /// Serializes `message-added`, `message-updated`, and finalize for one entry. + pub entry_locks: DashMap<(String, String), Arc>>, + /// Serializes new-message creation per chat so the Telegram message order + /// matches entry order. + pub chat_create_locks: DashMap>>, + /// In-flight new-message creation requests per chat, ordered by + /// `(order_key, entry_id, chunk_index)`; the minimum is admitted first. + pub chat_create_order: DashMap>, + /// Entries that have started (`message-added`) but not yet posted a first + /// `sendMessage` — later entries must wait until prior slots materialize. + pub chat_pending_materialization: DashMap>, + /// Wakes creation waiters for a chat when the admitted set changes. + pub chat_create_notifies: DashMap>, + /// Highest order key already materialized into a Telegram message per chat. + pub last_created_order: DashMap, + /// Chats where draft transport failed and edit fallback is pinned. + pub draft_disabled_chats: DashMap, + /// FIFO message queue per chat_id. + pub fifo_queues: DashMap>, + /// Next `getUpdates` offset (last_update_id + 1). + pub poll_offset: AtomicI64, + /// Cancels the background poller on adapter switch or shutdown. + pub poller_cancel: Mutex>, + /// Join handle for the background poller task. + pub poller_handle: Mutex>>, + /// Engine HTTP-trigger handle for the webhook ingress route. `Some` only + /// while the webhook adapter is active; retained so the route can be + /// unregistered when switching back to polling (the SDK `Trigger` has no + /// `Drop`, so it must be explicitly `take()`n and `.unregister()`ed). + pub webhook_trigger: Mutex>, + /// Latest harness turn_id per session (for trace baggage on binding handlers). + pub active_turns: DashMap, +} + +impl Default for RuntimeState { + fn default() -> Self { + Self::new() + } +} + +impl RuntimeState { + pub fn new() -> Self { + Self { + http: Client::new(), + revisions: DashMap::new(), + edit_times: DashMap::new(), + draft_times: DashMap::new(), + pending_entries: DashMap::new(), + stream_sessions: DashMap::new(), + finalized_entries: DashMap::new(), + entry_locks: DashMap::new(), + chat_create_locks: DashMap::new(), + chat_create_order: DashMap::new(), + chat_create_notifies: DashMap::new(), + chat_pending_materialization: DashMap::new(), + last_created_order: DashMap::new(), + draft_disabled_chats: DashMap::new(), + fifo_queues: DashMap::new(), + poll_offset: AtomicI64::new(0), + poller_cancel: Mutex::new(None), + poller_handle: Mutex::new(None), + webhook_trigger: Mutex::new(None), + active_turns: DashMap::new(), + } + } + + /// Per-entry mutex so finalize and stream handlers cannot interleave. + pub fn entry_lock(&self, key: &(String, String)) -> Arc> { + self.entry_locks + .entry(key.clone()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + } + + /// Per-chat mutex so only one new Telegram message is created at a time. + pub fn chat_create_lock(&self, chat_id: i64) -> Arc> { + self.chat_create_locks + .entry(chat_id) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + } + + /// Per-chat notify used to wake new-message creation waiters. + pub fn chat_create_notify(&self, chat_id: i64) -> Arc { + self.chat_create_notifies + .entry(chat_id) + .or_insert_with(|| Arc::new(Notify::new())) + .clone() + } + + /// Drop in-memory streaming/queue state when a chat starts a new session. + pub fn reset_for_chat(&self, chat_id: i64, old_session_id: Option<&str>) { + self.fifo_queues.remove(&chat_id); + self.chat_create_order.remove(&chat_id); + self.chat_pending_materialization.remove(&chat_id); + self.last_created_order.remove(&chat_id); + self.chat_create_locks.remove(&chat_id); + if let Some((_, notify)) = self.chat_create_notifies.remove(&chat_id) { + // Wake stragglers so they re-evaluate against the cleared state. + notify.notify_waiters(); + } + if let Some(sid) = old_session_id { + self.stream_sessions.retain(|k, _| k.0 != sid); + self.pending_entries.retain(|k, _| k.0 != sid); + self.finalized_entries.retain(|k, _| k.0 != sid); + self.entry_locks.retain(|k, _| k.0 != sid); + self.revisions.retain(|k, _| k.0 != sid); + self.active_turns.remove(sid); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reset_for_chat_clears_fifo_and_session_state() { + let runtime = RuntimeState::new(); + runtime.fifo_queues.insert(42, vec!["queued".into()]); + runtime.stream_sessions.insert( + ("old-session".into(), "entry-1".into()), + StreamSession { + draft_id: 1, + chat_id: 42, + transport: EffectiveTransport::Edit, + message_id: None, + thinking_message_id: None, + last_revision: 0, + last_text: String::new(), + last_thinking_text: String::new(), + phase: MessagePhase::Empty, + finalized: false, + draft_started: false, + order_key: 0, + }, + ); + runtime.pending_entries.insert( + ("old-session".into(), "entry-1".into()), + PendingEntryState { + chat_id: 42, + revision: 0, + text: String::new(), + thinking_text: String::new(), + phase: MessagePhase::Empty, + message_id: None, + thinking_message_id: None, + finalized: false, + draft_started: false, + order_key: 0, + }, + ); + runtime.stream_sessions.insert( + ("other-session".into(), "entry-2".into()), + StreamSession { + draft_id: 2, + chat_id: 99, + transport: EffectiveTransport::Edit, + message_id: None, + thinking_message_id: None, + last_revision: 0, + last_text: String::new(), + last_thinking_text: String::new(), + phase: MessagePhase::Empty, + finalized: false, + draft_started: false, + order_key: 0, + }, + ); + + runtime.reset_for_chat(42, Some("old-session")); + + assert!(!runtime.active_turns.contains_key("old-session")); + assert!(!runtime.fifo_queues.contains_key(&42)); + assert!(!runtime + .stream_sessions + .contains_key(&("old-session".into(), "entry-1".into()))); + assert!(runtime + .stream_sessions + .contains_key(&("other-session".into(), "entry-2".into()))); + assert!(!runtime + .pending_entries + .contains_key(&("old-session".into(), "entry-1".into()))); + } + + #[test] + fn reset_for_chat_clears_sequencing_state() { + let runtime = RuntimeState::new(); + let mut set = BTreeSet::new(); + set.insert((1i64, "entry-1".to_string(), 0)); + runtime.chat_create_order.insert(42, set); + runtime + .chat_pending_materialization + .insert(42, BTreeSet::from([(2i64, "entry-2".to_string())])); + runtime.last_created_order.insert(42, 5); + let _ = runtime.chat_create_lock(42); + let _ = runtime.chat_create_notify(42); + // Unrelated chat must survive. + runtime.last_created_order.insert(99, 1); + + runtime.reset_for_chat(42, None); + + assert!(!runtime.chat_create_order.contains_key(&42)); + assert!(!runtime.chat_pending_materialization.contains_key(&42)); + assert!(!runtime.last_created_order.contains_key(&42)); + assert!(!runtime.chat_create_locks.contains_key(&42)); + assert!(!runtime.chat_create_notifies.contains_key(&42)); + assert!(runtime.last_created_order.contains_key(&99)); + } +} diff --git a/telegram-bot/src/functions/bindings/message_added.rs b/telegram-bot/src/functions/bindings/message_added.rs new file mode 100644 index 000000000..39af315b2 --- /dev/null +++ b/telegram-bot/src/functions/bindings/message_added.rs @@ -0,0 +1,39 @@ +use std::sync::Arc; + +use iii_sdk::{IIIError, III}; + +use crate::deps::Deps; +use crate::render::stream; +use crate::telemetry; +use crate::types::MessageAddedEvent; + +#[derive(Debug, serde::Serialize, schemars::JsonSchema)] +pub struct BindingAck { + pub ok: bool, +} + +pub fn register(iii: &Arc, deps: &Arc) { + super::super::register( + iii, + deps, + super::super::ON_MESSAGE_ADDED_ID, + "Create a Telegram message for each new assistant or function_result entry.", + |d, evt| async move { handle(&d, evt).await }, + ); +} + +async fn handle(deps: &Deps, evt: MessageAddedEvent) -> Result { + let message = match evt.message { + Some(m) => m, + None => return Ok(BindingAck { ok: true }), + }; + + let session_id = evt.session_id.clone(); + let entry_id = evt.entry_id.clone(); + let timestamp = evt.timestamp; + telemetry::with_session_baggage(deps, &session_id, Some(&entry_id), || async { + stream::on_message_added(deps, &session_id, &entry_id, &message, timestamp).await + }) + .await?; + Ok(BindingAck { ok: true }) +} diff --git a/telegram-bot/src/functions/bindings/message_updated.rs b/telegram-bot/src/functions/bindings/message_updated.rs new file mode 100644 index 000000000..4ec5b246c --- /dev/null +++ b/telegram-bot/src/functions/bindings/message_updated.rs @@ -0,0 +1,30 @@ +use std::sync::Arc; + +use iii_sdk::{IIIError, III}; + +use crate::deps::Deps; +use crate::render::stream; +use crate::telemetry; +use crate::types::MessageUpdatedEvent; + +use super::message_added::BindingAck; + +pub fn register(iii: &Arc, deps: &Arc) { + super::super::register( + iii, + deps, + super::super::ON_MESSAGE_UPDATED_ID, + "Stream assistant edits into Telegram, throttled by revision.", + |d, evt| async move { handle(&d, evt).await }, + ); +} + +async fn handle(deps: &Deps, evt: MessageUpdatedEvent) -> Result { + let session_id = evt.session_id.clone(); + let entry_id = evt.entry_id.clone(); + telemetry::with_session_baggage(deps, &session_id, Some(&entry_id), || async { + stream::on_message_updated(deps, evt).await + }) + .await?; + Ok(BindingAck { ok: true }) +} diff --git a/telegram-bot/src/functions/bindings/mod.rs b/telegram-bot/src/functions/bindings/mod.rs new file mode 100644 index 000000000..7a1888e7c --- /dev/null +++ b/telegram-bot/src/functions/bindings/mod.rs @@ -0,0 +1,21 @@ +pub mod message_added; +pub mod message_updated; +pub mod pending_created; +pub mod pending_resolved; +pub mod status_changed; +pub mod turn_completed; + +use std::sync::Arc; + +use iii_sdk::III; + +use crate::deps::Deps; + +pub fn register(iii: &Arc, deps: &Arc) { + message_added::register(iii, deps); + message_updated::register(iii, deps); + status_changed::register(iii, deps); + turn_completed::register(iii, deps); + pending_created::register(iii, deps); + pending_resolved::register(iii, deps); +} diff --git a/telegram-bot/src/functions/bindings/pending_created.rs b/telegram-bot/src/functions/bindings/pending_created.rs new file mode 100644 index 000000000..de4b01039 --- /dev/null +++ b/telegram-bot/src/functions/bindings/pending_created.rs @@ -0,0 +1,78 @@ +use std::sync::Arc; + +use iii_sdk::{IIIError, III}; + +use crate::clients::telegram; +use crate::deps::Deps; +use crate::kv; +use crate::types::PendingApprovalRecord; + +use super::message_added::BindingAck; + +pub fn register(iii: &Arc, deps: &Arc) { + super::super::register( + iii, + deps, + super::super::ON_PENDING_CREATED_ID, + "Send an inline approval keyboard when a function call is held.", + |d, record| async move { handle_internal(&d, record).await }, + ); +} + +pub async fn handle_internal( + deps: &Deps, + record: PendingApprovalRecord, +) -> Result { + let Some(chat_id) = kv::chat_id_for_session(deps, &record.session_id).await else { + return Ok(BindingAck { ok: true }); + }; + + let token = short_token(&record.function_call_id); + kv::store_approval_callback( + deps, + &token, + &crate::types::ApprovalCallbackData { + session_id: record.session_id.clone(), + function_call_id: record.function_call_id.clone(), + function_id: record.function_id.clone(), + }, + ) + .await?; + + let args = record + .arguments_excerpt + .as_ref() + .map(|v| v.to_string()) + .unwrap_or_else(|| "{}".into()); + let prompt = format!( + "Approval required for `{}`\n{}", + record.function_id, + crate::text::truncate_ellipsis(&args, 500) + ); + + let keyboard = telegram::inline_keyboard(vec![ + vec![ + ("Approve".into(), format!("a:{token}")), + ("Reject".into(), format!("d:{token}")), + ], + vec![("Approve always".into(), format!("w:{token}"))], + ]); + + let message_id = telegram::send_message(deps, chat_id, &prompt, Some(keyboard), None).await?; + kv::set_approval_message_id( + deps, + &record.session_id, + &record.function_call_id, + message_id, + ) + .await?; + Ok(BindingAck { ok: true }) +} + +fn short_token(function_call_id: &str) -> String { + let mut hash: u64 = 0; + for b in function_call_id.bytes() { + hash = hash.wrapping_mul(31).wrapping_add(u64::from(b)); + } + format!("{:08x}", hash & 0xffff_ffff) +} diff --git a/telegram-bot/src/functions/bindings/pending_resolved.rs b/telegram-bot/src/functions/bindings/pending_resolved.rs new file mode 100644 index 000000000..36bbdd603 --- /dev/null +++ b/telegram-bot/src/functions/bindings/pending_resolved.rs @@ -0,0 +1,42 @@ +use std::sync::Arc; + +use iii_sdk::{IIIError, III}; + +use crate::clients::telegram; +use crate::deps::Deps; +use crate::kv; +use crate::types::PendingResolvedEvent; + +use super::message_added::BindingAck; + +pub fn register(iii: &Arc, deps: &Arc) { + super::super::register( + iii, + deps, + super::super::ON_PENDING_RESOLVED_ID, + "Clear the approval prompt when a held call is resolved.", + |d, evt| async move { handle(&d, evt).await }, + ); +} + +async fn handle(deps: &Deps, evt: PendingResolvedEvent) -> Result { + let Some(chat_id) = kv::chat_id_for_session(deps, &evt.session_id).await else { + return Ok(BindingAck { ok: true }); + }; + let Some(message_id) = + kv::approval_message_id(deps, &evt.session_id, &evt.function_call_id).await + else { + return Ok(BindingAck { ok: true }); + }; + + let label = match evt.outcome.as_str() { + "allow" => "✅ approved", + "deny" => "❌ rejected", + "timeout" => "⏱ timed out", + "aborted" => "⏹ aborted", + other => other, + }; + let _ = telegram::edit_message_text(deps, chat_id, message_id, label, None).await; + let _ = telegram::edit_message_reply_markup(deps, chat_id, message_id).await; + Ok(BindingAck { ok: true }) +} diff --git a/telegram-bot/src/functions/bindings/status_changed.rs b/telegram-bot/src/functions/bindings/status_changed.rs new file mode 100644 index 000000000..42d73796a --- /dev/null +++ b/telegram-bot/src/functions/bindings/status_changed.rs @@ -0,0 +1,31 @@ +use std::sync::Arc; + +use iii_sdk::{IIIError, III}; + +use crate::deps::Deps; +use crate::telemetry; +use crate::types::StatusChangedEvent; + +use super::message_added::BindingAck; + +pub fn register(iii: &Arc, deps: &Arc) { + super::super::register( + iii, + deps, + super::super::ON_STATUS_CHANGED_ID, + "Observe session status changes.", + |d, evt| async move { handle(d, evt).await }, + ); +} + +async fn handle(deps: Arc, evt: StatusChangedEvent) -> Result { + telemetry::with_session_baggage(&deps, &evt.session_id, None, || async { + tracing::debug!( + status = %evt.status, + reason = evt.status_reason.as_deref().unwrap_or(""), + "session status changed" + ); + Ok(BindingAck { ok: true }) + }) + .await +} diff --git a/telegram-bot/src/functions/bindings/turn_completed.rs b/telegram-bot/src/functions/bindings/turn_completed.rs new file mode 100644 index 000000000..c5d600426 --- /dev/null +++ b/telegram-bot/src/functions/bindings/turn_completed.rs @@ -0,0 +1,111 @@ +use std::sync::Arc; + +use iii_sdk::{IIIError, III}; + +use crate::config::SteeringMode; +use crate::deps::Deps; +use crate::kv; +use crate::render::{stream, verbosity}; +use crate::telemetry; +use crate::types::TurnCompletedEvent; + +use super::message_added::BindingAck; + +pub fn register(iii: &Arc, deps: &Arc) { + super::super::register( + iii, + deps, + super::super::ON_TURN_COMPLETED_ID, + "Finalize streaming, drain FIFO queue, and post turn outcome toasts.", + |d, evt| async move { handle(&d, evt).await }, + ); +} + +async fn handle(deps: &Deps, evt: TurnCompletedEvent) -> Result { + telemetry::with_baggage(&evt.session_id, &evt.turn_id, || async { + let cfg = deps.cfg().await; + + stream::finalize_session(deps, &evt.session_id).await?; + + deps.runtime.active_turns.remove(&evt.session_id); + + let Some(chat_id) = kv::chat_id_for_session(deps, &evt.session_id).await else { + return Ok(BindingAck { ok: true }); + }; + + let status_msg = verbosity::turn_status_message( + &evt.status, + evt.reason.as_deref(), + evt.result_error.as_deref(), + ); + if !status_msg.is_empty() { + let order_key = deps + .runtime + .last_created_order + .get(&chat_id) + .map(|r| *r.value() + 1) + .unwrap_or(i64::MAX); + let entry_id = format!("_turn_status_{}", evt.turn_id); + let _ = stream::send_chat_message_in_order( + deps, + &cfg, + stream::OrderedChatMessage { + chat_id, + order_key, + entry_id: &entry_id, + chunk_idx: 0, + text: &status_msg, + reply_markup: None, + }, + ) + .await; + } + + if cfg.steering_mode == SteeringMode::Fifo { + drain_fifo(deps, chat_id, &evt.session_id, &cfg).await?; + } + + Ok(BindingAck { ok: true }) + }) + .await +} + +async fn drain_fifo( + deps: &Deps, + chat_id: i64, + session_id: &str, + cfg: &crate::config::WorkerConfig, +) -> Result<(), IIIError> { + let text = deps + .runtime + .fifo_queues + .get(&chat_id) + .and_then(|q| q.first().cloned()); + + let Some(text) = text else { + return Ok(()); + }; + + let Some(model) = kv::chat_model(deps, chat_id).await else { + tracing::warn!(chat_id, "FIFO drain skipped: no model selected"); + return Ok(()); + }; + + crate::functions::webhook::send_user_message( + deps, + chat_id, + Some(session_id), + &text, + None, + cfg, + &model, + ) + .await?; + + if let Some(mut q) = deps.runtime.fifo_queues.get_mut(&chat_id) { + if !q.is_empty() { + q.remove(0); + } + } + Ok(()) +} diff --git a/telegram-bot/src/functions/mod.rs b/telegram-bot/src/functions/mod.rs new file mode 100644 index 000000000..76f6c2034 --- /dev/null +++ b/telegram-bot/src/functions/mod.rs @@ -0,0 +1,140 @@ +pub mod bindings; +pub mod set_webhook; +pub mod webhook; + +use std::future::Future; +use std::sync::Arc; + +use iii_sdk::{IIIError, RegisterFunction, RegisterTriggerInput, Trigger, III}; +use schemars::JsonSchema; +use serde::de::DeserializeOwned; +use serde::Serialize; +use serde_json::json; + +use crate::deps::Deps; + +pub const WEBHOOK_ID: &str = "telegram-bot::webhook"; +pub const SET_WEBHOOK_ID: &str = "telegram-bot::set-webhook"; +pub const ON_MESSAGE_ADDED_ID: &str = "telegram-bot::on-message-added"; +pub const ON_MESSAGE_UPDATED_ID: &str = "telegram-bot::on-message-updated"; +pub const ON_STATUS_CHANGED_ID: &str = "telegram-bot::on-status-changed"; +pub const ON_TURN_COMPLETED_ID: &str = "telegram-bot::on-turn-completed"; +pub const ON_PENDING_CREATED_ID: &str = "telegram-bot::on-pending-created"; +pub const ON_PENDING_RESOLVED_ID: &str = "telegram-bot::on-pending-resolved"; + +fn register( + iii: &Arc, + deps: &Arc, + id: &str, + description: &str, + handler: F, +) where + Req: DeserializeOwned + JsonSchema + Send + 'static, + Resp: Serialize + JsonSchema + Send + 'static, + F: Fn(Arc, Req) -> Fut + Send + Sync + Clone + 'static, + Fut: Future> + Send + 'static, +{ + let deps = deps.clone(); + iii.register_function( + id, + RegisterFunction::new_async(move |req: Req| { + let deps = deps.clone(); + let handler = handler.clone(); + async move { handler(deps, req).await } + }) + .description(description), + ); +} + +pub fn register_all(iii: &Arc, deps: &Arc) { + webhook::register(iii, deps); + set_webhook::register(iii, deps); + bindings::register(iii, deps); + tracing::info!("all functions registered"); +} + +pub fn bind_triggers(iii: &Arc) { + let bindings = [ + ( + "session::message-added", + ON_MESSAGE_ADDED_ID, + json!({ "roles": ["assistant", "function_result"] }), + ), + ( + "session::message-updated", + ON_MESSAGE_UPDATED_ID, + json!({ "roles": ["assistant"] }), + ), + ("session::status-changed", ON_STATUS_CHANGED_ID, json!({})), + ("harness::turn-completed", ON_TURN_COMPLETED_ID, json!({})), + ( + "approval::pending-created", + ON_PENDING_CREATED_ID, + json!({}), + ), + ( + "approval::pending-resolved", + ON_PENDING_RESOLVED_ID, + json!({}), + ), + ]; + for (trigger_type, function_id, config) in bindings { + bind_best_effort(iii, trigger_type, function_id, config); + } +} + +/// Register the always-on control-plane HTTP triggers at boot. +/// +/// Only the `set-webhook` control endpoint is static. The `webhook` ingress +/// route is registered/unregistered dynamically by [`crate::ingress`] to follow +/// the `updates` adapter (created on switch to webhook, removed on switch back +/// to polling) — see [`register_webhook_trigger`]. +pub fn bind_http_triggers(iii: &Arc) { + let http = [(SET_WEBHOOK_ID, "telegram-bot/set-webhook", "POST")]; + for (function_id, api_path, http_method) in http { + match iii.register_trigger(RegisterTriggerInput { + trigger_type: "http".to_string(), + function_id: function_id.to_string(), + config: json!({ "api_path": api_path, "http_method": http_method }), + metadata: None, + }) { + Ok(_) => tracing::info!(function_id, api_path, "http trigger registered"), + Err(e) => tracing::warn!(error = %e, function_id, "failed to register http trigger"), + } + } +} + +/// Register the Telegram webhook ingress HTTP route and return its [`Trigger`] +/// handle so the caller can later [`Trigger::unregister`] it. The route path is +/// [`crate::config::WEBHOOK_API_PATH`] — the same constant used to derive the +/// public URL handed to Telegram, so they cannot drift. +pub fn register_webhook_trigger(iii: &III) -> Result { + iii.register_trigger(RegisterTriggerInput { + trigger_type: "http".to_string(), + function_id: WEBHOOK_ID.to_string(), + config: json!({ "api_path": crate::config::WEBHOOK_API_PATH, "http_method": "POST" }), + metadata: None, + }) +} + +fn bind_best_effort( + iii: &Arc, + trigger_type: &str, + function_id: &str, + config: serde_json::Value, +) { + match iii.register_trigger(RegisterTriggerInput { + trigger_type: trigger_type.to_string(), + function_id: function_id.to_string(), + config, + metadata: None, + }) { + Ok(_) => tracing::info!(trigger_type, function_id, "trigger binding requested"), + Err(e) => tracing::warn!( + trigger_type, + function_id, + error = %e, + "trigger binding failed (sibling absent?)" + ), + } +} diff --git a/telegram-bot/src/functions/set_webhook.rs b/telegram-bot/src/functions/set_webhook.rs new file mode 100644 index 000000000..88b727ef6 --- /dev/null +++ b/telegram-bot/src/functions/set_webhook.rs @@ -0,0 +1,44 @@ +use std::sync::Arc; + +use iii_sdk::{IIIError, III}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::clients::telegram; +use crate::config::UpdatesAdapter; +use crate::deps::Deps; + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct SetWebhookRequest {} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct SetWebhookResponse { + pub ok: bool, + pub url: String, +} + +pub fn register(iii: &Arc, deps: &Arc) { + super::register( + iii, + deps, + super::SET_WEBHOOK_ID, + "Register the Telegram webhook URL from configuration.", + |d, _req: SetWebhookRequest| async move { handle(&d).await }, + ); +} + +async fn handle(deps: &Deps) -> Result { + let cfg = deps.cfg().await; + let UpdatesAdapter::Webhook(webhook) = &cfg.updates else { + return Err(IIIError::Handler( + "set-webhook requires updates adapter name: webhook".into(), + )); + }; + let Some(url) = webhook.endpoint_url() else { + return Err(IIIError::Handler( + "updates.webhook.config.base_url is not set in telegram-bot configuration".into(), + )); + }; + telegram::set_webhook(deps, &url, webhook.secret.as_deref()).await?; + Ok(SetWebhookResponse { ok: true, url }) +} diff --git a/telegram-bot/src/functions/webhook.rs b/telegram-bot/src/functions/webhook.rs new file mode 100644 index 000000000..353ef982c --- /dev/null +++ b/telegram-bot/src/functions/webhook.rs @@ -0,0 +1,661 @@ +use std::sync::Arc; + +use iii_sdk::{IIIError, III}; +use schemars::JsonSchema; +use serde::Serialize; +use serde_json::Value; + +use crate::clients::{approval, harness, router, telegram}; +use crate::config::{ModelRef, SteeringMode, UpdatesAdapter, WorkerConfig}; +use crate::deps::Deps; +use crate::functions::bindings::message_added::BindingAck; +use crate::kv; +use crate::preferences::{self, parse_thinking_level, parse_verbosity, thinking_level_wire}; +use crate::telemetry; +use crate::types::{ + ChatFsm, HttpTriggerRequest, PendingApprovalRecord, ResolveDecision, TelegramUpdate, +}; + +#[derive(Debug, Serialize, JsonSchema)] +pub struct WebhookResponse { + pub ok: bool, +} + +pub fn register(iii: &Arc, deps: &Arc) { + super::register( + iii, + deps, + super::WEBHOOK_ID, + "Receive Telegram updates; route commands, messages, and callbacks.", + |d, req| async move { handle(&d, req).await }, + ); +} + +pub async fn handle(deps: &Deps, req: HttpTriggerRequest) -> Result { + let cfg = deps.cfg().await; + if let UpdatesAdapter::Webhook(webhook) = &cfg.updates { + // Validate the secret token only when one is configured. A secret-less + // webhook is accepted (the operator opted out of validation) so ingress + // works without it, but it is strongly recommended — without it anyone + // who learns the URL can inject forged updates. + match webhook.secret.as_deref().filter(|s| !s.is_empty()) { + Some(secret) => { + if !header_matches(&req.headers, secret) { + return Err(IIIError::Handler("invalid webhook secret".into())); + } + } + None => { + tracing::warn!( + "webhook update accepted without secret validation; set updates.webhook.config.secret" + ); + } + } + } else { + return Err(IIIError::Handler( + "webhook HTTP ingress is disabled; updates adapter is not webhook".into(), + )); + } + + let update: TelegramUpdate = serde_json::from_value(req.body) + .map_err(|e| IIIError::Handler(format!("invalid telegram update: {e}")))?; + + process_update_with_tracing(deps, update).await?; + Ok(WebhookResponse { ok: true }) +} + +/// Resolve harness session id for trace baggage from a Telegram update. +async fn baggage_session_for_update(deps: &Deps, update: &TelegramUpdate) -> String { + let chat_id = update.message.as_ref().map(|m| m.chat.id).or_else(|| { + update + .callback_query + .as_ref() + .and_then(|cb| cb.message.as_ref()) + .map(|m| m.chat.id) + }); + if let Some(chat_id) = chat_id { + if let Some(sid) = kv::chat_session(deps, chat_id).await { + return sid; + } + return telemetry::pending_session_id(chat_id); + } + "telegram-update".into() +} + +pub async fn process_update_with_tracing( + deps: &Deps, + update: TelegramUpdate, +) -> Result<(), IIIError> { + let session_id = baggage_session_for_update(deps, &update).await; + let message_id = telemetry::telegram_message_id(update.update_id); + telemetry::with_baggage(&session_id, &message_id, || async { + process_update(deps, update).await + }) + .await +} + +pub async fn process_update(deps: &Deps, update: TelegramUpdate) -> Result<(), IIIError> { + if let Some(cb) = update.callback_query { + return handle_callback(deps, cb).await; + } + + if let Some(message) = update.message { + let update_id = update.update_id; + return handle_message(deps, update_id, message).await; + } + + Ok(()) +} + +async fn handle_message( + deps: &Deps, + update_id: i64, + message: crate::types::TelegramMessage, +) -> Result<(), IIIError> { + let chat_id = message.chat.id; + let cfg = deps.cfg().await; + + if let Some(text) = message.text.as_deref() { + if text.starts_with('/') { + return handle_command(deps, chat_id, text, &cfg).await; + } + } + + let user_text = extract_user_content(&message); + if user_text.is_empty() { + return Ok(()); + } + + let session_id = kv::chat_session(deps, chat_id).await; + if let Some(ref sid) = session_id { + catch_up_approvals(deps, sid, &cfg).await; + } + + if kv::get_fsm(deps, chat_id).await == ChatFsm::AwaitingModel { + telegram::send_message( + deps, + chat_id, + "Pick a model with /start or /model first.", + None, + None, + ) + .await?; + return Ok(()); + } + + let Some(model) = kv::chat_model(deps, chat_id).await else { + telegram::send_message(deps, chat_id, "No model selected. Send /start.", None, None) + .await?; + return Ok(()); + }; + + if cfg.steering_mode == SteeringMode::Fifo { + if let Some(ref sid) = session_id { + match harness::status_active(&deps.iii, sid, cfg.timeout_ms).await { + Ok(true) => { + deps.runtime + .fifo_queues + .entry(chat_id) + .or_default() + .push(user_text); + return Ok(()); + } + Ok(false) => {} + Err(e) => { + tracing::warn!(error = %e, session_id = %sid, "harness::status failed; queueing message"); + deps.runtime + .fifo_queues + .entry(chat_id) + .or_default() + .push(user_text); + return Ok(()); + } + } + } + } + + send_user_message( + deps, + chat_id, + session_id.as_deref(), + &user_text, + Some(update_id), + &cfg, + &model, + ) + .await?; + Ok(()) +} + +fn extract_user_content(message: &crate::types::TelegramMessage) -> String { + if let Some(text) = &message.text { + if !text.is_empty() { + return text.clone(); + } + } + if let Some(caption) = &message.caption { + if !caption.is_empty() { + return caption.clone(); + } + } + if message.photo.is_some() { + return "[User sent a photo]".into(); + } + if message.document.is_some() { + return "[User sent a document]".into(); + } + if message.voice.is_some() { + return "[User sent a voice message]".into(); + } + String::new() +} + +async fn handle_command( + deps: &Deps, + chat_id: i64, + text: &str, + cfg: &WorkerConfig, +) -> Result<(), IIIError> { + let parts: Vec<&str> = text.split_whitespace().collect(); + let cmd = parts.first().copied().unwrap_or(text); + let arg = parts.get(1).copied(); + + match cmd { + "/start" => start_flow(deps, chat_id, arg, cfg).await, + "/stop" => { + if let Some(session_id) = kv::chat_session(deps, chat_id).await { + harness::stop(&deps.iii, &session_id, cfg.timeout_ms).await?; + } + telegram::send_message(deps, chat_id, "Stopped.", None, None).await?; + Ok(()) + } + "/model" => show_model_picker(deps, chat_id, cfg).await, + "/help" => help_message(deps, chat_id).await, + "/thinking" => thinking_command(deps, chat_id, arg, cfg).await, + "/verbosity" => verbosity_command(deps, chat_id, arg, cfg).await, + "/settings" => settings_message(deps, chat_id, cfg).await, + _ => { + telegram::send_message(deps, chat_id, "Unknown command. Try /help.", None, None) + .await?; + Ok(()) + } + } +} + +async fn help_message(deps: &Deps, chat_id: i64) -> Result<(), IIIError> { + let text = "/start — start a new session and pick a model\n\ +/stop — stop the current turn\n\ +/model — change model\n\ +/thinking — set reasoning depth (off|minimal|low|medium|high|xhigh)\n\ +/verbosity — set transcript detail (none|minimal|high|debug)\n\ +/settings — show current preferences\n\ +/help — this message"; + telegram::send_message(deps, chat_id, text, None, None).await?; + Ok(()) +} + +async fn settings_message(deps: &Deps, chat_id: i64, cfg: &WorkerConfig) -> Result<(), IIIError> { + let verbosity = preferences::effective_verbosity(deps, chat_id, cfg).await; + let thinking = preferences::effective_thinking_level(deps, chat_id, cfg) + .await + .map(|l| thinking_level_wire(l).to_string()) + .unwrap_or_else(|| "default".into()); + let text = format!( + "verbosity: {verbosity:?}\nthinking: {thinking}\nstreaming: {:?}", + cfg.streaming.transport + ); + telegram::send_message(deps, chat_id, &text, None, None).await?; + Ok(()) +} + +async fn thinking_command( + deps: &Deps, + chat_id: i64, + arg: Option<&str>, + cfg: &WorkerConfig, +) -> Result<(), IIIError> { + let Some(arg) = arg else { + let current = preferences::effective_thinking_level(deps, chat_id, cfg) + .await + .map(|l| thinking_level_wire(l).to_string()) + .unwrap_or_else(|| "default (off)".into()); + telegram::send_message( + deps, + chat_id, + &format!( + "Thinking level: {current}. Usage: /thinking off|minimal|low|medium|high|xhigh" + ), + None, + None, + ) + .await?; + return Ok(()); + }; + + let level = parse_thinking_level(arg); + if level.is_none() && arg.to_lowercase() != "off" && arg.to_lowercase() != "none" { + telegram::send_message( + deps, + chat_id, + "Invalid level. Use: off, minimal, low, medium, high, xhigh", + None, + None, + ) + .await?; + return Ok(()); + } + + kv::set_chat_thinking_level(deps, chat_id, level).await?; + let label = level.map(thinking_level_wire).unwrap_or("off"); + telegram::send_message( + deps, + chat_id, + &format!("Thinking level set to {label}."), + None, + None, + ) + .await?; + Ok(()) +} + +async fn verbosity_command( + deps: &Deps, + chat_id: i64, + arg: Option<&str>, + cfg: &WorkerConfig, +) -> Result<(), IIIError> { + let Some(arg) = arg else { + let current = preferences::effective_verbosity(deps, chat_id, cfg).await; + telegram::send_message( + deps, + chat_id, + &format!("Verbosity: {current:?}. Usage: /verbosity none|minimal|high|debug"), + None, + None, + ) + .await?; + return Ok(()); + }; + + let Some(v) = parse_verbosity(arg) else { + telegram::send_message( + deps, + chat_id, + "Invalid verbosity. Use: none, minimal, high, debug", + None, + None, + ) + .await?; + return Ok(()); + }; + + kv::set_chat_verbosity(deps, chat_id, v).await?; + telegram::send_message( + deps, + chat_id, + &format!("Verbosity set to {v:?}."), + None, + None, + ) + .await?; + Ok(()) +} + +async fn start_flow( + deps: &Deps, + chat_id: i64, + payload: Option<&str>, + cfg: &WorkerConfig, +) -> Result<(), IIIError> { + if let Some(p) = payload { + if !p.is_empty() { + tracing::info!(chat_id, payload = p, "deep link /start payload"); + } + } + + reset_chat_for_start(deps, chat_id, cfg).await?; + + if let Some(model) = cfg.default_model.clone() { + bind_model(deps, chat_id, &model).await?; + telegram::send_message( + deps, + chat_id, + &format!("Ready. Model: {} ({})", model.id, model.provider), + None, + None, + ) + .await?; + return Ok(()); + } + show_model_picker(deps, chat_id, cfg).await +} + +async fn reset_chat_for_start( + deps: &Deps, + chat_id: i64, + cfg: &WorkerConfig, +) -> Result<(), IIIError> { + let old_session = kv::chat_session(deps, chat_id).await; + if let Some(ref sid) = old_session { + let _ = harness::stop(&deps.iii, sid, cfg.timeout_ms).await; + } + deps.runtime.reset_for_chat(chat_id, old_session.as_deref()); + kv::clear_chat_session(deps, chat_id).await?; + kv::clear_chat_model(deps, chat_id).await?; + Ok(()) +} + +async fn show_model_picker(deps: &Deps, chat_id: i64, cfg: &WorkerConfig) -> Result<(), IIIError> { + if cfg.default_model.is_some() { + telegram::send_message( + deps, + chat_id, + "Model is fixed by configuration.", + None, + None, + ) + .await?; + return Ok(()); + } + + let models = router::list_models(&deps.iii, cfg.timeout_ms).await?; + if models.is_empty() { + telegram::send_message(deps, chat_id, "No models available.", None, None).await?; + return Ok(()); + } + + let mut rows = Vec::new(); + for model in models.iter().take(12) { + let mut label = model + .display_name + .clone() + .unwrap_or_else(|| model.id.clone()); + if model.supports_thinking == Some(true) { + label.push_str(" 🧠"); + } + rows.push(vec![( + format!("{label} ({})", model.provider), + format!("m:{}:{}", model.provider, model.id), + )]); + } + + kv::set_fsm(deps, chat_id, ChatFsm::AwaitingModel).await?; + telegram::send_message( + deps, + chat_id, + "Choose a model:", + Some(telegram::inline_keyboard(rows)), + None, + ) + .await?; + Ok(()) +} + +async fn handle_callback( + deps: &Deps, + cb: crate::types::TelegramCallbackQuery, +) -> Result<(), IIIError> { + let cfg = deps.cfg().await; + let data = cb.data.unwrap_or_default(); + let chat_id = cb + .message + .as_ref() + .map(|m| m.chat.id) + .or_else(|| cb.from.as_ref().map(|u| u.id)) + .unwrap_or(0); + + if data.starts_with("m:") { + let parts: Vec<&str> = data.splitn(3, ':').collect(); + if parts.len() == 3 { + let model = ModelRef { + provider: parts[1].into(), + id: parts[2].into(), + }; + bind_model(deps, chat_id, &model).await?; + telegram::answer_callback_query(deps, &cb.id, Some("Model selected")).await?; + telegram::send_message( + deps, + chat_id, + &format!("Model set to {} ({})", model.id, model.provider), + None, + None, + ) + .await?; + } + return Ok(()); + } + + if let Some(token) = data.strip_prefix("a:") { + resolve_callback(deps, token, ResolveDecision::Allow, false, &cfg).await?; + telegram::answer_callback_query(deps, &cb.id, Some("Approved")).await?; + return Ok(()); + } + if let Some(token) = data.strip_prefix("d:") { + resolve_callback(deps, token, ResolveDecision::Deny, false, &cfg).await?; + telegram::answer_callback_query(deps, &cb.id, Some("Rejected")).await?; + return Ok(()); + } + if let Some(token) = data.strip_prefix("w:") { + resolve_callback(deps, token, ResolveDecision::Allow, true, &cfg).await?; + telegram::answer_callback_query(deps, &cb.id, Some("Approved always")).await?; + return Ok(()); + } + + telegram::answer_callback_query(deps, &cb.id, None).await?; + Ok(()) +} + +async fn resolve_callback( + deps: &Deps, + token: &str, + decision: ResolveDecision, + approve_always: bool, + cfg: &WorkerConfig, +) -> Result<(), IIIError> { + let Some(data) = kv::load_approval_callback(deps, token).await else { + return Ok(()); + }; + if approve_always { + approval::approve_always( + &deps.iii, + &data.session_id, + &data.function_id, + cfg.timeout_ms, + ) + .await?; + } + let reason = if decision == ResolveDecision::Deny { + Some("rejected via telegram".into()) + } else { + None + }; + approval::resolve( + &deps.iii, + approval::ResolveRequest { + session_id: data.session_id, + function_call_id: data.function_call_id, + decision, + reason, + }, + cfg.timeout_ms, + ) + .await?; + kv::delete_approval_callback(deps, token).await; + Ok(()) +} + +pub async fn send_user_message( + deps: &Deps, + chat_id: i64, + session_id: Option<&str>, + text: &str, + idempotency_key: Option, + cfg: &WorkerConfig, + model: &ModelRef, +) -> Result<(), IIIError> { + let thinking_level = preferences::effective_thinking_level(deps, chat_id, cfg) + .await + .map(|l| thinking_level_wire(l).to_string()); + + let message_id = idempotency_key + .map(telemetry::telegram_message_id) + .unwrap_or_else(|| format!("tg-fifo-{chat_id}")); + + let session_id_for_trace = session_id + .map(String::from) + .unwrap_or_else(|| telemetry::pending_session_id(chat_id)); + + let metadata = telemetry::tracing_metadata(&session_id_for_trace, &message_id); + + let options = harness::SendOptions { + system_prompt: cfg.system_prompt.clone(), + mode: None, + thinking_level, + functions: if cfg.functions_allow.is_empty() { + None + } else { + Some(harness::FunctionsPolicy { + allow: cfg.functions_allow.clone(), + }) + }, + metadata: Some(metadata), + }; + + let resp = telemetry::with_baggage(&session_id_for_trace, &message_id, || async { + harness::send( + &deps.iii, + harness::HarnessSendRequest { + session_id: session_id.map(String::from), + message: text.to_string(), + model: model.id.clone(), + provider: model.provider.clone(), + idempotency_key: idempotency_key.map(|id| id.to_string()), + session: Some(harness::SessionSeed { + title: Some(format!("Telegram {chat_id}")), + metadata: Some(harness::metadata_for_chat(chat_id, model)), + }), + options: Some(options), + }, + cfg.timeout_ms, + ) + .await + }) + .await?; + + deps.runtime + .active_turns + .insert(resp.session_id.clone(), resp.turn_id.clone()); + + if session_id.is_none() { + kv::set_chat_session(deps, chat_id, &resp.session_id).await?; + } + + Ok(()) +} + +async fn bind_model(deps: &Deps, chat_id: i64, model: &ModelRef) -> Result<(), IIIError> { + kv::set_chat_model(deps, chat_id, model).await?; + kv::set_fsm(deps, chat_id, ChatFsm::Idle).await?; + Ok(()) +} + +async fn catch_up_approvals(deps: &Deps, session_id: &str, cfg: &WorkerConfig) { + let Ok(pending) = approval::list_pending(&deps.iii, session_id, cfg.timeout_ms).await else { + return; + }; + for record in pending { + if kv::approval_message_id(deps, &record.session_id, &record.function_call_id) + .await + .is_some() + { + continue; + } + let _ = dispatch_pending_created(deps, record).await; + } +} + +async fn dispatch_pending_created( + deps: &Deps, + record: PendingApprovalRecord, +) -> Result { + super::bindings::pending_created::handle_internal(deps, record).await +} + +fn header_matches(headers: &Option>, secret: &str) -> bool { + let Some(map) = headers else { + return false; + }; + const TARGET: &str = "x-telegram-bot-api-secret-token"; + map.iter() + .any(|(key, value)| key.eq_ignore_ascii_case(TARGET) && value.as_str() == Some(secret)) +} + +#[cfg(test)] +mod tests { + #[test] + fn parses_model_callback() { + let data = "m:anthropic:claude-sonnet-4"; + let parts: Vec<&str> = data.splitn(3, ':').collect(); + assert_eq!(parts.len(), 3); + assert_eq!(parts[1], "anthropic"); + } +} diff --git a/telegram-bot/src/ingress.rs b/telegram-bot/src/ingress.rs new file mode 100644 index 000000000..4f5bbf875 --- /dev/null +++ b/telegram-bot/src/ingress.rs @@ -0,0 +1,234 @@ +//! Telegram update ingress supervisor — polling loop or webhook registration. + +use std::sync::atomic::Ordering; +use std::sync::Arc; + +use iii_sdk::IIIError; +use tokio::time::{sleep, Duration}; +use tokio_util::sync::CancellationToken; + +use crate::clients::telegram; +use crate::config::{PollingConfig, UpdatesAdapter, WorkerConfig}; +use crate::deps::Deps; +use crate::functions::{self, webhook}; + +/// Start ingress for the initial config (called once at boot). +pub async fn start(deps: &Arc) { + let cfg = deps.cfg().await; + apply_updates_adapter(deps, None, &cfg).await; +} + +/// React to a config reload. `prev` is `None` on first boot (handled by [`start`]). +pub async fn apply_config_change(deps: &Arc, prev: &WorkerConfig, next: &WorkerConfig) { + if !ingress_changed(prev, next) { + return; + } + apply_updates_adapter(deps, Some(prev), next).await; +} + +fn ingress_changed(prev: &WorkerConfig, next: &WorkerConfig) -> bool { + match (&prev.updates, &next.updates) { + (UpdatesAdapter::Polling(_), UpdatesAdapter::Polling(_)) => false, + (UpdatesAdapter::Webhook(a), UpdatesAdapter::Webhook(b)) => a != b, + _ => true, + } +} + +async fn apply_updates_adapter(deps: &Arc, _prev: Option<&WorkerConfig>, cfg: &WorkerConfig) { + stop_poller(deps).await; + + match cfg.resolve_updates() { + UpdatesAdapter::Polling(polling) => { + // Stop Telegram POSTing *before* removing the engine route, so there + // is no window where the route is gone but updates still arrive. + if let Err(e) = telegram::delete_webhook(deps).await { + tracing::warn!(error = %e, "deleteWebhook failed (may already be unset)"); + } + unregister_webhook_trigger(deps).await; + start_poller(deps.clone(), polling).await; + tracing::info!("updates adapter: polling"); + } + UpdatesAdapter::Webhook(webhook) => { + let Some(url) = webhook.endpoint_url() else { + tracing::warn!("updates adapter: webhook (base_url not set; ingress inactive)"); + return; + }; + // The engine route must exist before Telegram is told to POST to it, + // otherwise early updates hit an unregistered path. If registration + // fails, leave the trigger unset so the next reload retries and skip + // setWebhook. + if let Err(e) = ensure_webhook_trigger(deps).await { + tracing::error!(error = %e, "failed to register webhook http trigger; skipping setWebhook"); + return; + } + match telegram::set_webhook(deps, &url, webhook.secret.as_deref()).await { + Ok(()) => tracing::info!(url = %url, "updates adapter: webhook registered"), + Err(e) => tracing::error!(error = %e, "setWebhook failed"), + } + } + } +} + +/// Register the webhook ingress HTTP route if it is not already registered, +/// retaining the [`iii_sdk::Trigger`] handle in [`Deps`]. Idempotent: a second +/// call while the route is held (e.g. a webhook→webhook URL change) is a no-op, +/// so the engine never accumulates duplicate UUID-keyed routes. +async fn ensure_webhook_trigger(deps: &Arc) -> Result<(), IIIError> { + let mut guard = deps.runtime.webhook_trigger.lock().await; + if guard.is_some() { + return Ok(()); + } + let trigger = functions::register_webhook_trigger(&deps.iii)?; + *guard = Some(trigger); + tracing::info!( + api_path = crate::config::WEBHOOK_API_PATH, + "webhook http trigger registered" + ); + Ok(()) +} + +/// Remove the webhook ingress HTTP route if one is registered. `take()` + +/// `unregister()` is mandatory — `Trigger` has no `Drop`, so dropping the handle +/// silently would leave the route live on the engine. +async fn unregister_webhook_trigger(deps: &Arc) { + let handle = deps.runtime.webhook_trigger.lock().await.take(); + if let Some(trigger) = handle { + trigger.unregister(); + tracing::info!("webhook http trigger unregistered"); + } +} + +async fn start_poller(deps: Arc, _polling: PollingConfig) { + let cancel = CancellationToken::new(); + let child_cancel = cancel.clone(); + let deps_for_task = deps.clone(); + let handle = tokio::spawn(async move { + run_poller(deps_for_task, child_cancel).await; + }); + + let mut guard = deps.runtime.poller_cancel.lock().await; + *guard = Some(cancel); + drop(guard); + + let mut handle_guard = deps.runtime.poller_handle.lock().await; + *handle_guard = Some(handle); +} + +/// Stop the background poller and remove the webhook route on shutdown, so a +/// graceful restart starts from a clean slate rather than relying on engine-side +/// cleanup of a disconnected worker's triggers. +pub async fn shutdown(deps: &Arc) { + stop_poller(deps).await; + unregister_webhook_trigger(deps).await; +} + +async fn stop_poller(deps: &Arc) { + let cancel = deps.runtime.poller_cancel.lock().await.take(); + if let Some(token) = cancel { + token.cancel(); + } + let handle = deps.runtime.poller_handle.lock().await.take(); + if let Some(h) = handle { + let _ = h.await; + } +} + +async fn run_poller(deps: Arc, cancel: CancellationToken) { + let mut backoff_secs = 1u64; + loop { + if cancel.is_cancelled() { + return; + } + + let cfg = deps.cfg().await; + let UpdatesAdapter::Polling(polling) = cfg.resolve_updates() else { + return; + }; + + let offset = deps.runtime.poll_offset.load(Ordering::Relaxed); + let timeout = polling.timeout_seconds.min(50); + + match telegram::get_updates_with_cancel(&deps, offset, timeout, Some(&cancel)).await { + Ok(updates) => { + if cancel.is_cancelled() { + return; + } + backoff_secs = 1; + if updates.is_empty() { + continue; + } + let mut last_id = offset; + for update in updates { + last_id = update.update_id; + if let Err(e) = webhook::process_update_with_tracing(&deps, update).await { + tracing::warn!(error = %e, "failed to process polled update"); + } + } + deps.runtime + .poll_offset + .store(last_id.saturating_add(1), Ordering::Relaxed); + } + Err(e) if e.to_string().contains("cancelled") => return, + Err(e) => { + tracing::warn!(error = %e, backoff_secs, "getUpdates failed"); + tokio::select! { + _ = cancel.cancelled() => return, + _ = sleep(Duration::from_secs(backoff_secs)) => {} + } + backoff_secs = (backoff_secs * 2).min(30); + } + } + } +} + +/// Advance offset after processing a batch (unit-tested). +pub fn advance_offset(current: i64, last_update_id: i64) -> i64 { + last_update_id.saturating_add(1).max(current) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{PollingConfig, WebhookConfig}; + + #[test] + fn advance_offset_moves_past_last_id() { + assert_eq!(advance_offset(0, 42), 43); + assert_eq!(advance_offset(50, 42), 50); + } + + #[test] + fn polling_timeout_change_does_not_restart_ingress() { + let prev = WorkerConfig { + bot_token: "t".into(), + updates: UpdatesAdapter::Polling(PollingConfig { + timeout_seconds: 50, + }), + ..WorkerConfig::default() + }; + let next = WorkerConfig { + updates: UpdatesAdapter::Polling(PollingConfig { + timeout_seconds: 30, + }), + ..prev.clone() + }; + assert!(!ingress_changed(&prev, &next)); + } + + #[test] + fn adapter_swap_restarts_ingress() { + let prev = WorkerConfig { + bot_token: "t".into(), + updates: UpdatesAdapter::Polling(PollingConfig::default()), + ..WorkerConfig::default() + }; + let next = WorkerConfig { + updates: UpdatesAdapter::Webhook(WebhookConfig { + base_url: "https://x".into(), + secret: None, + }), + ..prev.clone() + }; + assert!(ingress_changed(&prev, &next)); + } +} diff --git a/telegram-bot/src/kv.rs b/telegram-bot/src/kv.rs new file mode 100644 index 000000000..385d87998 --- /dev/null +++ b/telegram-bot/src/kv.rs @@ -0,0 +1,412 @@ +//! Chat ↔ session KV helpers. + +use iii_sdk::IIIError; +use serde_json::json; + +use crate::clients::state; +use crate::deps::{Deps, STATE_SCOPE}; +use crate::types::ChatFsm; + +pub async fn chat_session(deps: &Deps, chat_id: i64) -> Option { + let key = format!("chat:{chat_id}:session"); + state::get_string(&deps.iii, STATE_SCOPE, &key, deps.cfg().await.timeout_ms).await +} + +pub async fn set_chat_session(deps: &Deps, chat_id: i64, session_id: &str) -> Result<(), IIIError> { + let timeout = deps.cfg().await.timeout_ms; + let chat_key = format!("chat:{chat_id}:session"); + state::set( + &deps.iii, + STATE_SCOPE, + &chat_key, + json!(session_id), + Some(timeout), + ) + .await?; + if let Err(e) = state::set( + &deps.iii, + STATE_SCOPE, + &format!("session:{session_id}:chat"), + json!(chat_id), + Some(timeout), + ) + .await + { + let _ = state::delete(&deps.iii, STATE_SCOPE, &chat_key, Some(timeout)).await; + return Err(e); + } + Ok(()) +} + +pub async fn chat_id_for_session(deps: &Deps, session_id: &str) -> Option { + state::get_i64( + &deps.iii, + STATE_SCOPE, + &format!("session:{session_id}:chat"), + deps.cfg().await.timeout_ms, + ) + .await +} + +pub async fn entry_message_id(deps: &Deps, session_id: &str, entry_id: &str) -> Option { + state::get_i64( + &deps.iii, + STATE_SCOPE, + &format!("entry:{session_id}:{entry_id}:msg"), + deps.cfg().await.timeout_ms, + ) + .await +} + +pub async fn set_entry_message_id( + deps: &Deps, + session_id: &str, + entry_id: &str, + message_id: i64, +) -> Result<(), IIIError> { + state::set( + &deps.iii, + STATE_SCOPE, + &format!("entry:{session_id}:{entry_id}:msg"), + json!(message_id), + Some(deps.cfg().await.timeout_ms), + ) + .await?; + Ok(()) +} + +pub async fn entry_chunk_message_id( + deps: &Deps, + session_id: &str, + entry_id: &str, + chunk_idx: u32, +) -> Option { + state::get_i64( + &deps.iii, + STATE_SCOPE, + &format!("entry:{session_id}:{entry_id}:chunk:{chunk_idx}:msg"), + deps.cfg().await.timeout_ms, + ) + .await +} + +pub async fn set_entry_chunk_message_id( + deps: &Deps, + session_id: &str, + entry_id: &str, + chunk_idx: u32, + message_id: i64, +) -> Result<(), IIIError> { + state::set( + &deps.iii, + STATE_SCOPE, + &format!("entry:{session_id}:{entry_id}:chunk:{chunk_idx}:msg"), + json!(message_id), + Some(deps.cfg().await.timeout_ms), + ) + .await?; + Ok(()) +} + +/// Per-entry ordering key (append time in ms). Survives reconstruction so +/// finalize can post entries in append order even across a restart. +pub async fn entry_order(deps: &Deps, session_id: &str, entry_id: &str) -> Option { + state::get_i64( + &deps.iii, + STATE_SCOPE, + &format!("entry:{session_id}:{entry_id}:order"), + deps.cfg().await.timeout_ms, + ) + .await +} + +pub async fn set_entry_order( + deps: &Deps, + session_id: &str, + entry_id: &str, + order_key: i64, +) -> Result<(), IIIError> { + state::set( + &deps.iii, + STATE_SCOPE, + &format!("entry:{session_id}:{entry_id}:order"), + json!(order_key), + Some(deps.cfg().await.timeout_ms), + ) + .await?; + Ok(()) +} + +pub async fn is_entry_finalized(deps: &Deps, session_id: &str, entry_id: &str) -> bool { + state::get( + &deps.iii, + STATE_SCOPE, + &format!("entry:{session_id}:{entry_id}:finalized"), + Some(deps.cfg().await.timeout_ms), + ) + .await + .ok() + .and_then(|v| v.as_bool()) + .unwrap_or(false) +} + +pub async fn set_entry_finalized( + deps: &Deps, + session_id: &str, + entry_id: &str, +) -> Result<(), IIIError> { + state::set( + &deps.iii, + STATE_SCOPE, + &format!("entry:{session_id}:{entry_id}:finalized"), + json!(true), + Some(deps.cfg().await.timeout_ms), + ) + .await?; + Ok(()) +} + +pub async fn approval_message_id( + deps: &Deps, + session_id: &str, + function_call_id: &str, +) -> Option { + state::get_i64( + &deps.iii, + STATE_SCOPE, + &format!("approval:{session_id}:{function_call_id}:msg"), + deps.cfg().await.timeout_ms, + ) + .await +} + +pub async fn set_approval_message_id( + deps: &Deps, + session_id: &str, + function_call_id: &str, + message_id: i64, +) -> Result<(), IIIError> { + state::set( + &deps.iii, + STATE_SCOPE, + &format!("approval:{session_id}:{function_call_id}:msg"), + json!(message_id), + Some(deps.cfg().await.timeout_ms), + ) + .await?; + Ok(()) +} + +pub async fn get_fsm(deps: &Deps, chat_id: i64) -> ChatFsm { + let key = format!("chat:{chat_id}:fsm"); + match state::get_string(&deps.iii, STATE_SCOPE, &key, deps.cfg().await.timeout_ms).await { + Some(s) => ChatFsm::parse(&s), + None => ChatFsm::Idle, + } +} + +pub async fn set_fsm(deps: &Deps, chat_id: i64, fsm: ChatFsm) -> Result<(), IIIError> { + state::set( + &deps.iii, + STATE_SCOPE, + &format!("chat:{chat_id}:fsm"), + json!(fsm.as_str()), + Some(deps.cfg().await.timeout_ms), + ) + .await?; + Ok(()) +} + +pub async fn clear_chat_session(deps: &Deps, chat_id: i64) -> Result, IIIError> { + let timeout = deps.cfg().await.timeout_ms; + let old = chat_session(deps, chat_id).await; + if let Some(ref sid) = old { + state::delete( + &deps.iii, + STATE_SCOPE, + &format!("session:{sid}:chat"), + Some(timeout), + ) + .await?; + } + state::delete( + &deps.iii, + STATE_SCOPE, + &format!("chat:{chat_id}:session"), + Some(timeout), + ) + .await?; + Ok(old) +} + +pub async fn clear_chat_model(deps: &Deps, chat_id: i64) -> Result<(), IIIError> { + let timeout = deps.cfg().await.timeout_ms; + state::delete( + &deps.iii, + STATE_SCOPE, + &format!("chat:{chat_id}:model"), + Some(timeout), + ) + .await?; + Ok(()) +} + +pub async fn set_chat_model( + deps: &Deps, + chat_id: i64, + model: &crate::config::ModelRef, +) -> Result<(), IIIError> { + state::set( + &deps.iii, + STATE_SCOPE, + &format!("chat:{chat_id}:model"), + serde_json::to_value(model).unwrap_or(json!({})), + Some(deps.cfg().await.timeout_ms), + ) + .await?; + Ok(()) +} + +pub async fn chat_model(deps: &Deps, chat_id: i64) -> Option { + let v = state::get( + &deps.iii, + STATE_SCOPE, + &format!("chat:{chat_id}:model"), + Some(deps.cfg().await.timeout_ms), + ) + .await + .ok()?; + serde_json::from_value(v).ok() +} + +pub async fn store_approval_callback( + deps: &Deps, + token: &str, + data: &crate::types::ApprovalCallbackData, +) -> Result<(), IIIError> { + state::set( + &deps.iii, + STATE_SCOPE, + &format!("cb:{token}"), + serde_json::to_value(data).unwrap_or(json!({})), + Some(deps.cfg().await.timeout_ms), + ) + .await?; + Ok(()) +} + +pub async fn load_approval_callback( + deps: &Deps, + token: &str, +) -> Option { + let v = state::get( + &deps.iii, + STATE_SCOPE, + &format!("cb:{token}"), + Some(deps.cfg().await.timeout_ms), + ) + .await + .ok()?; + serde_json::from_value(v).ok() +} + +pub async fn delete_approval_callback(deps: &Deps, token: &str) { + let _ = state::delete( + &deps.iii, + STATE_SCOPE, + &format!("cb:{token}"), + Some(deps.cfg().await.timeout_ms), + ) + .await; +} + +pub async fn thinking_message_id(deps: &Deps, session_id: &str, entry_id: &str) -> Option { + state::get_i64( + &deps.iii, + STATE_SCOPE, + &format!("entry:{session_id}:{entry_id}:thinking_msg"), + deps.cfg().await.timeout_ms, + ) + .await +} + +pub async fn set_thinking_message_id( + deps: &Deps, + session_id: &str, + entry_id: &str, + message_id: i64, +) -> Result<(), IIIError> { + state::set( + &deps.iii, + STATE_SCOPE, + &format!("entry:{session_id}:{entry_id}:thinking_msg"), + json!(message_id), + Some(deps.cfg().await.timeout_ms), + ) + .await?; + Ok(()) +} + +pub async fn chat_verbosity_override( + deps: &Deps, + chat_id: i64, +) -> Option { + let v = state::get( + &deps.iii, + STATE_SCOPE, + &format!("chat:{chat_id}:verbosity"), + Some(deps.cfg().await.timeout_ms), + ) + .await + .ok()?; + serde_json::from_value(v).ok() +} + +pub async fn set_chat_verbosity( + deps: &Deps, + chat_id: i64, + verbosity: crate::config::Verbosity, +) -> Result<(), IIIError> { + state::set( + &deps.iii, + STATE_SCOPE, + &format!("chat:{chat_id}:verbosity"), + json!(verbosity), + Some(deps.cfg().await.timeout_ms), + ) + .await?; + Ok(()) +} + +pub async fn chat_thinking_level_override( + deps: &Deps, + chat_id: i64, +) -> Option { + let v = state::get( + &deps.iii, + STATE_SCOPE, + &format!("chat:{chat_id}:thinking_level"), + Some(deps.cfg().await.timeout_ms), + ) + .await + .ok()?; + serde_json::from_value(v).ok() +} + +pub async fn set_chat_thinking_level( + deps: &Deps, + chat_id: i64, + level: Option, +) -> Result<(), IIIError> { + let key = format!("chat:{chat_id}:thinking_level"); + let timeout = deps.cfg().await.timeout_ms; + match level { + Some(l) => { + state::set(&deps.iii, STATE_SCOPE, &key, json!(l), Some(timeout)).await?; + } + None => { + let _ = state::delete(&deps.iii, STATE_SCOPE, &key, Some(timeout)).await; + } + } + Ok(()) +} diff --git a/telegram-bot/src/lib.rs b/telegram-bot/src/lib.rs new file mode 100644 index 000000000..0ac14b6ff --- /dev/null +++ b/telegram-bot/src/lib.rs @@ -0,0 +1,16 @@ +pub mod clients; +pub mod config; +pub mod configuration; +pub mod deps; +pub mod functions; +pub mod ingress; +pub mod kv; +pub mod manifest; +pub mod preferences; +pub mod render; +pub mod surface; +pub mod telemetry; +pub mod text; +pub mod types; + +pub use config::WorkerConfig; diff --git a/telegram-bot/src/main.rs b/telegram-bot/src/main.rs new file mode 100644 index 000000000..83df6bbae --- /dev/null +++ b/telegram-bot/src/main.rs @@ -0,0 +1,110 @@ +use std::sync::Arc; + +use anyhow::{Context, Result}; +use clap::Parser; +use iii_sdk::{register_worker, InitOptions, WorkerMetadata}; +use tokio::sync::RwLock; + +use telegram_bot::clients::telegram; +use telegram_bot::configuration::{self, ConfigCell}; +use telegram_bot::deps::Deps; +use telegram_bot::functions; +use telegram_bot::ingress; +use telegram_bot::WorkerConfig; + +#[derive(Parser, Debug)] +#[command( + name = "telegram-bot", + about = "Telegram bridge to the harness stack (polling or webhook ingress)." +)] +struct Cli { + #[arg(long)] + config: Option, + + #[arg(long, default_value = "ws://127.0.0.1:49134")] + url: String, + + #[arg(long)] + manifest: bool, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let cli = Cli::parse(); + + if cli.manifest { + let m = telegram_bot::manifest::build_manifest(); + println!("{}", serde_json::to_string_pretty(&m).unwrap()); + return Ok(()); + } + + let seed = cli.config.as_deref().and_then(|path| { + match WorkerConfig::from_file(path) { + Ok(c) => Some(c), + Err(e) => { + tracing::warn!(error = %e, path, "failed to parse --config seed; continuing without seed"); + None + } + } + }); + + let iii = register_worker( + &cli.url, + InitOptions { + metadata: Some(WorkerMetadata { + runtime: "rust".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + name: "telegram-bot".to_string(), + os: std::env::consts::OS.to_string(), + pid: Some(std::process::id()), + telemetry: None, + ..WorkerMetadata::default() + }), + ..InitOptions::default() + }, + ); + let iii = Arc::new(iii); + + configuration::register_config(&iii, seed.as_ref()) + .await + .map_err(anyhow::Error::msg) + .context("registering telegram-bot configuration schema")?; + let cfg = configuration::fetch_config(&iii) + .await + .map_err(anyhow::Error::msg) + .context("loading telegram-bot configuration")?; + cfg.validate() + .map_err(anyhow::Error::msg) + .context("telegram-bot requires a non-empty bot_token in configuration")?; + + let cell: ConfigCell = Arc::new(RwLock::new(Arc::new(cfg))); + let deps = Arc::new(Deps::new(iii.clone(), cell.clone())); + + functions::register_all(&iii, &deps); + functions::bind_triggers(&iii); + functions::bind_http_triggers(&iii); + + configuration::register_config_trigger(&iii, cell, deps.clone()) + .map_err(anyhow::Error::msg) + .context("binding configuration trigger")?; + + ingress::start(&deps).await; + + if let Err(e) = telegram::set_my_commands(&deps).await { + tracing::warn!(error = %e, "failed to register bot commands with Telegram"); + } + + tracing::info!("telegram-bot ready, waiting for invocations"); + tokio::signal::ctrl_c().await?; + tracing::info!("telegram-bot shutting down"); + ingress::shutdown(&deps).await; + iii.shutdown_async().await; + Ok(()) +} diff --git a/telegram-bot/src/manifest.rs b/telegram-bot/src/manifest.rs new file mode 100644 index 000000000..8e15838f2 --- /dev/null +++ b/telegram-bot/src/manifest.rs @@ -0,0 +1,37 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub struct ModuleManifest { + pub name: String, + pub version: String, + pub description: String, + pub default_config: serde_json::Value, + pub supported_targets: Vec, +} + +pub fn build_manifest() -> ModuleManifest { + ModuleManifest { + name: env!("CARGO_PKG_NAME").to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + description: "Telegram bridge to the harness stack (polling or webhook ingress)." + .to_string(), + default_config: crate::config::WorkerConfig::default().to_json(), + supported_targets: vec![env!("TARGET").to_string()], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_roundtrip_has_required_fields() { + let m = build_manifest(); + let json = serde_json::to_string_pretty(&m).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["name"], env!("CARGO_PKG_NAME")); + assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION")); + assert!(parsed["default_config"].is_object()); + assert!(!parsed["supported_targets"].as_array().unwrap().is_empty()); + } +} diff --git a/telegram-bot/src/preferences.rs b/telegram-bot/src/preferences.rs new file mode 100644 index 000000000..b8662dd38 --- /dev/null +++ b/telegram-bot/src/preferences.rs @@ -0,0 +1,79 @@ +//! Per-chat preference resolution (KV overrides merged with worker config). + +use crate::config::{ThinkingLevel, Verbosity, WorkerConfig}; +use crate::deps::Deps; +use crate::kv; + +pub async fn effective_verbosity(deps: &Deps, chat_id: i64, cfg: &WorkerConfig) -> Verbosity { + if let Some(v) = kv::chat_verbosity_override(deps, chat_id).await { + return v; + } + cfg.verbosity +} + +pub async fn effective_thinking_level( + deps: &Deps, + chat_id: i64, + cfg: &WorkerConfig, +) -> Option { + if let Some(l) = kv::chat_thinking_level_override(deps, chat_id).await { + return Some(l); + } + cfg.default_thinking_level +} + +pub fn thinking_level_wire(level: ThinkingLevel) -> &'static str { + match level { + ThinkingLevel::Minimal => "minimal", + ThinkingLevel::Low => "low", + ThinkingLevel::Medium => "medium", + ThinkingLevel::High => "high", + ThinkingLevel::Xhigh => "xhigh", + } +} + +pub fn parse_thinking_level(s: &str) -> Option { + match s.to_lowercase().as_str() { + "off" | "none" => None, + "minimal" => Some(ThinkingLevel::Minimal), + "low" => Some(ThinkingLevel::Low), + "medium" => Some(ThinkingLevel::Medium), + "high" => Some(ThinkingLevel::High), + "xhigh" | "x-high" => Some(ThinkingLevel::Xhigh), + _ => None, + } +} + +pub fn parse_verbosity(s: &str) -> Option { + match s.to_lowercase().as_str() { + "none" => Some(Verbosity::None), + "minimal" => Some(Verbosity::Minimal), + "high" => Some(Verbosity::High), + "debug" => Some(Verbosity::Debug), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_thinking_levels() { + assert!(parse_thinking_level("off").is_none()); + assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium)); + assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::Xhigh)); + } + + #[test] + fn parse_verbosity_levels() { + assert_eq!(parse_verbosity("none"), Some(Verbosity::None)); + assert_eq!(parse_verbosity("debug"), Some(Verbosity::Debug)); + assert!(parse_verbosity("invalid").is_none()); + } + + #[test] + fn thinking_level_wire_values() { + assert_eq!(thinking_level_wire(ThinkingLevel::High), "high"); + } +} diff --git a/telegram-bot/src/render/chunk.rs b/telegram-bot/src/render/chunk.rs new file mode 100644 index 000000000..6e89266e3 --- /dev/null +++ b/telegram-bot/src/render/chunk.rs @@ -0,0 +1,65 @@ +//! Split long Telegram messages at the 4096-character limit. + +pub const TELEGRAM_MAX_MESSAGE_LEN: usize = 4096; + +/// Split `text` into chunks of at most `max_len` bytes (UTF-8 safe). +pub fn split_message(text: &str, max_len: usize) -> Vec { + if text.is_empty() { + return Vec::new(); + } + if text.len() <= max_len { + return vec![text.to_string()]; + } + + let mut chunks = Vec::new(); + let mut rest = text; + while !rest.is_empty() { + if rest.len() <= max_len { + chunks.push(rest.to_string()); + break; + } + let mut split_at = max_len; + while split_at > 0 && !rest.is_char_boundary(split_at) { + split_at -= 1; + } + if split_at == 0 { + split_at = rest + .char_indices() + .nth(1) + .map(|(i, _)| i) + .unwrap_or(rest.len()); + } + chunks.push(rest[..split_at].to_string()); + rest = &rest[split_at..]; + } + chunks +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn short_text_single_chunk() { + assert_eq!(split_message("hello", 4096), vec!["hello"]); + } + + #[test] + fn splits_long_text() { + let text = "a".repeat(5000); + let chunks = split_message(&text, 4096); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].len(), 4096); + assert_eq!(chunks[1].len(), 904); + } + + #[test] + fn utf8_safe_split() { + let text = "🙂".repeat(3000); + let chunks = split_message(&text, 4096); + for chunk in &chunks { + assert!(chunk.len() <= 4096); + assert!(std::str::from_utf8(chunk.as_bytes()).is_ok()); + } + } +} diff --git a/telegram-bot/src/render/format.rs b/telegram-bot/src/render/format.rs new file mode 100644 index 000000000..476919c69 --- /dev/null +++ b/telegram-bot/src/render/format.rs @@ -0,0 +1,258 @@ +//! Convert LLM markdown to Telegram-compatible HTML. + +use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, LinkType, Options, Parser, Tag, TagEnd}; + +const HTML_PARSE_MODE: &str = "HTML"; + +/// Format outgoing assistant text as Telegram HTML (markdown converted). +pub fn format_outgoing(text: &str) -> (String, Option<&'static str>) { + (markdown_to_html(text), Some(HTML_PARSE_MODE)) +} + +fn escape_html(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for ch in text.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + _ => out.push(ch), + } + } + out +} + +fn escape_attr(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for ch in text.chars() { + match ch { + '&' => out.push_str("&"), + '"' => out.push_str("""), + '<' => out.push_str("<"), + _ => out.push(ch), + } + } + out +} + +fn markdown_to_html(text: &str) -> String { + let mut options = Options::empty(); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_TABLES); + + let parser = Parser::new_ext(text, options); + let mut out = String::new(); + let mut list_stack: Vec = Vec::new(); + let mut table_row_first = true; + + for event in parser { + match event { + Event::Start(tag) => match tag { + Tag::Paragraph => {} + Tag::Heading { .. } => out.push_str(""), + Tag::BlockQuote(_) => out.push_str("
"), + Tag::CodeBlock(kind) => match kind { + CodeBlockKind::Fenced(info) => { + out.push_str("
');
+                    }
+                    CodeBlockKind::Indented => out.push_str("
"),
+                },
+                Tag::List(start) => {
+                    list_stack.push(ListState {
+                        ordered: start.is_some(),
+                        next: start.unwrap_or(1),
+                    });
+                }
+                Tag::Item => {
+                    if let Some(list) = list_stack.last_mut() {
+                        if list.ordered {
+                            out.push_str(&format!("{}. ", list.next));
+                            list.next += 1;
+                        } else {
+                            out.push_str("- ");
+                        }
+                    }
+                }
+                Tag::Emphasis => out.push_str(""),
+                Tag::Strong => out.push_str(""),
+                Tag::Strikethrough => out.push_str(""),
+                Tag::Link {
+                    link_type: LinkType::Email | LinkType::Autolink,
+                    dest_url,
+                    ..
+                }
+                | Tag::Link {
+                    link_type: LinkType::Inline,
+                    dest_url,
+                    ..
+                } => {
+                    out.push_str("");
+                }
+                Tag::Table(_) => {}
+                Tag::TableHead | Tag::TableRow => {
+                    table_row_first = true;
+                }
+                Tag::TableCell => {
+                    if !table_row_first {
+                        out.push_str(" | ");
+                    }
+                    table_row_first = false;
+                }
+                Tag::Image { dest_url, .. } => {
+                    out.push_str("");
+                }
+                _ => {}
+            },
+            Event::End(tag_end) => match tag_end {
+                TagEnd::Paragraph => out.push_str("\n\n"),
+                TagEnd::Heading(level) => {
+                    out.push_str("");
+                    if level != HeadingLevel::H6 {
+                        out.push('\n');
+                    }
+                    out.push('\n');
+                }
+                TagEnd::BlockQuote(_) => out.push_str("
\n"), + TagEnd::CodeBlock => out.push_str("\n\n"), + TagEnd::List(_) => { + list_stack.pop(); + out.push('\n'); + } + TagEnd::Item => out.push('\n'), + TagEnd::Emphasis => out.push_str(""), + TagEnd::Strong => out.push_str("
"), + TagEnd::Strikethrough => out.push_str(""), + TagEnd::Link => out.push_str(""), + TagEnd::Table => { + out.push('\n'); + } + TagEnd::TableHead | TagEnd::TableRow => out.push('\n'), + TagEnd::TableCell => {} + TagEnd::Image => out.push_str(""), + _ => {} + }, + Event::Text(text) => { + out.push_str(&escape_html(&text)); + } + Event::Code(text) => { + out.push_str(""); + out.push_str(&escape_html(&text)); + out.push_str(""); + } + Event::Html(html) => out.push_str(&escape_html(&html)), + Event::InlineHtml(html) => out.push_str(&escape_html(&html)), + Event::SoftBreak | Event::HardBreak => out.push('\n'), + Event::Rule => out.push_str("\n──────────\n\n"), + Event::TaskListMarker(checked) => { + if checked { + out.push_str("[x] "); + } else { + out.push_str("[ ] "); + } + } + Event::FootnoteReference(_) => {} + Event::InlineMath(_) | Event::DisplayMath(_) => {} + } + } + + trim_trailing_whitespace(&out) +} + +fn trim_trailing_whitespace(text: &str) -> String { + text.trim_end().to_string() +} + +#[derive(Debug, Clone, Copy)] +struct ListState { + ordered: bool, + next: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bold_to_html() { + let (text, mode) = format_outgoing("**What I CAN do:**"); + assert_eq!(text, "What I CAN do:"); + assert_eq!(mode, Some("HTML")); + } + + #[test] + fn markdown_bold_to_html() { + let (text, mode) = format_outgoing("**bold**"); + assert_eq!(text, "bold"); + assert_eq!(mode, Some("HTML")); + } + + #[test] + fn inline_code() { + assert_eq!( + markdown_to_html("Use `engine::functions::list` here."), + "Use engine::functions::list here." + ); + } + + #[test] + fn fenced_code_block() { + let md = "```python\nprint('hi')\n```"; + assert_eq!( + markdown_to_html(md), + "
print('hi')\n
" + ); + } + + #[test] + fn link() { + assert_eq!( + markdown_to_html("[docs](https://example.com)"), + "docs" + ); + } + + #[test] + fn escapes_special_chars() { + assert_eq!(markdown_to_html("2 < 3 & 4 > 1"), "2 < 3 & 4 > 1"); + } + + #[test] + fn heading_renders_bold() { + assert_eq!(markdown_to_html("# Title"), "Title"); + } + + #[test] + fn unordered_list() { + assert_eq!(markdown_to_html("- one\n- two"), "- one\n- two"); + } + + #[test] + fn ordered_list() { + assert_eq!( + markdown_to_html("1. first\n2. second"), + "1. first\n2. second" + ); + } + + #[test] + fn edit_throttle_uses_entry_revision_key() { + use crate::deps::RuntimeState; + use crate::render::throttle; + + let rt = RuntimeState::new(); + // Main channel consumes revision 5 for entry "e1". + assert!(throttle::should_edit(&rt, 1, 10, 5, "s1", "e1", 0)); + // A different entry id gets its own throttle slot. + assert!(throttle::should_edit(&rt, 1, 10, 5, "s1", "e2", 0)); + } +} diff --git a/telegram-bot/src/render/mod.rs b/telegram-bot/src/render/mod.rs new file mode 100644 index 000000000..710060082 --- /dev/null +++ b/telegram-bot/src/render/mod.rs @@ -0,0 +1,5 @@ +pub mod chunk; +pub mod format; +pub mod stream; +pub mod throttle; +pub mod verbosity; diff --git a/telegram-bot/src/render/stream.rs b/telegram-bot/src/render/stream.rs new file mode 100644 index 000000000..f3ed3d528 --- /dev/null +++ b/telegram-bot/src/render/stream.rs @@ -0,0 +1,1875 @@ +//! Assistant output streaming: draft transport, edit fallback, finalization. + +use std::future::Future; + +use iii_sdk::IIIError; + +use crate::clients::telegram; +use crate::config::{StreamTransport, WorkerConfig}; +use crate::deps::{Deps, RuntimeState, StreamSession}; +use crate::kv; +use crate::preferences; +use crate::render::chunk::{split_message, TELEGRAM_MAX_MESSAGE_LEN}; +use crate::render::format; +use crate::render::throttle; +use crate::render::verbosity::{ + self, message_phase, render_answer_text, render_thinking_text, MessagePhase, +}; +use crate::types::{AgentMessage, MessageUpdatedEvent}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EffectiveTransport { + Draft, + Edit, +} + +pub async fn on_message_added( + deps: &Deps, + session_id: &str, + entry_id: &str, + message: &AgentMessage, + timestamp: i64, +) -> Result, IIIError> { + let cfg = deps.cfg().await; + let Some(chat_id) = kv::chat_id_for_session(deps, session_id).await else { + return Ok(None); + }; + + if message.role == "function_result" { + let verbosity = preferences::effective_verbosity(deps, chat_id, &cfg).await; + let text = verbosity::render_function_result_message(message, verbosity); + let text = match text { + Some(t) if !t.is_empty() => t, + _ => return Ok(None), + }; + let order_key = record_order_key(deps, session_id, entry_id, timestamp).await; + let id = send_chat_message_in_order( + deps, + &cfg, + OrderedChatMessage { + chat_id, + order_key, + entry_id, + chunk_idx: 0, + text: &text, + reply_markup: None, + }, + ) + .await?; + return Ok(Some(id)); + } + + if message.role != "assistant" { + return Ok(None); + } + + let phase = message_phase(&message.content); + if phase == MessagePhase::Empty { + return with_entry_lock(deps, session_id, entry_id, || { + on_empty_assistant_added(deps, session_id, entry_id, timestamp, chat_id, &cfg) + }) + .await; + } + + with_entry_lock(deps, session_id, entry_id, || { + on_message_added_locked( + deps, session_id, entry_id, message, timestamp, chat_id, &cfg, + ) + }) + .await +} + +async fn on_message_added_locked( + deps: &Deps, + session_id: &str, + entry_id: &str, + message: &AgentMessage, + timestamp: i64, + chat_id: i64, + cfg: &WorkerConfig, +) -> Result, IIIError> { + if is_entry_finalized(deps, session_id, entry_id).await { + return Ok(kv::entry_message_id(deps, session_id, entry_id).await); + } + + let order_key = record_order_key(deps, session_id, entry_id, timestamp).await; + + if let Some(existing_id) = kv::entry_message_id(deps, session_id, entry_id).await { + let mut session = bootstrap_session(deps, cfg, session_id, entry_id, chat_id).await; + hydrate_message_ids(deps, session_id, entry_id, &mut session).await; + session.message_id = Some(existing_id); + session.phase = message_phase(&message.content); + session.order_key = order_key; + deps.runtime + .stream_sessions + .insert(entry_key(session_id, entry_id), session); + return Ok(Some(existing_id)); + } + + // Prefer an existing in-memory session: a `message-updated` may have raced + // ahead of this `message-added`, and bootstrapping fresh would reset + // `last_revision` to 0 and let the (stale, revision-0) added clobber it. + let key = entry_key(session_id, entry_id); + let mut session = deps + .runtime + .stream_sessions + .get(&key) + .map(|s| s.clone()) + .unwrap_or_else(|| bootstrap_session_sync(cfg, deps, session_id, entry_id, chat_id)); + hydrate_message_ids(deps, session_id, entry_id, &mut session).await; + session.order_key = order_key; + + // Only block siblings while this entry has yet to post its first bubble. An + // out-of-order update may already have materialized it (edit transport). + if session.message_id.is_none() { + register_pending_materialization(&deps.runtime, chat_id, order_key, entry_id); + } + + apply_render( + deps, + cfg, + session_id, + entry_id, + message, + 0, + order_key, + &mut session, + ) + .await?; + + deps.runtime.stream_sessions.insert(key, session.clone()); + + Ok(session.message_id) +} + +async fn on_empty_assistant_added( + deps: &Deps, + session_id: &str, + entry_id: &str, + timestamp: i64, + chat_id: i64, + cfg: &WorkerConfig, +) -> Result, IIIError> { + if is_entry_finalized(deps, session_id, entry_id).await { + return Ok(kv::entry_message_id(deps, session_id, entry_id).await); + } + + let order_key = record_order_key(deps, session_id, entry_id, timestamp).await; + let mut session = bootstrap_session(deps, cfg, session_id, entry_id, chat_id).await; + hydrate_message_ids(deps, session_id, entry_id, &mut session).await; + session.order_key = order_key; + + register_pending_materialization(&deps.runtime, chat_id, order_key, entry_id); + + if should_emit_native_thinking_placeholder(session.transport) { + send_native_thinking_placeholder(deps, &mut session, "").await?; + } + + deps.runtime + .stream_sessions + .insert(entry_key(session_id, entry_id), session); + + Ok(None) +} + +pub async fn on_message_updated(deps: &Deps, evt: MessageUpdatedEvent) -> Result<(), IIIError> { + let cfg = deps.cfg().await; + let Some(chat_id) = kv::chat_id_for_session(deps, &evt.session_id).await else { + return Ok(()); + }; + + let session_id = evt.session_id.clone(); + let entry_id = evt.entry_id.clone(); + let message = evt.message; + let revision = evt.revision; + let timestamp = evt.timestamp; + + with_entry_lock(deps, &session_id, &entry_id, || { + on_message_updated_locked( + deps, + &session_id, + &entry_id, + message, + revision, + timestamp, + chat_id, + &cfg, + ) + }) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn on_message_updated_locked( + deps: &Deps, + session_id: &str, + entry_id: &str, + message: AgentMessage, + revision: u64, + timestamp: i64, + chat_id: i64, + cfg: &WorkerConfig, +) -> Result<(), IIIError> { + if is_entry_finalized(deps, session_id, entry_id).await { + return reconcile_finalized_update( + deps, cfg, session_id, entry_id, &message, revision, timestamp, chat_id, + ) + .await; + } + + let order_key = record_order_key(deps, session_id, entry_id, timestamp).await; + + let key = entry_key(session_id, entry_id); + let mut session = deps + .runtime + .stream_sessions + .get(&key) + .map(|s| s.clone()) + .unwrap_or_else(|| bootstrap_session_sync(cfg, deps, session_id, entry_id, chat_id)); + + hydrate_message_ids(deps, session_id, entry_id, &mut session).await; + session.order_key = order_key; + + apply_render( + deps, + cfg, + session_id, + entry_id, + &message, + revision, + order_key, + &mut session, + ) + .await?; + + deps.runtime.stream_sessions.insert(key, session); + Ok(()) +} + +/// Handle a `message-updated` that arrives after the entry was finalized. +/// +/// Finalize can win the per-entry lock before the last `message-updated` task +/// runs (handlers are spawned per event and only loosely ordered), persisting +/// slightly stale text. Rather than drop the genuine final revision, edit the +/// already-posted message so it matches the authoritative text. Only strictly +/// higher revisions reconcile, so duplicate/stale late events are ignored. +#[allow(clippy::too_many_arguments)] +async fn reconcile_finalized_update( + deps: &Deps, + cfg: &WorkerConfig, + session_id: &str, + entry_id: &str, + message: &AgentMessage, + revision: u64, + timestamp: i64, + chat_id: i64, +) -> Result<(), IIIError> { + let key = entry_key(session_id, entry_id); + let finalized_revision = deps + .runtime + .finalized_entries + .get(&key) + .map(|r| *r.value()) + .unwrap_or(u64::MAX); + if !should_reconcile_finalized(revision, finalized_revision) { + return Ok(()); + } + + let verbosity = preferences::effective_verbosity(deps, chat_id, cfg).await; + let text = match render_answer_text(message, verbosity) { + Some(t) if !t.is_empty() => t, + _ => return Ok(()), + }; + + let mut message_id = kv::entry_message_id(deps, session_id, entry_id).await; + if message_id.is_none() { + return Ok(()); + } + + // Record the higher revision before editing so an even-later event still + // wins and stale ones remain dropped. + deps.runtime.finalized_entries.insert(key, revision); + + let order_key = record_order_key(deps, session_id, entry_id, timestamp).await; + deliver_text( + deps, + cfg, + session_id, + entry_id, + chat_id, + &mut message_id, + &text, + revision, + false, + order_key, + ) + .await +} + +pub async fn finalize_session(deps: &Deps, session_id: &str) -> Result<(), IIIError> { + let cfg = deps.cfg().await; + + let mut ordered_keys: Vec<(i64, (String, String))> = deps + .runtime + .stream_sessions + .iter() + .filter(|e| e.key().0 == session_id) + .map(|e| (e.value().order_key, e.key().clone())) + .collect(); + + for e in deps.runtime.pending_entries.iter() { + if e.key().0 != session_id || e.value().finalized { + continue; + } + if deps.runtime.finalized_entries.contains_key(e.key()) { + continue; + } + let key = e.key().clone(); + if !ordered_keys.iter().any(|(_, k)| k == &key) { + ordered_keys.push((e.value().order_key, key)); + } + } + + let keys = order_for_posting(ordered_keys); + + for key in keys { + if is_entry_finalized(deps, &key.0, &key.1).await { + deps.runtime.stream_sessions.remove(&key); + deps.runtime.pending_entries.remove(&key); + continue; + } + let sid = key.0.clone(); + let eid = key.1.clone(); + with_entry_lock(deps, &sid, &eid, || { + finalize_entry_under_lock(deps, &cfg, &sid, &eid) + }) + .await?; + } + + Ok(()) +} + +async fn finalize_entry_under_lock( + deps: &Deps, + cfg: &WorkerConfig, + session_id: &str, + entry_id: &str, +) -> Result<(), IIIError> { + if is_entry_finalized(deps, session_id, entry_id).await { + return Ok(()); + } + + let key = entry_key(session_id, entry_id); + let chat_id = deps + .runtime + .stream_sessions + .get(&key) + .map(|s| s.chat_id) + .or_else(|| deps.runtime.pending_entries.get(&key).map(|p| p.chat_id)); + + let Some(chat_id) = chat_id else { + return Ok(()); + }; + + let mut session = deps + .runtime + .stream_sessions + .remove(&key) + .map(|(_, s)| s) + .unwrap_or_else(|| bootstrap_session_sync(cfg, deps, session_id, entry_id, chat_id)); + + if let Some(pending) = deps.runtime.pending_entries.get(&key) { + merge_pending_into_session(&mut session, &pending); + } + + finalize_entry_locked(deps, cfg, session_id, entry_id, &mut session).await?; + deps.runtime.stream_sessions.remove(&key); + Ok(()) +} + +async fn finalize_entry_locked( + deps: &Deps, + cfg: &WorkerConfig, + session_id: &str, + entry_id: &str, + session: &mut StreamSession, +) -> Result<(), IIIError> { + if is_entry_finalized(deps, session_id, entry_id).await { + return Ok(()); + } + if session.finalized { + mark_entry_finalized( + deps, + &entry_key(session_id, entry_id), + session.chat_id, + session.last_revision, + ) + .await; + return Ok(()); + } + + hydrate_message_ids(deps, session_id, entry_id, session).await; + finalize_entry(deps, cfg, session_id, entry_id, session).await?; + mark_entry_finalized( + deps, + &entry_key(session_id, entry_id), + session.chat_id, + session.last_revision, + ) + .await; + Ok(()) +} + +async fn with_entry_lock( + deps: &Deps, + session_id: &str, + entry_id: &str, + f: F, +) -> Result +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + let lock = deps.runtime.entry_lock(&entry_key(session_id, entry_id)); + let _guard = lock.lock().await; + f().await +} + +fn entry_key(session_id: &str, entry_id: &str) -> (String, String) { + (session_id.to_string(), entry_id.to_string()) +} + +async fn bootstrap_session( + deps: &Deps, + cfg: &WorkerConfig, + session_id: &str, + entry_id: &str, + chat_id: i64, +) -> StreamSession { + bootstrap_session_sync(cfg, deps, session_id, entry_id, chat_id) +} + +fn bootstrap_session_sync( + cfg: &WorkerConfig, + deps: &Deps, + session_id: &str, + entry_id: &str, + chat_id: i64, +) -> StreamSession { + StreamSession { + draft_id: draft_id_for_entry(cfg, session_id, entry_id), + chat_id, + transport: resolve_transport(deps, chat_id, cfg), + message_id: None, + thinking_message_id: None, + last_revision: 0, + last_text: String::new(), + last_thinking_text: String::new(), + phase: MessagePhase::Empty, + finalized: false, + draft_started: false, + // Overwritten by the caller from the event's recorded order key. + order_key: 0, + } +} + +async fn hydrate_message_ids( + deps: &Deps, + session_id: &str, + entry_id: &str, + session: &mut StreamSession, +) { + if session.message_id.is_none() { + session.message_id = kv::entry_message_id(deps, session_id, entry_id).await; + } + if session.thinking_message_id.is_none() { + session.thinking_message_id = kv::thinking_message_id(deps, session_id, entry_id).await; + } +} + +#[allow(clippy::too_many_arguments)] +async fn apply_render( + deps: &Deps, + cfg: &WorkerConfig, + session_id: &str, + entry_id: &str, + message: &AgentMessage, + revision: u64, + order_key: i64, + session: &mut StreamSession, +) -> Result<(), IIIError> { + if is_entry_finalized(deps, session_id, entry_id).await { + return Ok(()); + } + + // The per-entry lock serializes handlers but does not order them: an older + // revision can win the lock after a newer one already applied. Re-applying + // it would regress the live draft and the `last_text`/pending snapshot used + // at finalize. `message-added` carries revision 0, which is only fresh for a + // brand-new entry (whose `last_revision` is also 0). + if !revision_is_fresh(revision, session.last_revision) { + return Ok(()); + } + + let phase = message_phase(&message.content); + session.phase = phase; + session.order_key = order_key; + + let verbosity = preferences::effective_verbosity(deps, session.chat_id, cfg).await; + + let answer_text = render_answer_text(message, verbosity); + let thinking_text = render_thinking_text(message); + + let text = match answer_text { + Some(t) if !t.is_empty() => t, + _ if phase == MessagePhase::ThinkingOnly => String::new(), + _ => return Ok(()), + }; + + session.last_revision = revision; + session.last_text = text.clone(); + session.last_thinking_text = thinking_text.clone().unwrap_or_default(); + + hydrate_message_ids(deps, session_id, entry_id, session).await; + + match session.transport { + EffectiveTransport::Draft => { + apply_draft_update(deps, cfg, session_id, entry_id, session, &text, phase).await?; + } + EffectiveTransport::Edit => { + apply_edit_update(deps, cfg, session_id, entry_id, session, &text).await?; + } + } + + // Snapshot after the transport step so the pending entry reflects the most + // recent `draft_started`/`message_id` (e.g. after an edit-fallback switch). + deps.runtime.pending_entries.insert( + entry_key(session_id, entry_id), + crate::deps::PendingEntryState { + chat_id: session.chat_id, + revision, + text, + thinking_text: session.last_thinking_text.clone(), + phase, + message_id: session.message_id, + thinking_message_id: session.thinking_message_id, + finalized: false, + draft_started: session.draft_started, + order_key, + }, + ); + + Ok(()) +} + +/// Whether `incoming` is at least as new as the last applied revision. Stale +/// (out-of-order) revisions are dropped so they cannot regress rendered text. +fn revision_is_fresh(incoming: u64, last_applied: u64) -> bool { + incoming >= last_applied +} + +/// Whether a post-finalize `message-updated` should edit the already-posted +/// message. Only strictly higher revisions reconcile, so duplicate/stale late +/// events — and restart-learned finalizes recorded as `u64::MAX` — are ignored. +fn should_reconcile_finalized(incoming: u64, finalized: u64) -> bool { + incoming > finalized +} + +async fn apply_draft_update( + deps: &Deps, + cfg: &WorkerConfig, + session_id: &str, + entry_id: &str, + session: &mut StreamSession, + text: &str, + phase: MessagePhase, +) -> Result<(), IIIError> { + if !should_draft(deps, session, cfg.streaming.draft_throttle_ms) { + return Ok(()); + } + + if uses_rich_thinking_draft(phase) { + let thinking = session.last_thinking_text.clone(); + send_native_thinking_placeholder(deps, session, &thinking).await?; + return Ok(()); + } + + let draft_text = if phase == MessagePhase::ThinkingOnly && text.is_empty() { + "" + } else { + text + }; + + match telegram::send_message_draft(deps, session.chat_id, session.draft_id, draft_text, None) + .await + { + Ok(()) => { + session.draft_started = true; + } + Err(e) if is_draft_unsupported(&e) => { + pin_edit_fallback(deps, session.chat_id); + session.transport = EffectiveTransport::Edit; + apply_edit_update(deps, cfg, session_id, entry_id, session, text).await?; + } + Err(e) => { + tracing::warn!(error = %e, "sendMessageDraft failed"); + } + } + Ok(()) +} + +async fn apply_edit_update( + deps: &Deps, + cfg: &WorkerConfig, + session_id: &str, + entry_id: &str, + session: &mut StreamSession, + text: &str, +) -> Result<(), IIIError> { + let chat_id = session.chat_id; + let revision = session.last_revision; + let order_key = session.order_key; + deliver_text( + deps, + cfg, + session_id, + entry_id, + chat_id, + &mut session.message_id, + text, + revision, + true, + order_key, + ) + .await?; + Ok(()) +} + +/// Send or edit a continuation chunk (index >= 1), persisting message IDs in KV. +#[allow(clippy::too_many_arguments)] +async fn deliver_continuation_chunk( + deps: &Deps, + session_id: &str, + entry_id: &str, + chat_id: i64, + order_key: i64, + chunk_idx: u32, + chunk: &str, + settle_ms: u64, +) -> Result<(), IIIError> { + if let Some(id) = kv::entry_chunk_message_id(deps, session_id, entry_id, chunk_idx).await { + if let Err(e) = edit_message_text_formatted(deps, chat_id, id, chunk).await { + tracing::warn!(error = %e, message_id = id, chunk_idx, "editMessageText failed"); + } + return Ok(()); + } + let id = send_in_order( + deps, + chat_id, + order_key, + entry_id, + chunk_idx, + settle_ms, + || send_message_formatted(deps, chat_id, chunk, None), + ) + .await?; + kv::set_entry_chunk_message_id(deps, session_id, entry_id, chunk_idx, id).await?; + Ok(()) +} + +/// Send a new Telegram message or edit an existing one, with idempotency guards. +/// +/// Every new `sendMessage` (first chunk and continuations) goes through the +/// per-chat ordering gate so bubbles land in append order relative to other +/// entries in the same turn. +#[allow(clippy::too_many_arguments)] +async fn deliver_text( + deps: &Deps, + cfg: &WorkerConfig, + session_id: &str, + entry_id: &str, + chat_id: i64, + message_id: &mut Option, + text: &str, + revision: u64, + throttle_edits: bool, + order_key: i64, +) -> Result<(), IIIError> { + let chunks = split_message(text, TELEGRAM_MAX_MESSAGE_LEN); + if chunks.is_empty() || chunks.iter().all(|c| c.is_empty()) { + return Ok(()); + } + + // The finalize path (`throttle_edits == false`) skips the create-slot settle + // delay so the persistent bubble appears immediately; live streaming keeps + // the configured settle so near-simultaneous sibling entries still order. + let settle_ms = if throttle_edits { + cfg.streaming.create_settle_ms + } else { + 0 + }; + + if is_entry_finalized(deps, session_id, entry_id).await { + if let Some(id) = *message_id { + if let Some(first) = chunks.first() { + let _ = edit_message_text_formatted(deps, chat_id, id, first).await; + } + for (i, chunk) in chunks.iter().skip(1).enumerate() { + let chunk_idx = i as u32 + 1; + let _ = deliver_continuation_chunk( + deps, session_id, entry_id, chat_id, order_key, chunk_idx, chunk, settle_ms, + ) + .await; + } + } + return Ok(()); + } + + if message_id.is_none() { + *message_id = kv::entry_message_id(deps, session_id, entry_id).await; + } + + if let Some(id) = *message_id { + if throttle_edits + && !throttle::should_edit( + &deps.runtime, + chat_id, + id, + revision, + session_id, + entry_id, + cfg.streaming.draft_throttle_ms, + ) + { + return Ok(()); + } + if let Some(first) = chunks.first() { + if let Err(e) = edit_message_text_formatted(deps, chat_id, id, first).await { + tracing::warn!(error = %e, message_id = id, "editMessageText failed"); + } + } + for (i, chunk) in chunks.iter().skip(1).enumerate() { + let chunk_idx = i as u32 + 1; + let _ = deliver_continuation_chunk( + deps, session_id, entry_id, chat_id, order_key, chunk_idx, chunk, settle_ms, + ) + .await; + } + return Ok(()); + } + + if is_entry_finalized(deps, session_id, entry_id).await { + return Ok(()); + } + + let mut chunk_index = 0u32; + for chunk in chunks.iter() { + if chunk.is_empty() { + continue; + } + if is_entry_finalized(deps, session_id, entry_id).await { + return Ok(()); + } + let id = send_in_order( + deps, + chat_id, + order_key, + entry_id, + chunk_index, + settle_ms, + || send_message_formatted(deps, chat_id, chunk, None), + ) + .await?; + if chunk_index == 0 { + *message_id = Some(id); + kv::set_entry_message_id(deps, session_id, entry_id, id).await?; + } else { + kv::set_entry_chunk_message_id(deps, session_id, entry_id, chunk_index, id).await?; + } + chunk_index += 1; + } + Ok(()) +} + +async fn finalize_entry( + deps: &Deps, + cfg: &WorkerConfig, + session_id: &str, + entry_id: &str, + session: &mut StreamSession, +) -> Result<(), IIIError> { + if session.finalized { + return Ok(()); + } + + let text = session.last_text.clone(); + + if text.is_empty() && session.phase == MessagePhase::Empty { + clear_pending_materialization(&deps.runtime, session.chat_id, entry_id); + session.finalized = true; + return Ok(()); + } + + if is_entry_finalized(deps, session_id, entry_id).await { + session.finalized = true; + return Ok(()); + } + + hydrate_message_ids(deps, session_id, entry_id, session).await; + + match session.transport { + EffectiveTransport::Draft if session.draft_started => { + // Issue 3: post the persistent bubble first, then remove the + // ephemeral preview. Clearing first animated the streamed text away + // and left a visible gap before the real message arrived ("retype"). + finalize_draft_messages(deps, session_id, entry_id, session, &text).await?; + clear_draft(deps, session).await; + } + EffectiveTransport::Edit => { + flush_edit_final(deps, cfg, session_id, entry_id, session, &text).await?; + } + EffectiveTransport::Draft => { + // Draft transport without a confirmed draft: still flush the text and + // clear any draft that may be lingering so the chat doesn't keep + // showing the "typing" preview after the bubble posts (issue 2). + flush_edit_final(deps, cfg, session_id, entry_id, session, &text).await?; + clear_draft(deps, session).await; + } + } + + session.finalized = true; + deps.runtime + .pending_entries + .remove(&entry_key(session_id, entry_id)); + Ok(()) +} + +async fn finalize_draft_messages( + deps: &Deps, + session_id: &str, + entry_id: &str, + session: &mut StreamSession, + text: &str, +) -> Result<(), IIIError> { + let order_key = session.order_key; + let chunks = split_message(text, TELEGRAM_MAX_MESSAGE_LEN); + + if let Some(message_id) = session.message_id { + if let Some(first) = chunks.first() { + if let Err(e) = + edit_message_text_formatted(deps, session.chat_id, message_id, first).await + { + tracing::warn!(error = %e, message_id, "finalize draft editMessageText failed"); + } + } + for (i, chunk) in chunks.iter().skip(1).enumerate() { + let chunk_idx = i as u32 + 1; + let _ = deliver_continuation_chunk( + deps, + session_id, + entry_id, + session.chat_id, + order_key, + chunk_idx, + chunk, + 0, + ) + .await; + } + return Ok(()); + } + + if is_entry_finalized(deps, session_id, entry_id).await { + return Ok(()); + } + + let mut chunk_index = 0u32; + for chunk in chunks.iter() { + if chunk.is_empty() { + continue; + } + if is_entry_finalized(deps, session_id, entry_id).await { + return Ok(()); + } + match send_in_order( + deps, + session.chat_id, + order_key, + entry_id, + chunk_index, + 0, + || send_message_formatted(deps, session.chat_id, chunk, None), + ) + .await + { + Ok(id) => { + if chunk_index == 0 { + session.message_id = Some(id); + kv::set_entry_message_id(deps, session_id, entry_id, id).await?; + } else { + kv::set_entry_chunk_message_id(deps, session_id, entry_id, chunk_index, id) + .await?; + } + } + Err(e) => tracing::warn!(error = %e, "finalize sendMessage failed"), + } + chunk_index += 1; + } + Ok(()) +} + +async fn flush_edit_final( + deps: &Deps, + cfg: &WorkerConfig, + session_id: &str, + entry_id: &str, + session: &mut StreamSession, + text: &str, +) -> Result<(), IIIError> { + let chat_id = session.chat_id; + let revision = session.last_revision; + let order_key = session.order_key; + deliver_text( + deps, + cfg, + session_id, + entry_id, + chat_id, + &mut session.message_id, + text, + revision, + false, + order_key, + ) + .await +} + +async fn send_message_formatted( + deps: &Deps, + chat_id: i64, + text: &str, + reply_markup: Option, +) -> Result { + let (formatted, parse_mode) = format::format_outgoing(text); + match telegram::send_message(deps, chat_id, &formatted, reply_markup.clone(), parse_mode).await + { + Ok(id) => Ok(id), + Err(e) if parse_mode.is_some() => { + tracing::warn!(error = %e, "formatted sendMessage failed, retrying plain"); + telegram::send_message(deps, chat_id, text, reply_markup, None).await + } + Err(e) => Err(e), + } +} + +async fn edit_message_text_formatted( + deps: &Deps, + chat_id: i64, + message_id: i64, + text: &str, +) -> Result<(), IIIError> { + let (formatted, parse_mode) = format::format_outgoing(text); + match telegram::edit_message_text(deps, chat_id, message_id, &formatted, parse_mode).await { + Ok(()) => Ok(()), + Err(e) if parse_mode.is_some() => { + tracing::warn!(error = %e, message_id, "formatted editMessageText failed, retrying plain"); + telegram::edit_message_text(deps, chat_id, message_id, text, None).await + } + Err(e) => Err(e), + } +} + +async fn is_entry_finalized(deps: &Deps, session_id: &str, entry_id: &str) -> bool { + let key = entry_key(session_id, entry_id); + if deps.runtime.finalized_entries.contains_key(&key) { + return true; + } + if kv::is_entry_finalized(deps, session_id, entry_id).await { + // Revision is not durably recorded, so treat a restart-learned finalize + // as the highest possible: the turn is over and the persisted message is + // authoritative, so no late event should reconcile it. + deps.runtime.finalized_entries.insert(key, u64::MAX); + return true; + } + false +} + +async fn mark_entry_finalized(deps: &Deps, key: &(String, String), chat_id: i64, revision: u64) { + deps.runtime.finalized_entries.insert(key.clone(), revision); + deps.runtime.pending_entries.remove(key); + clear_pending_materialization(&deps.runtime, chat_id, &key.1); + let _ = kv::set_entry_finalized(deps, &key.0, &key.1).await; +} + +fn merge_pending_into_session( + session: &mut StreamSession, + pending: &crate::deps::PendingEntryState, +) { + if pending.revision >= session.last_revision { + session.last_revision = pending.revision; + session.last_text = pending.text.clone(); + session.last_thinking_text = pending.thinking_text.clone(); + session.phase = pending.phase; + session.message_id = session.message_id.or(pending.message_id); + session.thinking_message_id = session.thinking_message_id.or(pending.thinking_message_id); + } + // A draft may have been pushed regardless of which snapshot has the newer + // revision, so finalize must clear it if either side saw one start. + session.draft_started = session.draft_started || pending.draft_started; + session.order_key = merge_order_key(session.order_key, pending.order_key); +} + +/// Order entry keys for posting: lowest order key first, ties broken by entry +/// id. DashMap iteration order is arbitrary, so this is what keeps multiple +/// entries finalized in one turn from landing out of order in the chat. +fn order_for_posting(mut pairs: Vec<(i64, (String, String))>) -> Vec<(String, String)> { + pairs.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1 .1.cmp(&b.1 .1))); + pairs.into_iter().map(|(_, key)| key).collect() +} + +/// Combine two ordering keys for the same entry, preferring the earlier +/// (smaller) non-zero value. `0` means "unset" (append time not yet observed). +fn merge_order_key(a: i64, b: i64) -> i64 { + match (a, b) { + (0, b) => b, + (a, 0) => a, + (a, b) => a.min(b), + } +} + +/// Resolve the per-entry ordering key as the earliest append time observed +/// across the entry's events (`min` wins), persisting it so finalize and +/// reconstruction agree even when events arrive out of order or after a +/// restart. +async fn record_order_key(deps: &Deps, session_id: &str, entry_id: &str, timestamp: i64) -> i64 { + let existing = kv::entry_order(deps, session_id, entry_id).await; + let key = match existing { + Some(prev) => merge_order_key(prev, timestamp), + None => timestamp, + }; + if existing != Some(key) { + let _ = kv::set_entry_order(deps, session_id, entry_id, key).await; + } + key +} + +/// Parameters for posting a new Telegram bubble in per-chat append order. +pub struct OrderedChatMessage<'a> { + pub chat_id: i64, + pub order_key: i64, + pub entry_id: &'a str, + pub chunk_idx: u32, + pub text: &'a str, + pub reply_markup: Option, +} + +/// Post a new Telegram bubble in per-chat append order (for bindings outside +/// the streaming path, e.g. function_result entries and turn status toasts). +pub async fn send_chat_message_in_order( + deps: &Deps, + cfg: &WorkerConfig, + msg: OrderedChatMessage<'_>, +) -> Result { + send_in_order( + deps, + msg.chat_id, + msg.order_key, + msg.entry_id, + msg.chunk_idx, + cfg.streaming.create_settle_ms, + || send_message_formatted(deps, msg.chat_id, msg.text, msg.reply_markup.clone()), + ) + .await +} + +/// Create a new Telegram message in per-chat append order. +/// +/// Concurrently-spawned handlers register an `(order_key, entry_id, chunk)` +/// slot; only the earliest pending slot is admitted at a time. Later entries +/// also wait until earlier entries have materialized (posted a first bubble +/// or been finalized empty). +async fn send_in_order( + deps: &Deps, + chat_id: i64, + order_key: i64, + entry_id: &str, + chunk_idx: u32, + settle_ms: u64, + make: F, +) -> Result +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + let slot = (order_key, entry_id.to_string(), chunk_idx); + await_create_slot(&deps.runtime, chat_id, &slot, settle_ms).await; + + let lock = deps.runtime.chat_create_lock(chat_id); + let _guard = lock.lock().await; + let result = make().await; + let ok = result.is_ok(); + clear_pending_materialization(&deps.runtime, chat_id, entry_id); + complete_create_slot(&deps.runtime, chat_id, &slot, order_key, ok); + result +} + +fn register_pending_materialization( + runtime: &RuntimeState, + chat_id: i64, + order_key: i64, + entry_id: &str, +) { + runtime + .chat_pending_materialization + .entry(chat_id) + .or_default() + .insert((order_key, entry_id.to_string())); + runtime.chat_create_notify(chat_id).notify_waiters(); +} + +fn clear_pending_materialization(runtime: &RuntimeState, chat_id: i64, entry_id: &str) { + if let Some(mut set) = runtime.chat_pending_materialization.get_mut(&chat_id) { + set.retain(|(_, e)| e != entry_id); + } + runtime.chat_create_notify(chat_id).notify_waiters(); +} + +fn has_prior_unmaterialized( + runtime: &RuntimeState, + chat_id: i64, + order_key: i64, + entry_id: &str, +) -> bool { + runtime + .chat_pending_materialization + .get(&chat_id) + .is_some_and(|set| { + set.iter() + .any(|(k, e)| (*k, e.as_str()) < (order_key, entry_id)) + }) +} + +/// Register a creation slot and wait until it is the earliest pending slot for +/// the chat and all prior entries have materialized. Holds no creation lock +/// while waiting, so it cannot deadlock against per-entry locks. +async fn await_create_slot( + runtime: &RuntimeState, + chat_id: i64, + slot: &(i64, String, u32), + settle_ms: u64, +) { + runtime + .chat_create_order + .entry(chat_id) + .or_default() + .insert(slot.clone()); + let notify = runtime.chat_create_notify(chat_id); + + if settle_ms > 0 { + tokio::time::sleep(std::time::Duration::from_millis(settle_ms)).await; + } + + loop { + let notified = notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + + let order_admitted = match runtime.chat_create_order.get(&chat_id) { + Some(set) => !set.contains(slot) || set.iter().next() == Some(slot), + None => true, + }; + let materialized_ok = !has_prior_unmaterialized(runtime, chat_id, slot.0, &slot.1); + if order_admitted && materialized_ok { + break; + } + + notified.await; + } +} + +/// Mark a creation slot done: record the high-water order key, drop the slot, +/// and wake the next waiter. +fn complete_create_slot( + runtime: &RuntimeState, + chat_id: i64, + slot: &(i64, String, u32), + order_key: i64, + ok: bool, +) { + if ok { + let mut highest = runtime + .last_created_order + .entry(chat_id) + .or_insert(order_key); + if order_key > *highest { + *highest = order_key; + } + } + if let Some(mut set) = runtime.chat_create_order.get_mut(&chat_id) { + set.remove(slot); + } + runtime.chat_create_notify(chat_id).notify_waiters(); +} + +fn uses_rich_thinking_draft(phase: MessagePhase) -> bool { + phase == MessagePhase::ThinkingOnly +} + +fn should_emit_native_thinking_placeholder(transport: EffectiveTransport) -> bool { + transport == EffectiveTransport::Draft +} + +async fn clear_draft(deps: &Deps, session: &StreamSession) { + if let Err(e) = + telegram::send_message_draft(deps, session.chat_id, session.draft_id, "", None).await + { + tracing::warn!(error = %e, draft_id = session.draft_id, "clear draft failed"); + } +} + +fn resolve_transport(deps: &Deps, chat_id: i64, cfg: &WorkerConfig) -> EffectiveTransport { + if deps.runtime.draft_disabled_chats.contains_key(&chat_id) { + return EffectiveTransport::Edit; + } + match cfg.streaming.transport { + StreamTransport::Edit => EffectiveTransport::Edit, + StreamTransport::Draft => EffectiveTransport::Draft, + StreamTransport::Auto => EffectiveTransport::Draft, + } +} + +fn draft_id_for_entry(cfg: &WorkerConfig, session_id: &str, entry_id: &str) -> i32 { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + session_id.hash(&mut hasher); + entry_id.hash(&mut hasher); + let hash = hasher.finish(); + cfg.streaming.draft_id_seed + (hash as i32).unsigned_abs() as i32 % 100_000 +} + +fn should_draft(deps: &Deps, session: &StreamSession, throttle_ms: u64) -> bool { + let key = (session.chat_id, session.draft_id); + let now = std::time::Instant::now(); + if let Some(last) = deps.runtime.draft_times.get(&key) { + if now.duration_since(*last) < std::time::Duration::from_millis(throttle_ms) { + return false; + } + } + deps.runtime.draft_times.insert(key, now); + true +} + +fn pin_edit_fallback(deps: &Deps, chat_id: i64) { + deps.runtime.draft_disabled_chats.insert(chat_id, ()); +} + +fn is_draft_unsupported(err: &IIIError) -> bool { + let msg = format!("{err}"); + msg.contains("TEXTDRAFT_PEER_INVALID") + || msg.contains("method not found") + || msg.contains("Not Found") +} + +async fn send_native_thinking_placeholder( + deps: &Deps, + session: &mut StreamSession, + thinking_text: &str, +) -> Result<(), IIIError> { + let rich = telegram::rich_thinking_draft(thinking_text); + match telegram::send_rich_message_draft(deps, session.chat_id, session.draft_id, &rich, None) + .await + { + Ok(()) => { + session.draft_started = true; + } + Err(e) if is_draft_unsupported(&e) => { + pin_edit_fallback(deps, session.chat_id); + session.transport = EffectiveTransport::Edit; + } + Err(e) => { + tracing::warn!(error = %e, "sendRichMessageDraft failed"); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::deps::{PendingEntryState, RuntimeState}; + + use crate::config::Verbosity; + use crate::render::verbosity::{render_answer_text, render_thinking_text}; + use crate::types::{AgentMessage, ContentBlock}; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum DraftFinalizeStep { + Post, + ClearDraftAfter, + } + + const DRAFT_FINALIZE_STEPS: [DraftFinalizeStep; 2] = + [DraftFinalizeStep::Post, DraftFinalizeStep::ClearDraftAfter]; + + #[test] + fn draft_id_stable() { + let cfg = WorkerConfig::default(); + let a = draft_id_for_entry(&cfg, "s1", "e1"); + let b = draft_id_for_entry(&cfg, "s1", "e1"); + assert_eq!(a, b); + assert_ne!(a, 0); + } + + #[test] + fn merge_pending_under_lock_uses_latest_revision() { + let mut session = StreamSession { + draft_id: 1, + chat_id: 42, + transport: EffectiveTransport::Draft, + message_id: None, + thinking_message_id: None, + last_revision: 3, + last_text: "stale snapshot".into(), + last_thinking_text: String::new(), + phase: MessagePhase::Answering, + finalized: false, + draft_started: true, + order_key: 7, + }; + let pending = PendingEntryState { + chat_id: 42, + revision: 5, + text: "final answer".into(), + thinking_text: "thought".into(), + phase: MessagePhase::Answering, + message_id: None, + thinking_message_id: None, + finalized: false, + draft_started: false, + order_key: 5, + }; + merge_pending_into_session(&mut session, &pending); + assert_eq!(session.last_text, "final answer"); + assert_eq!(session.last_revision, 5); + } + + #[test] + fn split_message_produces_multiple_chunks_for_long_text() { + let text = "x".repeat(5000); + let chunks = split_message(&text, TELEGRAM_MAX_MESSAGE_LEN); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].len(), TELEGRAM_MAX_MESSAGE_LEN); + } + + #[test] + fn rich_thinking_draft_enabled_for_thinking_only() { + assert!(uses_rich_thinking_draft(MessagePhase::ThinkingOnly)); + assert!(!uses_rich_thinking_draft(MessagePhase::Answering)); + } + + #[test] + fn thinking_placeholder_eligible_on_draft_transport() { + assert!(should_emit_native_thinking_placeholder( + EffectiveTransport::Draft + )); + assert!(!should_emit_native_thinking_placeholder( + EffectiveTransport::Edit + )); + } + + #[test] + fn thinking_text_extracted_at_none_verbosity() { + let msg = AgentMessage { + role: "assistant".into(), + content: vec![ContentBlock::Thinking { + text: "plan".into(), + }], + }; + assert_eq!(render_thinking_text(&msg).unwrap(), "plan"); + assert!(render_answer_text(&msg, Verbosity::None).is_none()); + } + + #[test] + fn merge_pending_prefers_higher_revision() { + let mut session = StreamSession { + draft_id: 1, + chat_id: 42, + transport: EffectiveTransport::Draft, + message_id: Some(100), + thinking_message_id: None, + last_revision: 3, + last_text: "old".into(), + last_thinking_text: String::new(), + phase: MessagePhase::Answering, + finalized: false, + draft_started: true, + order_key: 7, + }; + let pending = PendingEntryState { + chat_id: 42, + revision: 5, + text: "final answer".into(), + thinking_text: "thought".into(), + phase: MessagePhase::Answering, + message_id: Some(100), + thinking_message_id: Some(200), + finalized: false, + draft_started: true, + order_key: 5, + }; + merge_pending_into_session(&mut session, &pending); + assert_eq!(session.last_revision, 5); + assert_eq!(session.last_text, "final answer"); + assert_eq!(session.last_thinking_text, "thought"); + assert_eq!(session.thinking_message_id, Some(200)); + // Order key tracks the earliest observed append time. + assert_eq!(session.order_key, 5); + } + + #[test] + fn merge_pending_ignores_stale_snapshot() { + let mut session = StreamSession { + draft_id: 1, + chat_id: 42, + transport: EffectiveTransport::Draft, + message_id: None, + thinking_message_id: None, + last_revision: 10, + last_text: "newer".into(), + last_thinking_text: String::new(), + phase: MessagePhase::Answering, + finalized: false, + draft_started: true, + order_key: 5, + }; + let pending = PendingEntryState { + chat_id: 42, + revision: 8, + text: "stale".into(), + thinking_text: String::new(), + phase: MessagePhase::Answering, + message_id: None, + thinking_message_id: None, + finalized: false, + draft_started: false, + order_key: 9, + }; + merge_pending_into_session(&mut session, &pending); + assert_eq!(session.last_revision, 10); + assert_eq!(session.last_text, "newer"); + // Stale revision is ignored, but the earliest order key still wins. + assert_eq!(session.order_key, 5); + } + + #[test] + fn revision_guard_rejects_stale_and_accepts_fresh() { + // Equal or newer revisions apply; older ones are dropped so an + // out-of-order handler cannot regress the rendered text. + assert!(revision_is_fresh(5, 5)); + assert!(revision_is_fresh(6, 5)); + assert!(!revision_is_fresh(4, 5)); + // `message-added` carries revision 0: fresh for a brand-new entry... + assert!(revision_is_fresh(0, 0)); + // ...but stale once an update already advanced the revision. + assert!(!revision_is_fresh(0, 3)); + } + + #[test] + fn reconcile_only_for_strictly_higher_revision() { + // Finalize may race ahead of the last token; a higher revision then + // reconciles, but duplicates/stale events do not. + assert!(should_reconcile_finalized(6, 5)); + assert!(!should_reconcile_finalized(5, 5)); + assert!(!should_reconcile_finalized(4, 5)); + // A finalize learned from durable state (revision unknown == u64::MAX) + // is treated as authoritative and never reconciled. + assert!(!should_reconcile_finalized(7, u64::MAX)); + assert!(!should_reconcile_finalized(u64::MAX, u64::MAX)); + } + + #[test] + fn merge_pending_preserves_draft_started() { + // Even when pending is the stale (lower-revision) side, a started draft + // must still be cleared at finalize. + let mut session = StreamSession { + draft_id: 1, + chat_id: 42, + transport: EffectiveTransport::Draft, + message_id: None, + thinking_message_id: None, + last_revision: 10, + last_text: "newer".into(), + last_thinking_text: String::new(), + phase: MessagePhase::Answering, + finalized: false, + draft_started: true, + order_key: 5, + }; + let pending = PendingEntryState { + chat_id: 42, + revision: 8, + text: "stale".into(), + thinking_text: String::new(), + phase: MessagePhase::Answering, + message_id: None, + thinking_message_id: None, + finalized: false, + draft_started: false, + order_key: 9, + }; + merge_pending_into_session(&mut session, &pending); + assert!(session.draft_started); + + // A rebuilt session (draft_started=false) adopts it from pending, so the + // lingering "typing" preview still gets cleared after the bubble posts. + let mut rebuilt = StreamSession { + draft_id: 1, + chat_id: 42, + transport: EffectiveTransport::Draft, + message_id: None, + thinking_message_id: None, + last_revision: 0, + last_text: String::new(), + last_thinking_text: String::new(), + phase: MessagePhase::Empty, + finalized: false, + draft_started: false, + order_key: 0, + }; + let pending_with_draft = PendingEntryState { + chat_id: 42, + revision: 3, + text: "answer".into(), + thinking_text: String::new(), + phase: MessagePhase::Answering, + message_id: Some(50), + thinking_message_id: None, + finalized: false, + draft_started: true, + order_key: 1, + }; + merge_pending_into_session(&mut rebuilt, &pending_with_draft); + assert!(rebuilt.draft_started); + } + + #[test] + fn finalized_entries_block_in_memory() { + let runtime = RuntimeState::new(); + let key = ("s1".into(), "e1".into()); + assert!(!runtime.finalized_entries.contains_key(&key)); + runtime.finalized_entries.insert(key.clone(), 0); + assert!(runtime.finalized_entries.contains_key(&key)); + } + + #[test] + fn mark_finalized_removes_pending() { + let runtime = RuntimeState::new(); + let key = ("s1".into(), "e1".into()); + runtime.pending_entries.insert( + key.clone(), + PendingEntryState { + chat_id: 42, + revision: 1, + text: "turn 1".into(), + thinking_text: String::new(), + phase: MessagePhase::Answering, + message_id: None, + thinking_message_id: None, + finalized: false, + draft_started: false, + order_key: 0, + }, + ); + runtime.finalized_entries.insert(key.clone(), 0); + runtime.pending_entries.remove(&key); + assert!(!runtime.pending_entries.contains_key(&key)); + } + + #[test] + fn hydrate_applies_kv_ids_to_session() { + let mut session = StreamSession { + draft_id: 1, + chat_id: 42, + transport: EffectiveTransport::Edit, + message_id: None, + thinking_message_id: None, + last_revision: 0, + last_text: String::new(), + last_thinking_text: String::new(), + phase: MessagePhase::Empty, + finalized: false, + draft_started: false, + order_key: 0, + }; + session.message_id = Some(999); + session.thinking_message_id = Some(888); + assert_eq!(session.message_id, Some(999)); + assert_eq!(session.thinking_message_id, Some(888)); + } + + #[test] + fn pending_keys_for_turn2_exclude_finalized() { + let runtime = RuntimeState::new(); + let turn1 = ("session".into(), "entry-turn1".into()); + let turn2 = ("session".into(), "entry-turn2".into()); + runtime.pending_entries.insert( + turn1.clone(), + PendingEntryState { + chat_id: 42, + revision: 1, + text: "turn 1 answer".into(), + thinking_text: String::new(), + phase: MessagePhase::Answering, + message_id: None, + thinking_message_id: None, + finalized: false, + draft_started: false, + order_key: 0, + }, + ); + runtime.pending_entries.insert( + turn2.clone(), + PendingEntryState { + chat_id: 42, + revision: 1, + text: "turn 2 answer".into(), + thinking_text: String::new(), + phase: MessagePhase::Answering, + message_id: None, + thinking_message_id: None, + finalized: false, + draft_started: false, + order_key: 0, + }, + ); + runtime.finalized_entries.insert(turn1.clone(), 0); + + let pending_keys: Vec<(String, String)> = runtime + .pending_entries + .iter() + .filter(|e| e.key().0 == "session" && !e.value().finalized) + .filter(|e| !runtime.finalized_entries.contains_key(e.key())) + .map(|e| e.key().clone()) + .collect(); + + assert_eq!(pending_keys.len(), 1); + assert_eq!(pending_keys[0], turn2); + } + + #[tokio::test] + async fn entry_lock_blocks_until_released() { + use std::sync::Arc; + + let runtime = Arc::new(RuntimeState::new()); + let key = ("s1".into(), "e1".into()); + let lock = runtime.entry_lock(&key); + let guard = lock.lock().await; + + let lock2 = runtime.entry_lock(&key); + let acquired = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let flag = acquired.clone(); + let handle = tokio::spawn(async move { + let _g = lock2.lock().await; + flag.store(true, std::sync::atomic::Ordering::SeqCst); + }); + + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + assert!(!acquired.load(std::sync::atomic::Ordering::SeqCst)); + + drop(guard); + handle.await.unwrap(); + assert!(acquired.load(std::sync::atomic::Ordering::SeqCst)); + } + + #[test] + fn entry_lock_serializes_concurrent_access() { + use std::sync::Arc; + + let runtime = Arc::new(RuntimeState::new()); + let key = ("s1".into(), "e1".into()); + let lock1 = runtime.entry_lock(&key); + let lock2 = runtime.entry_lock(&key); + assert!(Arc::ptr_eq(&lock1, &lock2)); + } + + #[test] + fn formatted_fallback_uses_plain_text_on_html_failure() { + let md = "**What I CAN do:**\n\n1. First item."; + let (formatted, mode) = format::format_outgoing(md); + assert_eq!(mode, Some("HTML")); + assert!(formatted.contains("")); + assert_ne!(formatted, md); + assert!(md.contains("**")); + } + + #[test] + fn draft_finalize_posts_before_clearing_draft() { + assert_eq!(DRAFT_FINALIZE_STEPS[0], DraftFinalizeStep::Post); + assert_eq!(DRAFT_FINALIZE_STEPS[1], DraftFinalizeStep::ClearDraftAfter); + } + + #[test] + fn order_for_posting_sorts_by_key_then_entry_id() { + let pairs = vec![ + (20, ("s".to_string(), "c".to_string())), + (10, ("s".to_string(), "b".to_string())), + (10, ("s".to_string(), "a".to_string())), + (5, ("s".to_string(), "z".to_string())), + ]; + let ordered = order_for_posting(pairs); + let ids: Vec<&str> = ordered.iter().map(|(_, e)| e.as_str()).collect(); + // Lowest key first; equal keys fall back to entry-id order. + assert_eq!(ids, vec!["z", "a", "b", "c"]); + } + + #[test] + fn merge_order_key_prefers_earliest_nonzero() { + assert_eq!(merge_order_key(0, 100), 100); + assert_eq!(merge_order_key(100, 0), 100); + // A message-updated (later) seen before its message-added (append + // time) still resolves to the earlier append time. + assert_eq!(merge_order_key(200, 100), 100); + assert_eq!(merge_order_key(100, 200), 100); + assert_eq!(merge_order_key(0, 0), 0); + } + + #[test] + fn complete_create_slot_keeps_highest_order() { + let runtime = RuntimeState::new(); + complete_create_slot(&runtime, 1, &(5, "e5".into(), 0), 5, true); + // A later, lower-keyed creation must not regress the high-water mark. + complete_create_slot(&runtime, 1, &(3, "e3".into(), 0), 3, true); + assert_eq!(*runtime.last_created_order.get(&1).unwrap(), 5); + } + + #[tokio::test] + async fn await_create_slot_admits_sole_creator_without_deadlock() { + let runtime = RuntimeState::new(); + // No earlier sibling ever registers: the sole creator must proceed. + await_create_slot(&runtime, 9, &(100, "z".into(), 0), 0).await; + complete_create_slot(&runtime, 9, &(100, "z".into(), 0), 100, true); + assert_eq!(*runtime.last_created_order.get(&9).unwrap(), 100); + assert!(runtime + .chat_create_order + .get(&9) + .map(|s| s.is_empty()) + .unwrap_or(true)); + } + + #[tokio::test] + async fn await_create_slot_admits_earliest_first() { + use std::sync::{Arc, Mutex}; + + let runtime = Arc::new(RuntimeState::new()); + let chat = 7; + let order: Arc>> = Arc::new(Mutex::new(Vec::new())); + + // Both register within the settle window, so the lower order key wins + // regardless of which task is scheduled first. + let rt_b = runtime.clone(); + let order_b = order.clone(); + let b = tokio::spawn(async move { + await_create_slot(&rt_b, chat, &(2, "b".into(), 0), 50).await; + order_b.lock().unwrap().push("b"); + complete_create_slot(&rt_b, chat, &(2, "b".into(), 0), 2, true); + }); + + let rt_a = runtime.clone(); + let order_a = order.clone(); + let a = tokio::spawn(async move { + await_create_slot(&rt_a, chat, &(1, "a".into(), 0), 50).await; + order_a.lock().unwrap().push("a"); + complete_create_slot(&rt_a, chat, &(1, "a".into(), 0), 1, true); + }); + + a.await.unwrap(); + b.await.unwrap(); + + assert_eq!(*order.lock().unwrap(), vec!["a", "b"]); + assert_eq!(*runtime.last_created_order.get(&chat).unwrap(), 2); + } + + #[tokio::test] + async fn await_create_slot_waits_for_prior_unmaterialized_entry() { + use std::sync::Arc; + + let runtime = Arc::new(RuntimeState::new()); + let chat = 3; + register_pending_materialization(&runtime, chat, 10, "entry_a"); + + let slot_b = (20, "entry_b".to_string(), 0u32); + runtime + .chat_create_order + .entry(chat) + .or_default() + .insert(slot_b.clone()); + + let admitted = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let flag = admitted.clone(); + let rt = runtime.clone(); + let handle = tokio::spawn(async move { + await_create_slot(&rt, chat, &slot_b, 0).await; + flag.store(true, std::sync::atomic::Ordering::SeqCst); + }); + + tokio::time::sleep(std::time::Duration::from_millis(30)).await; + assert!(!admitted.load(std::sync::atomic::Ordering::SeqCst)); + + clear_pending_materialization(&runtime, chat, "entry_a"); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + handle.await.unwrap(); + assert!(admitted.load(std::sync::atomic::Ordering::SeqCst)); + } + + #[tokio::test] + async fn continuation_chunk_slot_orders_after_first_chunk() { + use std::sync::{Arc, Mutex}; + + let runtime = Arc::new(RuntimeState::new()); + let chat = 5; + let order: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let slot0 = (10, "e".to_string(), 0u32); + let slot1 = (10, "e".to_string(), 1u32); + runtime + .chat_create_order + .entry(chat) + .or_default() + .insert(slot0.clone()); + runtime + .chat_create_order + .get_mut(&chat) + .unwrap() + .insert(slot1.clone()); + + let rt0 = runtime.clone(); + let o0 = order.clone(); + let h0 = tokio::spawn(async move { + await_create_slot(&rt0, chat, &slot0, 0).await; + o0.lock().unwrap().push(0); + complete_create_slot(&rt0, chat, &slot0, 10, true); + }); + + let rt1 = runtime.clone(); + let o1 = order.clone(); + let h1 = tokio::spawn(async move { + await_create_slot(&rt1, chat, &slot1, 0).await; + o1.lock().unwrap().push(1); + complete_create_slot(&rt1, chat, &slot1, 10, true); + }); + + h0.await.unwrap(); + h1.await.unwrap(); + assert_eq!(*order.lock().unwrap(), vec![0, 1]); + } + + #[test] + fn has_prior_unmaterialized_detects_earlier_entry() { + let runtime = RuntimeState::new(); + register_pending_materialization(&runtime, 1, 10, "a"); + assert!(has_prior_unmaterialized(&runtime, 1, 20, "b")); + assert!(!has_prior_unmaterialized(&runtime, 1, 10, "a")); + assert!(!has_prior_unmaterialized(&runtime, 1, 5, "z")); + } +} diff --git a/telegram-bot/src/render/throttle.rs b/telegram-bot/src/render/throttle.rs new file mode 100644 index 000000000..3d6f275d2 --- /dev/null +++ b/telegram-bot/src/render/throttle.rs @@ -0,0 +1,63 @@ +use std::time::{Duration, Instant}; + +use crate::deps::RuntimeState; + +pub fn should_edit( + runtime: &RuntimeState, + chat_id: i64, + message_id: i64, + revision: u64, + session_id: &str, + entry_id: &str, + throttle_ms: u64, +) -> bool { + let rev_key = (session_id.to_string(), entry_id.to_string()); + if let Some(prev) = runtime.revisions.get(&rev_key) { + if revision <= *prev { + return false; + } + } + + let edit_key = (chat_id, message_id); + let now = Instant::now(); + if let Some(last) = runtime.edit_times.get(&edit_key) { + if now.duration_since(*last) < Duration::from_millis(throttle_ms) { + return false; + } + } + + runtime.revisions.insert(rev_key, revision); + runtime.edit_times.insert(edit_key, now); + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::deps::RuntimeState; + + #[test] + fn revision_monotonicity() { + let rt = RuntimeState::new(); + assert!(should_edit(&rt, 1, 10, 1, "s1", "e1", 0)); + assert!(!should_edit(&rt, 1, 10, 1, "s1", "e1", 0)); + assert!(should_edit(&rt, 1, 10, 2, "s1", "e1", 0)); + } + + #[test] + fn throttle_blocks_rapid_edits() { + let rt = RuntimeState::new(); + assert!(should_edit(&rt, 1, 10, 1, "s1", "e1", 60_000)); + assert!(!should_edit(&rt, 1, 10, 2, "s1", "e1", 60_000)); + } + + #[test] + fn throttle_does_not_advance_revision_when_time_blocked() { + let rt = RuntimeState::new(); + assert!(should_edit(&rt, 1, 10, 1, "s1", "e1", 60_000)); + assert!(!should_edit(&rt, 1, 10, 2, "s1", "e1", 60_000)); + // Revision 2 was throttled, not consumed — a later attempt with no throttle succeeds. + assert!(should_edit(&rt, 1, 10, 2, "s1", "e1", 0)); + assert!(!should_edit(&rt, 1, 10, 2, "s1", "e1", 0)); + } +} diff --git a/telegram-bot/src/render/verbosity.rs b/telegram-bot/src/render/verbosity.rs new file mode 100644 index 000000000..af6be4101 --- /dev/null +++ b/telegram-bot/src/render/verbosity.rs @@ -0,0 +1,239 @@ +use crate::config::Verbosity; +use crate::types::{AgentMessage, ContentBlock}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MessagePhase { + Empty, + ThinkingOnly, + Answering, +} + +pub fn message_phase(content: &[ContentBlock]) -> MessagePhase { + let has_text = content + .iter() + .any(|b| matches!(b, ContentBlock::Text { text } if !text.is_empty())); + let has_thinking = content + .iter() + .any(|b| matches!(b, ContentBlock::Thinking { text } if !text.is_empty())); + match (has_text, has_thinking) { + (false, true) => MessagePhase::ThinkingOnly, + (true, _) => MessagePhase::Answering, + _ => MessagePhase::Empty, + } +} + +impl PartialOrd for Verbosity { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Verbosity { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + let rank = |v: Verbosity| match v { + Verbosity::None => 0, + Verbosity::Minimal => 1, + Verbosity::High => 2, + Verbosity::Debug => 3, + }; + rank(*self).cmp(&rank(*other)) + } +} + +pub fn render_assistant_message(message: &AgentMessage, verbosity: Verbosity) -> Option { + if message.role != "assistant" { + return None; + } + let parts = render_content_blocks(&message.content, verbosity, false); + if parts.is_empty() { + return None; + } + Some(parts.join("\n\n")) +} + +pub fn render_answer_text(message: &AgentMessage, verbosity: Verbosity) -> Option { + if message.role != "assistant" { + return None; + } + let parts = render_content_blocks(&message.content, verbosity, false); + if parts.is_empty() { + return None; + } + Some(parts.join("\n\n")) +} + +/// Raw thinking block text for RichBlockThinking drafts (not gated by verbosity). +pub fn render_thinking_text(message: &AgentMessage) -> Option { + if message.role != "assistant" { + return None; + } + let mut out = Vec::new(); + for block in &message.content { + if let ContentBlock::Thinking { text } = block { + if !text.is_empty() { + out.push(text.clone()); + } + } + } + if out.is_empty() { + return None; + } + Some(out.join("\n\n")) +} + +pub fn render_function_result_message( + message: &AgentMessage, + verbosity: Verbosity, +) -> Option { + if message.role != "function_result" { + return None; + } + if verbosity != Verbosity::Debug { + return None; + } + let parts = render_content_blocks(&message.content, verbosity, true); + if parts.is_empty() { + return None; + } + Some(format!("function result:\n{}", parts.join("\n"))) +} + +fn verbosity_at_least(have: Verbosity, need: Verbosity) -> bool { + have >= need +} + +fn render_content_blocks( + blocks: &[ContentBlock], + verbosity: Verbosity, + force_all_text: bool, +) -> Vec { + let mut out = Vec::new(); + + for block in blocks { + match block { + ContentBlock::Text { text } if !text.is_empty() => { + out.push(text.clone()); + } + ContentBlock::FunctionCall { + function_id, + arguments, + .. + } if verbosity_at_least(verbosity, Verbosity::High) => { + let args = truncate_json(arguments, 400); + out.push(format!("⚙️ `{function_id}`\n{args}")); + } + ContentBlock::Other if force_all_text => {} + _ => {} + } + } + out +} + +fn truncate_json(value: &serde_json::Value, max: usize) -> String { + crate::text::truncate_ellipsis(&value.to_string(), max) +} + +pub fn turn_status_message( + status: &str, + reason: Option<&str>, + result_error: Option<&str>, +) -> String { + match status { + "failed" => { + let msg = result_error.or(reason).unwrap_or("turn failed"); + format!("❌ {msg}") + } + "cancelled" => { + let msg = reason.unwrap_or("turn cancelled"); + format!("⏹ {msg}") + } + _ => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::ContentBlock; + use serde_json::json; + + fn assistant(content: Vec) -> AgentMessage { + AgentMessage { + role: "assistant".into(), + content, + } + } + + #[test] + fn none_verbosity_text_only() { + let msg = assistant(vec![ + ContentBlock::Text { + text: "hello".into(), + }, + ContentBlock::Thinking { text: "hmm".into() }, + ]); + let out = render_assistant_message(&msg, Verbosity::None).unwrap(); + assert_eq!(out, "hello"); + } + + #[test] + fn thinking_extracted_regardless_of_verbosity() { + let msg = assistant(vec![ContentBlock::Thinking { + text: "plan".into(), + }]); + let thinking = render_thinking_text(&msg).unwrap(); + assert_eq!(thinking, "plan"); + } + + #[test] + fn answer_excludes_thinking_blocks() { + let msg = assistant(vec![ + ContentBlock::Thinking { + text: "plan".into(), + }, + ContentBlock::Text { + text: "answer".into(), + }, + ]); + let thinking = render_thinking_text(&msg).unwrap(); + let answer = render_answer_text(&msg, Verbosity::None).unwrap(); + assert_eq!(thinking, "plan"); + assert_eq!(answer, "answer"); + } + + #[test] + fn message_phase_detection() { + assert_eq!( + message_phase(&[ContentBlock::Thinking { text: "x".into() }]), + MessagePhase::ThinkingOnly + ); + assert_eq!( + message_phase(&[ + ContentBlock::Thinking { text: "x".into() }, + ContentBlock::Text { text: "y".into() } + ]), + MessagePhase::Answering + ); + } + + #[test] + fn high_includes_function_call() { + let msg = assistant(vec![ContentBlock::FunctionCall { + id: "c1".into(), + function_id: "shell::exec".into(), + arguments: json!({ "cmd": "ls" }), + }]); + let out = render_assistant_message(&msg, Verbosity::High).unwrap(); + assert!(out.contains("shell::exec")); + } + + #[test] + fn debug_renders_function_result() { + let msg = AgentMessage { + role: "function_result".into(), + content: vec![ContentBlock::Text { text: "ok".into() }], + }; + let out = render_function_result_message(&msg, Verbosity::Debug).unwrap(); + assert!(out.contains("ok")); + } +} diff --git a/telegram-bot/src/surface.rs b/telegram-bot/src/surface.rs new file mode 100644 index 000000000..cd5322f4d --- /dev/null +++ b/telegram-bot/src/surface.rs @@ -0,0 +1,77 @@ +use schemars::schema::RootSchema; +use schemars::JsonSchema; + +use crate::configuration::{ConfigChangeAck, ConfigChangeRequest}; +use crate::functions::bindings::message_added::BindingAck; +use crate::functions::set_webhook::{SetWebhookRequest, SetWebhookResponse}; +use crate::functions::webhook::WebhookResponse; +use crate::types::{ + HttpTriggerRequest, MessageAddedEvent, MessageUpdatedEvent, PendingApprovalRecord, + PendingResolvedEvent, StatusChangedEvent, TurnCompletedEvent, +}; + +pub struct FunctionSpec { + pub function_id: &'static str, + pub description: &'static str, + pub request_schema: RootSchema, + pub response_schema: RootSchema, +} + +fn schema_of() -> RootSchema { + schemars::gen::SchemaSettings::draft07() + .into_generator() + .into_root_schema_for::() +} + +fn spec( + function_id: &'static str, + description: &'static str, +) -> FunctionSpec { + FunctionSpec { + function_id, + description, + request_schema: schema_of::(), + response_schema: schema_of::(), + } +} + +pub fn catalog() -> Vec { + vec![ + spec::( + "telegram-bot::webhook", + "Receive Telegram updates; route commands, messages, and callbacks.", + ), + spec::( + "telegram-bot::set-webhook", + "Register the Telegram webhook URL from configuration.", + ), + spec::( + "telegram-bot::on-message-added", + "Create a Telegram message for each new assistant or function_result entry.", + ), + spec::( + "telegram-bot::on-message-updated", + "Stream assistant edits into Telegram, throttled by revision.", + ), + spec::( + "telegram-bot::on-status-changed", + "Observe session status changes.", + ), + spec::( + "telegram-bot::on-turn-completed", + "Drain FIFO queue and post turn outcome toasts.", + ), + spec::( + "telegram-bot::on-pending-created", + "Send an inline approval keyboard when a function call is held.", + ), + spec::( + "telegram-bot::on-pending-resolved", + "Clear the approval prompt when a held call is resolved.", + ), + spec::( + "telegram-bot::on-config-change", + "Internal: reload telegram-bot configuration on change.", + ), + ] +} diff --git a/telegram-bot/src/telemetry.rs b/telegram-bot/src/telemetry.rs new file mode 100644 index 000000000..f44d64f07 --- /dev/null +++ b/telegram-bot/src/telemetry.rs @@ -0,0 +1,81 @@ +//! OpenTelemetry baggage helpers — correlate Telegram ingress with harness turns. + +use serde_json::{json, Value}; + +/// Metadata forwarded on `harness::send` `options.metadata` for trace propagation. +pub fn tracing_metadata(session_id: &str, message_id: &str) -> Value { + json!({ + "session_id": session_id, + "message_id": message_id, + "surface": "telegram", + }) +} + +/// Run an async block with session/turn baggage for harness binding handlers. +pub async fn with_session_baggage( + deps: &crate::deps::Deps, + session_id: &str, + entry_id: Option<&str>, + f: F, +) -> T +where + F: FnOnce() -> Fut, + Fut: std::future::Future, +{ + let message_id = message_id_for_binding(deps, session_id, entry_id); + with_baggage(session_id, &message_id, f).await +} + +fn message_id_for_binding( + deps: &crate::deps::Deps, + session_id: &str, + entry_id: Option<&str>, +) -> String { + deps.runtime + .active_turns + .get(session_id) + .map(|r| r.clone()) + .or_else(|| entry_id.map(String::from)) + .unwrap_or_else(|| session_id.to_string()) +} + +/// Run an async block with `iii.session.id` / `iii.message.id` stamped into baggage. +pub async fn with_baggage(session_id: &str, message_id: &str, f: F) -> T +where + F: FnOnce() -> Fut, + Fut: std::future::Future, +{ + let baggage = [ + ("iii.session.id", session_id), + ("iii.message.id", message_id), + ]; + iii_observability::run_with_baggage(&baggage, f()).await +} + +/// Correlation id for a Telegram update (matches harness idempotency / metadata). +pub fn telegram_message_id(update_id: i64) -> String { + format!("tg-{update_id}") +} + +/// Session id for tracing before the harness session exists for a chat. +pub fn pending_session_id(chat_id: i64) -> String { + format!("pending-{chat_id}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tracing_metadata_shape() { + let meta = tracing_metadata("s1", "tg-42"); + assert_eq!(meta["session_id"], "s1"); + assert_eq!(meta["message_id"], "tg-42"); + assert_eq!(meta["surface"], "telegram"); + } + + #[test] + fn telegram_message_id_format() { + assert_eq!(telegram_message_id(123), "tg-123"); + } +} diff --git a/telegram-bot/src/text.rs b/telegram-bot/src/text.rs new file mode 100644 index 000000000..48171b99a --- /dev/null +++ b/telegram-bot/src/text.rs @@ -0,0 +1,26 @@ +//! Shared text helpers. + +/// Truncate `s` to at most `max_bytes` UTF-8 bytes, appending an ellipsis when shortened. +pub fn truncate_ellipsis(s: &str, max_bytes: usize) -> String { + if s.len() <= max_bytes { + return s.to_string(); + } + let mut end = max_bytes; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_ellipsis_respects_utf8_boundary() { + let s = "hello 🌍 world"; + let out = truncate_ellipsis(s, 8); + assert!(out.ends_with('…')); + assert!(std::str::from_utf8(out.as_bytes()).is_ok()); + } +} diff --git a/telegram-bot/src/types.rs b/telegram-bot/src/types.rs new file mode 100644 index 000000000..dae23163e --- /dev/null +++ b/telegram-bot/src/types.rs @@ -0,0 +1,214 @@ +//! Wire and Telegram API types. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub type JsonMap = serde_json::Map; + +// --------------------------------------------------------------------------- +// Telegram Bot API (subset) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct TelegramUpdate { + pub update_id: i64, + #[serde(default)] + pub message: Option, + #[serde(default)] + pub callback_query: Option, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct TelegramMessage { + pub message_id: i64, + pub chat: TelegramChat, + #[serde(default)] + pub text: Option, + #[serde(default)] + pub caption: Option, + #[serde(default)] + pub photo: Option>, + #[serde(default)] + pub document: Option, + #[serde(default)] + pub voice: Option, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct TelegramPhotoSize { + pub file_id: String, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct TelegramDocument { + pub file_id: String, + #[serde(default)] + pub file_name: Option, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct TelegramVoice { + pub file_id: String, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct TelegramChat { + pub id: i64, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct TelegramCallbackQuery { + pub id: String, + #[serde(default)] + pub from: Option, + pub message: Option, + #[serde(default)] + pub data: Option, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct TelegramUser { + pub id: i64, +} + +// --------------------------------------------------------------------------- +// HTTP trigger envelope +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct HttpTriggerRequest { + #[serde(default)] + pub body: Value, + #[serde(default)] + pub headers: Option, +} + +// --------------------------------------------------------------------------- +// Session / harness event payloads (subset) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct MessageAddedEvent { + pub session_id: String, + pub entry_id: String, + /// Parent entry in the session chain (authoritative append order). + #[serde(default)] + pub parent_id: Option, + #[serde(default)] + pub message: Option, + /// Append time in ms; used as the per-entry ordering key. + #[serde(default)] + pub timestamp: i64, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct MessageUpdatedEvent { + pub session_id: String, + pub entry_id: String, + pub message: AgentMessage, + pub revision: u64, + /// Mutation time in ms; refines the per-entry ordering key (min wins). + #[serde(default)] + pub timestamp: i64, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct StatusChangedEvent { + pub session_id: String, + pub status: String, + #[serde(default)] + pub status_reason: Option, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct TurnCompletedEvent { + pub session_id: String, + pub turn_id: String, + pub status: String, + #[serde(default)] + pub result_error: Option, + #[serde(default)] + pub reason: Option, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct AgentMessage { + pub role: String, + #[serde(default)] + pub content: Vec, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ContentBlock { + Text { + text: String, + }, + Thinking { + text: String, + }, + FunctionCall { + id: String, + function_id: String, + arguments: Value, + }, + #[serde(other)] + Other, +} + +// --------------------------------------------------------------------------- +// Approval gate payloads (subset) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct PendingApprovalRecord { + pub session_id: String, + pub function_call_id: String, + pub function_id: String, + #[serde(default)] + pub arguments_excerpt: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct PendingResolvedEvent { + pub session_id: String, + pub function_call_id: String, + pub outcome: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum ResolveDecision { + Allow, + Deny, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ApprovalCallbackData { + pub session_id: String, + pub function_call_id: String, + pub function_id: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatFsm { + Idle, + AwaitingModel, +} + +impl ChatFsm { + pub fn as_str(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::AwaitingModel => "awaiting_model", + } + } + + pub fn parse(s: &str) -> Self { + match s { + "awaiting_model" => Self::AwaitingModel, + _ => Self::Idle, + } + } +} diff --git a/telegram-bot/tests/fixtures/config.yaml b/telegram-bot/tests/fixtures/config.yaml new file mode 100644 index 000000000..78e2b7ff7 --- /dev/null +++ b/telegram-bot/tests/fixtures/config.yaml @@ -0,0 +1,6 @@ +bot_token: "test:integration-token" +updates: + name: polling + config: {} +verbosity: none +steering_mode: steering diff --git a/telegram-bot/tests/golden/schemas/telegram-bot.on-config-change.json b/telegram-bot/tests/golden/schemas/telegram-bot.on-config-change.json new file mode 100644 index 000000000..73db21642 --- /dev/null +++ b/telegram-bot/tests/golden/schemas/telegram-bot.on-config-change.json @@ -0,0 +1,22 @@ +{ + "description": "Internal: reload telegram-bot configuration on change.", + "function_id": "telegram-bot::on-config-change", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ConfigChangeRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "title": "ConfigChangeAck", + "type": "object" + } +} diff --git a/telegram-bot/tests/golden/schemas/telegram-bot.on-message-added.json b/telegram-bot/tests/golden/schemas/telegram-bot.on-message-added.json new file mode 100644 index 000000000..984248216 --- /dev/null +++ b/telegram-bot/tests/golden/schemas/telegram-bot.on-message-added.json @@ -0,0 +1,155 @@ +{ + "description": "Create a Telegram message for each new assistant or function_result entry.", + "function_id": "telegram-bot::on-message-added", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AgentMessage": { + "properties": { + "content": { + "items": { + "$ref": "#/definitions/ContentBlock" + }, + "type": "array" + }, + "role": { + "type": "string" + } + }, + "required": [ + "role" + ], + "type": "object" + }, + "ContentBlock": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "text" + ], + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "type": "object" + }, + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "thinking" + ], + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "type": "object" + }, + { + "properties": { + "arguments": true, + "function_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "function_call" + ], + "type": "string" + } + }, + "required": [ + "arguments", + "function_id", + "id", + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "other" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + } + }, + "properties": { + "entry_id": { + "type": "string" + }, + "message": { + "anyOf": [ + { + "$ref": "#/definitions/AgentMessage" + }, + { + "type": "null" + } + ] + }, + "parent_id": { + "default": null, + "description": "Parent entry in the session chain (authoritative append order).", + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": "string" + }, + "timestamp": { + "default": 0, + "description": "Append time in ms; used as the per-entry ordering key.", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "entry_id", + "session_id" + ], + "title": "MessageAddedEvent", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "title": "BindingAck", + "type": "object" + } +} diff --git a/telegram-bot/tests/golden/schemas/telegram-bot.on-message-updated.json b/telegram-bot/tests/golden/schemas/telegram-bot.on-message-updated.json new file mode 100644 index 000000000..071d0129d --- /dev/null +++ b/telegram-bot/tests/golden/schemas/telegram-bot.on-message-updated.json @@ -0,0 +1,147 @@ +{ + "description": "Stream assistant edits into Telegram, throttled by revision.", + "function_id": "telegram-bot::on-message-updated", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AgentMessage": { + "properties": { + "content": { + "items": { + "$ref": "#/definitions/ContentBlock" + }, + "type": "array" + }, + "role": { + "type": "string" + } + }, + "required": [ + "role" + ], + "type": "object" + }, + "ContentBlock": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "text" + ], + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "type": "object" + }, + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "thinking" + ], + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "type": "object" + }, + { + "properties": { + "arguments": true, + "function_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "function_call" + ], + "type": "string" + } + }, + "required": [ + "arguments", + "function_id", + "id", + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "other" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + } + }, + "properties": { + "entry_id": { + "type": "string" + }, + "message": { + "$ref": "#/definitions/AgentMessage" + }, + "revision": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "session_id": { + "type": "string" + }, + "timestamp": { + "default": 0, + "description": "Mutation time in ms; refines the per-entry ordering key (min wins).", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "entry_id", + "message", + "revision", + "session_id" + ], + "title": "MessageUpdatedEvent", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "title": "BindingAck", + "type": "object" + } +} diff --git a/telegram-bot/tests/golden/schemas/telegram-bot.on-pending-created.json b/telegram-bot/tests/golden/schemas/telegram-bot.on-pending-created.json new file mode 100644 index 000000000..c1ab382c8 --- /dev/null +++ b/telegram-bot/tests/golden/schemas/telegram-bot.on-pending-created.json @@ -0,0 +1,41 @@ +{ + "description": "Send an inline approval keyboard when a function call is held.", + "function_id": "telegram-bot::on-pending-created", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "arguments_excerpt": { + "default": null + }, + "function_call_id": { + "type": "string" + }, + "function_id": { + "type": "string" + }, + "session_id": { + "type": "string" + } + }, + "required": [ + "function_call_id", + "function_id", + "session_id" + ], + "title": "PendingApprovalRecord", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "title": "BindingAck", + "type": "object" + } +} diff --git a/telegram-bot/tests/golden/schemas/telegram-bot.on-pending-resolved.json b/telegram-bot/tests/golden/schemas/telegram-bot.on-pending-resolved.json new file mode 100644 index 000000000..a8a159c0b --- /dev/null +++ b/telegram-bot/tests/golden/schemas/telegram-bot.on-pending-resolved.json @@ -0,0 +1,38 @@ +{ + "description": "Clear the approval prompt when a held call is resolved.", + "function_id": "telegram-bot::on-pending-resolved", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "function_call_id": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "session_id": { + "type": "string" + } + }, + "required": [ + "function_call_id", + "outcome", + "session_id" + ], + "title": "PendingResolvedEvent", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "title": "BindingAck", + "type": "object" + } +} diff --git a/telegram-bot/tests/golden/schemas/telegram-bot.on-status-changed.json b/telegram-bot/tests/golden/schemas/telegram-bot.on-status-changed.json new file mode 100644 index 000000000..9342825a7 --- /dev/null +++ b/telegram-bot/tests/golden/schemas/telegram-bot.on-status-changed.json @@ -0,0 +1,41 @@ +{ + "description": "Observe session status changes.", + "function_id": "telegram-bot::on-status-changed", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "session_id": { + "type": "string" + }, + "status": { + "type": "string" + }, + "status_reason": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "session_id", + "status" + ], + "title": "StatusChangedEvent", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "title": "BindingAck", + "type": "object" + } +} diff --git a/telegram-bot/tests/golden/schemas/telegram-bot.on-turn-completed.json b/telegram-bot/tests/golden/schemas/telegram-bot.on-turn-completed.json new file mode 100644 index 000000000..2741ed256 --- /dev/null +++ b/telegram-bot/tests/golden/schemas/telegram-bot.on-turn-completed.json @@ -0,0 +1,52 @@ +{ + "description": "Drain FIFO queue and post turn outcome toasts.", + "function_id": "telegram-bot::on-turn-completed", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "reason": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "result_error": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": "string" + }, + "status": { + "type": "string" + }, + "turn_id": { + "type": "string" + } + }, + "required": [ + "session_id", + "status", + "turn_id" + ], + "title": "TurnCompletedEvent", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "title": "BindingAck", + "type": "object" + } +} diff --git a/telegram-bot/tests/golden/schemas/telegram-bot.set-webhook.json b/telegram-bot/tests/golden/schemas/telegram-bot.set-webhook.json new file mode 100644 index 000000000..749a8a7f8 --- /dev/null +++ b/telegram-bot/tests/golden/schemas/telegram-bot.set-webhook.json @@ -0,0 +1,26 @@ +{ + "description": "Register the Telegram webhook URL from configuration.", + "function_id": "telegram-bot::set-webhook", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SetWebhookRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "ok": { + "type": "boolean" + }, + "url": { + "type": "string" + } + }, + "required": [ + "ok", + "url" + ], + "title": "SetWebhookResponse", + "type": "object" + } +} diff --git a/telegram-bot/tests/golden/schemas/telegram-bot.webhook.json b/telegram-bot/tests/golden/schemas/telegram-bot.webhook.json new file mode 100644 index 000000000..f662543b0 --- /dev/null +++ b/telegram-bot/tests/golden/schemas/telegram-bot.webhook.json @@ -0,0 +1,35 @@ +{ + "description": "Receive Telegram updates; route commands, messages, and callbacks.", + "function_id": "telegram-bot::webhook", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "body": { + "default": null + }, + "headers": { + "additionalProperties": true, + "default": null, + "type": [ + "object", + "null" + ] + } + }, + "title": "HttpTriggerRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "title": "WebhookResponse", + "type": "object" + } +} diff --git a/telegram-bot/tests/integration.rs b/telegram-bot/tests/integration.rs new file mode 100644 index 000000000..a6b4c02c2 --- /dev/null +++ b/telegram-bot/tests/integration.rs @@ -0,0 +1,97 @@ +//! End-to-end test: spawn `iii` engine + worker. Self-skips when `iii` is absent. + +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +use iii_sdk::{register_worker, InitOptions, TriggerRequest}; +use serde_json::json; +use tokio::time::{sleep, timeout}; + +const ENGINE_WS: &str = "ws://127.0.0.1:49134"; + +struct Harness { + iii: Child, + worker: Child, +} + +impl Drop for Harness { + fn drop(&mut self) { + let _ = self.worker.kill(); + let _ = self.worker.wait(); + let _ = self.iii.kill(); + let _ = self.iii.wait(); + } +} + +async fn boot() -> Option { + let iii_bin = which::which("iii").ok()?; + + let iii = Command::new(&iii_bin) + .arg("--use-default-config") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + sleep(Duration::from_millis(800)).await; + + let worker_bin = env!("CARGO_BIN_EXE_telegram-bot"); + let config = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/config.yaml"); + let worker = Command::new(worker_bin) + .arg("--url") + .arg(ENGINE_WS) + .arg("--config") + .arg(config) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + sleep(Duration::from_millis(1500)).await; + Some(Harness { iii, worker }) +} + +#[tokio::test] +async fn worker_registers_on_engine() { + let Some(_h) = boot().await else { + eprintln!("skipping: `iii` binary not on PATH"); + return; + }; + + let client = register_worker(ENGINE_WS, InitOptions::default()); + sleep(Duration::from_millis(500)).await; + + let result = timeout( + Duration::from_secs(10), + client.trigger(TriggerRequest { + function_id: "engine::functions::list".into(), + payload: json!({}), + action: None, + timeout_ms: Some(5000), + }), + ) + .await + .expect("trigger timed out") + .expect("trigger failed"); + + let functions = result + .get("functions") + .and_then(|v| v.as_array()) + .expect("functions list"); + // The engine identifies functions by `function_id`; older builds used `id`. + // Accept either so the probe is robust to engine/SDK version skew. The + // `telegram-bot::webhook` function is registered regardless of the active + // updates adapter (its HTTP route is what is adapter-gated, not the + // function itself). + let registered = functions.iter().any(|f| { + f.get("function_id") + .or_else(|| f.get("id")) + .and_then(|v| v.as_str()) + == Some("telegram-bot::webhook") + }); + if !registered { + panic!("telegram-bot::webhook was not registered on engine"); + } + + client.shutdown_async().await; +} diff --git a/telegram-bot/tests/manifest.rs b/telegram-bot/tests/manifest.rs new file mode 100644 index 000000000..33fa1225d --- /dev/null +++ b/telegram-bot/tests/manifest.rs @@ -0,0 +1,30 @@ +use std::process::Command; + +use serde_json::Value; + +#[test] +fn manifest_subcommand_emits_valid_json() { + let bin = env!("CARGO_BIN_EXE_telegram-bot"); + let output = Command::new(bin) + .arg("--manifest") + .output() + .expect("spawn telegram-bot --manifest"); + + assert!( + output.status.success(), + "binary exited with {:?}; stderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr), + ); + + let stdout = String::from_utf8(output.stdout).expect("manifest stdout is utf-8"); + let manifest: Value = serde_json::from_str(&stdout).expect("manifest stdout is valid JSON"); + + assert_eq!(manifest["name"], env!("CARGO_PKG_NAME")); + assert_eq!(manifest["version"], env!("CARGO_PKG_VERSION")); + assert!(manifest["default_config"].is_object()); + assert!(!manifest["supported_targets"] + .as_array() + .expect("supported_targets must be an array") + .is_empty()); +} diff --git a/telegram-bot/tests/schemas.rs b/telegram-bot/tests/schemas.rs new file mode 100644 index 000000000..0fb2c6550 --- /dev/null +++ b/telegram-bot/tests/schemas.rs @@ -0,0 +1,70 @@ +mod support; + +use telegram_bot::surface::{catalog, FunctionSpec}; + +fn golden_file_name(function_id: &str) -> String { + format!("schemas/{}.json", function_id.replace("::", ".")) +} + +fn spec_to_pretty_json(spec: &FunctionSpec) -> String { + let value = serde_json::json!({ + "function_id": spec.function_id, + "description": spec.description, + "request_schema": spec.request_schema, + "response_schema": spec.response_schema, + }); + let mut pretty = serde_json::to_string_pretty(&value).expect("spec serializes"); + pretty.push('\n'); + pretty +} + +#[test] +fn catalog_lists_all_functions_in_registration_order() { + let ids: Vec<&str> = catalog().iter().map(|s| s.function_id).collect(); + assert_eq!( + ids, + vec![ + "telegram-bot::webhook", + "telegram-bot::set-webhook", + "telegram-bot::on-message-added", + "telegram-bot::on-message-updated", + "telegram-bot::on-status-changed", + "telegram-bot::on-turn-completed", + "telegram-bot::on-pending-created", + "telegram-bot::on-pending-resolved", + "telegram-bot::on-config-change", + ] + ); +} + +#[test] +fn wire_schema_snapshots_match_goldens() { + let mut failures = Vec::new(); + for spec in catalog() { + let rel = golden_file_name(spec.function_id); + let actual = spec_to_pretty_json(&spec); + if let Err(msg) = support::check_golden(&rel, &actual) { + failures.push(msg); + } + } + assert!( + failures.is_empty(), + "{} wire-schema golden(s) drifted:\n\n{}", + failures.len(), + failures.join("\n") + ); +} + +#[test] +fn every_function_has_typed_request_and_response_schemas() { + for spec in catalog() { + support::assert_typed_schema( + &format!("{} request_schema", spec.function_id), + &spec.request_schema, + ); + support::assert_typed_schema( + &format!("{} response_schema", spec.function_id), + &spec.response_schema, + ); + } +} diff --git a/telegram-bot/tests/support/mod.rs b/telegram-bot/tests/support/mod.rs new file mode 100644 index 000000000..d77a2f459 --- /dev/null +++ b/telegram-bot/tests/support/mod.rs @@ -0,0 +1,57 @@ +#![allow(dead_code)] + +use std::fs; +use std::path::PathBuf; + +pub fn golden_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/golden") +} + +fn update_mode() -> bool { + std::env::var("UPDATE_GOLDENS") + .map(|v| v == "1") + .unwrap_or(false) +} + +pub fn check_golden(rel: &str, actual: &str) -> Result<(), String> { + let path = golden_root().join(rel); + if update_mode() { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; + } + fs::write(&path, actual).map_err(|e| format!("write {}: {e}", path.display()))?; + return Ok(()); + } + let expected = fs::read_to_string(&path).map_err(|e| { + format!( + "golden file {} unreadable ({e}). Run `UPDATE_GOLDENS=1 cargo test`.", + path.display() + ) + })?; + if expected == actual { + return Ok(()); + } + Err(format!("golden mismatch: tests/golden/{rel}")) +} + +pub fn assert_typed_schema(label: &str, schema: &schemars::schema::RootSchema) { + let value = serde_json::to_value(schema).expect("schema serializes"); + let obj = value + .as_object() + .unwrap_or_else(|| panic!("{label}: schema is not a JSON object")); + const DEFINING: [&str; 8] = [ + "type", + "properties", + "$ref", + "allOf", + "anyOf", + "oneOf", + "enum", + "items", + ]; + let has_defining = DEFINING.iter().any(|k| obj.contains_key(*k)); + assert!( + has_defining, + "{label}: schema is the permissive AnyValue/empty schema. Got: {value}" + ); +}