diff --git a/docs/superpowers/plans/2026-07-15-telegram-omp-status-indicator.md b/docs/superpowers/plans/2026-07-15-telegram-omp-status-indicator.md new file mode 100644 index 0000000..06cb4ac --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-telegram-omp-status-indicator.md @@ -0,0 +1,404 @@ +# Telegram OMP 17 Status Indicator Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show the Telegram bridge's connection state in OMP 17's upper status bar immediately after the cost segment, while documenting the correct OMP plugin workflow. + +**Architecture:** OMP gains one narrow public extension UI method for a sanitized status segment fixed after `cost`. The Telegram plugin calls that method from its existing status update path, keeping connection-state derivation local and network-free. A checkout of OMP is modified and built; the Bun-global package is never patched in place. + +**Tech Stack:** TypeScript, Bun, OMP 17 status-line component, OMP extension API, Node test runner, Telegram bridge extension. + +--- + +## File structure + +### OMP source checkout: `D:/Documents/Github/oh-my-pi` + +- `packages/coding-agent/src/extensibility/extensions/types.ts` — public extension UI type. +- `packages/coding-agent/src/modes/controllers/extension-ui-controller.ts` — forwards the public API to the status-line component. +- `packages/coding-agent/src/modes/components/status-line/component.ts` — owns segment storage, sanitization, insertion after `cost`, and width eviction. +- `packages/coding-agent/src/modes/components/status-line/component.test.ts` — verifies public-status segment rendering. + +### Telegram plugin: `D:/Documents/Github/oh-my-pi-telegram` + +- `lib/pi.ts` — narrow structural adapter for OMP's optional public top-status API. +- `lib/status.ts` — derives the two Telegram status labels and publishes through injected ports. +- `index.ts` — wires the OMP status-segment port into the existing status runtime. +- `tests/status.test.ts` — verifies connection-state labels and publication calls. +- `tests/public-api.test.ts` — raises the declared OMP compatibility floor to 17.0.0. +- `package.json` — raises `omp.compatibleWith["@oh-my-pi/pi-coding-agent"]` to `>=17.0.0`. +- `README.md` — correct OMP plugin install/link/setup instructions. + +## Task 1: Create a durable OMP 17 source checkout + +**Files:** +- Create: `D:/Documents/Github/oh-my-pi` + +- [ ] **Step 1: Clone and install the OMP 17 source tree** + +```powershell +git clone https://github.com/can1357/oh-my-pi.git D:/Documents/Github/oh-my-pi +cd D:/Documents/Github/oh-my-pi +git switch --create feature/telegram-status-segment +bun install +``` + +- [ ] **Step 2: Confirm the checkout matches the installed OMP major version** + +Run: + +```powershell +bun --cwd packages/coding-agent -e "console.log(require('./package.json').version)" +``` + +Expected: a `17.x.x` version. If the upstream checkout is newer, switch to the `v17.0.0` tag before editing so the source API matches the installed OMP. + +- [ ] **Step 3: Commit no code in this task** + +The checkout is preparation only. The first code commit is made after the API and its test pass together. + +## Task 2: Add OMP's public after-cost extension segment API + +**Files:** +- Modify: `D:/Documents/Github/oh-my-pi/packages/coding-agent/src/extensibility/extensions/types.ts` +- Modify: `D:/Documents/Github/oh-my-pi/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts` +- Modify: `D:/Documents/Github/oh-my-pi/packages/coding-agent/src/modes/components/status-line/component.ts` +- Test: `D:/Documents/Github/oh-my-pi/packages/coding-agent/src/modes/components/status-line/component.test.ts` + +- [ ] **Step 1: Write failing status-line component tests** + +Add tests that construct `StatusLineComponent`, register two extension segments, and inspect `getTopBorder(200).content` after stripping SGR codes. The assertions must prove: + +```ts +expect(stripped.indexOf("$2.50")).toBeLessThan(stripped.indexOf("Telegram: conectado")); +expect(stripped.indexOf("Telegram: conectado")).toBeLessThan(stripped.indexOf("session-name")); +expect(stripped).not.toContain("\\x1b[31m"); +``` + +Set the test session's usage statistics to `cost: 2.5` and its session name to `session-name`. Register a segment with `"Telegram: conectado"`; register another containing `"\x1b[31munsafe\x1b[0m"`; then clear the first segment and assert that only the cleared value disappears. + +- [ ] **Step 2: Run the component test and observe the missing API failure** + +Run: + +```powershell +bun test packages/coding-agent/src/modes/components/status-line/component.test.ts +``` + +Expected: FAIL because `StatusLineComponent` has no extension-status-segment method. + +- [ ] **Step 3: Define the public UI type** + +In `ExtensionUIContext`, add the narrow method and option type: + +```ts +export type ExtensionStatusSegmentPlacement = "after-cost"; + +export interface ExtensionStatusSegmentOptions { + placement: ExtensionStatusSegmentPlacement; +} + +setStatusSegment( + key: string, + text: string | undefined, + options: ExtensionStatusSegmentOptions, +): void; +``` + +Only `"after-cost"` is accepted. Do not expose component references, arbitrary segment IDs, callbacks, or styling controls. + +- [ ] **Step 4: Forward the API through the interactive extension UI controller** + +Add to `uiContext` in `ExtensionUiController.initHooksAndCustomTools()`: + +```ts +setStatusSegment: (key, text, options) => { + if (options.placement !== "after-cost") return; + this.ctx.statusLine.setExtensionStatusSegment(key, text); + this.ctx.ui.requestRender(); +}, +``` + +Add no-op implementations for non-interactive mock UI contexts in `extensibility/utils.ts`, `extensibility/extensions/runner.ts`, `modes/acp/acp-agent.ts`, and `session/agent-session.ts`, matching the existing `setStatus` convention. + +- [ ] **Step 5: Store and render sanitized extension segments in the status-line component** + +Add a private `Map` and method: + +```ts +setExtensionStatusSegment(key: string, text: string | undefined): void { + if (text === undefined) this.#extensionStatusSegments.delete(key); + else this.#extensionStatusSegments.set(key, sanitizeStatusText(text)); +} +``` + +In `#buildStatusLine`, retain IDs for both rendered groups. After collecting built-in segments, insert the map values sorted by key immediately after the rendered `cost` entry in whichever group contains it. If `cost` is not visible, omit extension segments; their contract is explicitly after visible cost. Include synthetic IDs in the width bookkeeping so the normal right-to-left overflow policy can evict them without breaking `leftSegIds` alignment. + +- [ ] **Step 6: Run the focused test to verify behavior** + +Run: + +```powershell +bun test packages/coding-agent/src/modes/components/status-line/component.test.ts +``` + +Expected: PASS. It proves deterministic ordering, sanitization, and clearing. + +- [ ] **Step 7: Type-check and commit the OMP API** + +Run: + +```powershell +bun --cwd packages/coding-agent run check:types +git diff --check +git add packages/coding-agent/src/extensibility/extensions/types.ts packages/coding-agent/src/extensibility/utils.ts packages/coding-agent/src/extensibility/extensions/runner.ts packages/coding-agent/src/modes/acp/acp-agent.ts packages/coding-agent/src/modes/components/status-line/component.ts packages/coding-agent/src/modes/components/status-line/component.test.ts packages/coding-agent/src/modes/controllers/extension-ui-controller.ts packages/coding-agent/src/session/agent-session.ts +git commit -m "feat: expose after-cost extension status segments" +``` + +Expected: type-check exits 0 and the commit contains only the API, wiring, component, and test. + +## Task 3: Publish Telegram bridge state through the new API + +**Files:** +- Modify: `D:/Documents/Github/oh-my-pi-telegram/lib/pi.ts` +- Modify: `D:/Documents/Github/oh-my-pi-telegram/lib/status.ts` +- Modify: `D:/Documents/Github/oh-my-pi-telegram/index.ts` +- Test: `D:/Documents/Github/oh-my-pi-telegram/tests/status.test.ts` + +- [ ] **Step 1: Write failing plugin status tests** + +Add pure tests for a new helper with these observable contracts: + +```ts +assert.equal(getTelegramTopStatusSegment({ pollingActive: true }), "Telegram: conectado"); +assert.equal(getTelegramTopStatusSegment({ pollingActive: false }), "Telegram: desconectado"); +``` + +Add an injected `setStatusSegment` spy and call `runtime.updateStatus(ctx)` with a context whose `ui.setStatus` is a no-op. Assert that the captured publication is: + +```ts +["telegram", "Telegram: conectado", { placement: "after-cost" }] +``` + +when polling becomes active, and the disconnected text when it becomes inactive. + +- [ ] **Step 2: Run the focused status test and observe the missing helper failure** + +Run: + +```powershell +node --experimental-strip-types --test tests/status.test.ts +``` + +Expected: FAIL because `getTelegramTopStatusSegment` is not exported. + +- [ ] **Step 3: Add a narrow OMP context capability adapter** + +In `lib/pi.ts`, define structural ports without importing OMP internal modules: + +```ts +export interface PiTopStatusSegmentPorts { + setStatusSegment?: ( + key: string, + text: string | undefined, + options: { placement: "after-cost" }, + ) => void; +} + +export function setExtensionContextTopStatusSegment( + ctx: ExtensionContext, + key: string, + text: string, +): void { + const ui = ctx.ui as ExtensionContext["ui"] & PiTopStatusSegmentPorts; + ui.setStatusSegment?.(key, text, { placement: "after-cost" }); +} +``` + +The optional capability keeps passive modes and stale local SDK declarations from throwing. The package compatibility floor still makes OMP 17 the supported runtime. + +- [ ] **Step 4: Publish the two-state bridge label in `lib/status.ts`** + +Add: + +```ts +export function getTelegramTopStatusSegment(state: Pick): string { + return state.pollingActive ? "Telegram: conectado" : "Telegram: desconectado"; +} +``` + +Extend `TelegramStatusRuntimeDeps` with optional: + +```ts +setStatusSegment?: (ctx: TContext, key: string, text: string) => void; +``` + +In `updateStatus`, compute the bridge state once, retain the existing footer `setStatus` call, and then call: + +```ts +deps.setStatusSegment?.(ctx, "telegram", getTelegramTopStatusSegment(state)); +``` + +The status runtime is already called from connect, disconnect, polling, lock, and session updates, so no additional event listeners are added. + +- [ ] **Step 5: Wire the context adapter from `index.ts`** + +Pass `Pi.setExtensionContextTopStatusSegment` as `setStatusSegment` into `createTelegramBridgeStatusRuntime`. Do not call the OMP API directly outside the `lib/pi.ts` adapter boundary. + +- [ ] **Step 6: Run focused tests and type-check** + +Run: + +```powershell +node --experimental-strip-types --test tests/status.test.ts +npm run typecheck +``` + +Expected: all focused status tests pass and TypeScript exits 0. + +- [ ] **Step 7: Commit the plugin integration** + +```powershell +git diff --check +git add lib/pi.ts lib/status.ts index.ts tests/status.test.ts +git commit -m "feat: publish Telegram bridge status in OMP" +``` + +## Task 4: Correct OMP installation and secure setup documentation + +**Files:** +- Modify: `D:/Documents/Github/oh-my-pi-telegram/README.md` +- Modify: `D:/Documents/Github/oh-my-pi-telegram/package.json` +- Modify: `D:/Documents/Github/oh-my-pi-telegram/tests/public-api.test.ts` + +- [ ] **Step 1: Write a failing manifest test for the OMP 17 floor** + +Change the existing test expectation to: + +```ts +assert.equal( + packageJson.omp?.compatibleWith?.["@oh-my-pi/pi-coding-agent"], + ">=17.0.0", +); +``` + +- [ ] **Step 2: Run the manifest test and observe the floor mismatch** + +Run: + +```powershell +node --experimental-strip-types --test tests/public-api.test.ts +``` + +Expected: FAIL because the manifest still declares `>=16.5.2`. + +- [ ] **Step 3: Update the manifest and README** + +Set the OMP compatibility floor in `package.json` to `>=17.0.0`. + +Replace the incorrect OMP install block with: + +```bash +omp plugin install npm:@evandrodevbr/oh-my-pi-telegram +omp plugin install git:github.com/evandrodevbr/oh-my-pi-telegram +omp plugin link /path/to/oh-my-pi-telegram +``` + +Document restarting OMP after installation/linking. Keep bot-token setup limited to the interactive commands: + +```text +/telegram-setup +/telegram-connect +/telegram-disconnect +/telegram-status +``` + +Explicitly state that bot tokens must not be committed or pasted into chat. + +- [ ] **Step 4: Run documentation-adjacent verification** + +Run: + +```powershell +node --experimental-strip-types --test tests/public-api.test.ts +npm run typecheck +npm run audit +npm run pack:check +``` + +Expected: all commands exit 0. + +- [ ] **Step 5: Commit documentation and compatibility metadata** + +```powershell +git diff --check +git add README.md package.json tests/public-api.test.ts +git commit -m "docs: document OMP 17 Telegram setup" +``` + +## Task 5: Build, install, configure, and smoke-test the integrated runtime + +**Files:** +- Modify: no source files expected. + +- [ ] **Step 1: Build and link the versioned OMP checkout** + +From `D:/Documents/Github/oh-my-pi`: + +```powershell +bun --cwd packages/coding-agent run check:types +bun --cwd packages/coding-agent run build +bun --cwd packages/coding-agent link +omp --version +``` + +Expected: the linked `omp` reports the built 17.x version. Do not edit or link files under the Bun global package directory directly. + +- [ ] **Step 2: Link the updated Telegram plugin** + +From `D:/Documents/Github/oh-my-pi-telegram`: + +```powershell +omp plugin link . +omp plugin doctor +``` + +Expected: the doctor lists `@evandrodevbr/oh-my-pi-telegram` with zero errors. + +- [ ] **Step 3: Configure the replacement token locally** + +Start `omp` in the user's own terminal and run: + +```text +/telegram-setup +``` + +Paste the new bot token only into OMP's local prompt. Then run: + +```text +/telegram-connect +``` + +Expected: polling starts and the upper status bar contains `Telegram: conectado` after `$… (sub)`. + +- [ ] **Step 4: Exercise disconnect and command behavior** + +In the same OMP session, run: + +```text +/telegram-status +/telegram-disconnect +``` + +Expected: `/telegram-status` returns bridge diagnostics and the upper status bar changes to `Telegram: desconectado` after cost. + +- [ ] **Step 5: Commit and push both repositories** + +From each repository after its focused tests pass: + +```powershell +git status --short +git push origin HEAD +``` + +Expected: both commits are on their respective remote branches. Do not push a bot token, generated build output, Bun-global installation files, or `.env` files. diff --git a/lib/notices.ts b/lib/notices.ts new file mode 100644 index 0000000..6e0ba3e --- /dev/null +++ b/lib/notices.ts @@ -0,0 +1,104 @@ +/** + * Telegram operator notices + * Zones: telegram ui, tui + * Owns the one-off host toasts (owner paired, connection recovered, reply not delivered) and their throttling + */ + +import { + sanitizeTelegramStatusText, + summarizeTelegramStatusError, +} from "./status.ts"; + +export type TelegramNoticeLevel = "info" | "warning" | "error"; + +/** A failed delivery raises at most one notice per this window. */ +export const TELEGRAM_DELIVERY_FAILED_NOTICE_COOLDOWN_MS = 30_000; + +export interface TelegramNoticeRuntimeDeps { + /** Current session context, when the bridge has one. */ + getContext: () => TContext | undefined; + notify: (ctx: TContext, message: string, level: TelegramNoticeLevel) => void; + recordRuntimeEvent: ( + category: string, + error: unknown, + details?: Record, + ) => void; + now?: () => number; +} + +export interface TelegramNoticeRuntime { + /** The first Telegram user was accepted as the bridge owner. */ + paired: (userId: number, ctx: TContext) => void; + /** Polling works again after at least one failed attempt. */ + recovered: (ctx: TContext) => void; + /** A message could not be delivered; throttled, uses the current context. */ + deliveryFailed: (error: unknown) => void; +} + +function describeTelegramDeliveryFailure(error: unknown): { + message: string; + level: TelegramNoticeLevel; +} { + const message = error instanceof Error ? error.message : String(error); + const summary = summarizeTelegramStatusError(message); + if (summary?.deliveryUnknown) { + return { + message: "Telegram: reply delivery unconfirmed (check the chat)", + level: "warning", + }; + } + if (summary) { + return { + message: `Telegram: reply not delivered (${summary.label})`, + level: summary.kind === "fatal" ? "error" : "warning", + }; + } + const raw = sanitizeTelegramStatusText(message); + const reason = raw.length > 80 ? `${raw.slice(0, 79)}…` : raw; + return { message: `Telegram: reply not delivered (${reason})`, level: "error" }; +} + +/** + * Toasts never propagate failures into the path that raised them: a stale + * session context only produces a recorded runtime event. + */ +export function createTelegramNoticeRuntime( + deps: TelegramNoticeRuntimeDeps, +): TelegramNoticeRuntime { + const now = deps.now ?? Date.now; + let lastDeliveryNoticeAtMs: number | undefined; + const show = ( + ctx: TContext, + message: string, + level: TelegramNoticeLevel, + ): void => { + try { + deps.notify(ctx, message, level); + } catch (error) { + deps.recordRuntimeEvent("status", error, { phase: "notice" }); + } + }; + return { + paired(userId, ctx) { + show(ctx, `Telegram paired with user ${userId}`, "info"); + }, + recovered(ctx) { + show(ctx, "Telegram reconnected", "info"); + }, + deliveryFailed(error) { + const ctx = deps.getContext(); + if (ctx === undefined) return; + const nowMs = now(); + if ( + lastDeliveryNoticeAtMs !== undefined && + nowMs - lastDeliveryNoticeAtMs < + TELEGRAM_DELIVERY_FAILED_NOTICE_COOLDOWN_MS + ) { + return; + } + lastDeliveryNoticeAtMs = nowMs; + const failure = describeTelegramDeliveryFailure(error); + show(ctx, failure.message, failure.level); + }, + }; +} diff --git a/lib/traffic.ts b/lib/traffic.ts new file mode 100644 index 0000000..24dd27c --- /dev/null +++ b/lib/traffic.ts @@ -0,0 +1,134 @@ +/** + * Telegram bridge traffic counters + * Zones: telegram transport, status + * Owns in-memory received/sent message counts and last-activity time for the status bar + * + * This domain is pure: it counts, it does not read config, call Telegram, or import other domains. + */ + +export interface TelegramTrafficSnapshot { + /** Authorized messages received from the paired owner since the bridge loaded. */ + received: number; + /** Messages the bridge delivered to Telegram since the bridge loaded. */ + sent: number; + /** Messages that could not be delivered after the transport retries. */ + failed: number; + /** Time of the latest received or sent message, when there was one. */ + lastAtMs?: number; +} + +export interface TelegramTrafficCounters { + recordReceived: () => void; + recordSent: () => void; + recordFailed: () => void; + snapshot: () => TelegramTrafficSnapshot; +} + +/** + * Bot API methods that deliver a message the owner can read. Typing actions, + * drafts, edits, reactions and callback answers are not messages. + */ +const TELEGRAM_OUTBOUND_MESSAGE_METHODS: Readonly> = { + sendMessage: true, + sendRichMessage: true, + sendPhoto: true, + sendDocument: true, + sendAudio: true, + sendVideo: true, + sendVoice: true, + sendAnimation: true, + sendVideoNote: true, + sendMediaGroup: true, + sendSticker: true, +}; + +export function isTelegramOutboundMessageMethod(method: string): boolean { + return Object.hasOwn(TELEGRAM_OUTBOUND_MESSAGE_METHODS, method); +} + +export function createTelegramTrafficCounters( + now: () => number = Date.now, +): TelegramTrafficCounters { + let received = 0; + let sent = 0; + let failed = 0; + let lastAtMs: number | undefined; + return { + recordReceived() { + received += 1; + lastAtMs = now(); + }, + recordSent() { + sent += 1; + lastAtMs = now(); + }, + recordFailed() { + failed += 1; + }, + snapshot: () => ({ + received, + sent, + failed, + ...(lastAtMs === undefined ? {} : { lastAtMs }), + }), + }; +} + +export interface TelegramTrafficStatusBridgeDeps { + counters: TelegramTrafficCounters; + /** Current session context, when the bridge has one. */ + getContext: () => TContext | undefined; + updateStatus: (ctx: TContext) => void; + recordRuntimeEvent: ( + category: string, + error: unknown, + details?: Record, + ) => void; + /** Told about every message delivery that failed, e.g. to raise a notice. */ + onDeliveryFailed?: (error: unknown) => void; +} + +export interface TelegramTrafficStatusBridge { + /** Observer for successful Bot API calls; counts delivered messages. */ + onCallSucceeded: (method: string) => void; + /** Observer for failed Bot API calls; counts undelivered messages. */ + onCallFailed: (method: string, error: unknown) => void; + /** Observer for messages accepted from the paired owner. */ + onAuthorizedMessage: (ctx: TContext) => void; +} + +/** + * Counts traffic and refreshes the status bar so the counters stay current. A + * status refresh failure (for example a stale session context) is recorded and + * never propagates into the send or receive path that triggered it. + */ +export function createTelegramTrafficStatusBridge( + deps: TelegramTrafficStatusBridgeDeps, +): TelegramTrafficStatusBridge { + const refreshStatus = (ctx: TContext): void => { + try { + deps.updateStatus(ctx); + } catch (error) { + deps.recordRuntimeEvent("status", error, { phase: "traffic-refresh" }); + } + }; + return { + onCallSucceeded(method) { + if (!isTelegramOutboundMessageMethod(method)) return; + deps.counters.recordSent(); + const ctx = deps.getContext(); + if (ctx !== undefined) refreshStatus(ctx); + }, + onCallFailed(method, error) { + if (!isTelegramOutboundMessageMethod(method)) return; + deps.counters.recordFailed(); + deps.onDeliveryFailed?.(error); + const ctx = deps.getContext(); + if (ctx !== undefined) refreshStatus(ctx); + }, + onAuthorizedMessage(ctx) { + deps.counters.recordReceived(); + refreshStatus(ctx); + }, + }; +} diff --git a/tests/notices.test.ts b/tests/notices.test.ts new file mode 100644 index 0000000..d22a259 --- /dev/null +++ b/tests/notices.test.ts @@ -0,0 +1,111 @@ +/** + * Regression tests for the Telegram operator notices + * Covers the toast wording, severity, throttling and failure isolation. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createTelegramNoticeRuntime, + TELEGRAM_DELIVERY_FAILED_NOTICE_COOLDOWN_MS, +} from "../lib/notices.ts"; + +function createNoticeHarness(options: { notifyThrows?: boolean } = {}) { + const notices: string[] = []; + const recorded: string[] = []; + let nowMs = 1_000_000; + let context: string | undefined = "session"; + const runtime = createTelegramNoticeRuntime({ + getContext: () => context, + notify: (ctx: string, message, level) => { + if (options.notifyThrows) throw new Error("stale ctx"); + notices.push(`${level}|${ctx}|${message}`); + }, + recordRuntimeEvent: (category, error, details) => { + recorded.push(`${category}:${(error as Error).message}:${details?.phase}`); + }, + now: () => nowMs, + }); + return { + runtime, + notices, + recorded, + advance: (ms: number) => { + nowMs += ms; + }, + dropContext: () => { + context = undefined; + }, + }; +} + +test("Notices announce pairing and recovery as info toasts", () => { + const { runtime, notices } = createNoticeHarness(); + runtime.paired(777, "ctx-a"); + runtime.recovered("ctx-b"); + assert.deepEqual(notices, [ + "info|ctx-a|Telegram paired with user 777", + "info|ctx-b|Telegram reconnected", + ]); +}); + +test("Delivery failures explain the reason and pick a severity from it", () => { + const harness = createNoticeHarness(); + harness.runtime.deliveryFailed( + new Error("Telegram API sendMessage failed: HTTP 429: Too Many Requests: retry after 12"), + ); + harness.advance(TELEGRAM_DELIVERY_FAILED_NOTICE_COOLDOWN_MS); + harness.runtime.deliveryFailed(new Error("Unable to connect. Is the computer able to access the url?")); + harness.advance(TELEGRAM_DELIVERY_FAILED_NOTICE_COOLDOWN_MS); + harness.runtime.deliveryFailed(new Error("Telegram API sendMessage failed: HTTP 401: Unauthorized")); + harness.advance(TELEGRAM_DELIVERY_FAILED_NOTICE_COOLDOWN_MS); + harness.runtime.deliveryFailed(new Error("message is too long")); + harness.advance(TELEGRAM_DELIVERY_FAILED_NOTICE_COOLDOWN_MS); + harness.runtime.deliveryFailed( + new Error("Telegram API sendRichMessage may have committed before transport failed."), + ); + assert.deepEqual(harness.notices, [ + "warning|session|Telegram: reply not delivered (rate limited 12s)", + "warning|session|Telegram: reply not delivered (offline)", + "error|session|Telegram: reply not delivered (invalid token)", + "error|session|Telegram: reply not delivered (message is too long)", + "warning|session|Telegram: reply delivery unconfirmed (check the chat)", + ]); +}); + +test("Delivery failure notices are throttled and need a live context", () => { + const harness = createNoticeHarness(); + harness.runtime.deliveryFailed(new Error("fetch failed")); + harness.advance(TELEGRAM_DELIVERY_FAILED_NOTICE_COOLDOWN_MS - 1); + harness.runtime.deliveryFailed(new Error("fetch failed")); + assert.equal(harness.notices.length, 1); + harness.advance(1); + harness.runtime.deliveryFailed(new Error("fetch failed")); + assert.equal(harness.notices.length, 2); + harness.advance(TELEGRAM_DELIVERY_FAILED_NOTICE_COOLDOWN_MS); + harness.dropContext(); + harness.runtime.deliveryFailed(new Error("fetch failed")); + assert.equal(harness.notices.length, 2); +}); + +test("A failing toast is recorded and never reaches the caller", () => { + const harness = createNoticeHarness({ notifyThrows: true }); + assert.doesNotThrow(() => { + harness.runtime.paired(777, "ctx"); + harness.runtime.recovered("ctx"); + harness.runtime.deliveryFailed(new Error("fetch failed")); + }); + assert.deepEqual(harness.recorded, [ + "status:stale ctx:notice", + "status:stale ctx:notice", + "status:stale ctx:notice", + ]); +}); + +test("Delivery failure notices carry no control sequences from raw error text", () => { + const harness = createNoticeHarness(); + harness.runtime.deliveryFailed(new Error("bad\u001b]0;pwned\u0007\u001b[31m text")); + assert.equal(harness.notices.length, 1); + assert.doesNotMatch(harness.notices[0], /[\u0000-\u001f\u007f-\u009f]/); +}); diff --git a/tests/traffic.test.ts b/tests/traffic.test.ts new file mode 100644 index 0000000..bc01cc7 --- /dev/null +++ b/tests/traffic.test.ts @@ -0,0 +1,163 @@ +/** + * Regression tests for the Telegram traffic counters + * Covers what counts as a message, last-activity tracking, failed deliveries, and the /telegram-status line. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildTelegramBridgeStatusLines } from "../lib/status.ts"; +import { + createTelegramTrafficCounters, + createTelegramTrafficStatusBridge, + isTelegramOutboundMessageMethod, +} from "../lib/traffic.ts"; + +test("Traffic counters count every direction and remember the latest message", () => { + let nowMs = 1_000; + const counters = createTelegramTrafficCounters(() => nowMs); + assert.deepEqual(counters.snapshot(), { received: 0, sent: 0, failed: 0 }); + counters.recordReceived(); + nowMs = 2_000; + counters.recordSent(); + counters.recordSent(); + nowMs = 3_000; + counters.recordFailed(); + assert.deepEqual(counters.snapshot(), { + received: 1, + sent: 2, + failed: 1, + lastAtMs: 2_000, + }); +}); + +test("Only calls that deliver a readable message count as sent", () => { + for (const method of ["sendMessage", "sendRichMessage", "sendPhoto", "sendDocument", "sendVoice"]) { + assert.equal(isTelegramOutboundMessageMethod(method), true, method); + } + for (const method of [ + "sendChatAction", + "sendMessageDraft", + "editMessageText", + "setMessageReaction", + "answerCallbackQuery", + "getUpdates", + "toString", + ]) { + assert.equal(isTelegramOutboundMessageMethod(method), false, method); + } +}); + +test("Status lines report the message counters and the time of the last one", () => { + const baseState = { + botUsername: "demo_bot", + allowedUserId: 7, + pollingActive: true, + pendingDispatch: false, + compactionInProgress: false, + activeToolExecutions: 0, + pendingModelSwitch: false, + queuedItems: [], + recentRuntimeEvents: [], + }; + const lastAtMs = new Date(2026, 9, 1, 14, 32).getTime(); + const clean = buildTelegramBridgeStatusLines({ + ...baseState, + traffic: { received: 12, sent: 11, failed: 0, lastAtMs }, + }); + assert.ok( + clean.includes("- messages: 12 received, 11 sent, last 14:32"), + clean.join("\n"), + ); + const withFailures = buildTelegramBridgeStatusLines({ + ...baseState, + traffic: { received: 12, sent: 11, failed: 2, lastAtMs }, + }); + assert.ok( + withFailures.includes("- messages: 12 received, 11 sent, 2 failed, last 14:32"), + withFailures.join("\n"), + ); + const withoutTraffic = buildTelegramBridgeStatusLines(baseState); + assert.equal(withoutTraffic.some((line) => line.startsWith("- messages:")), false); +}); + +test("Traffic bridge counts delivered messages and refreshes the status with the current context", () => { + const counters = createTelegramTrafficCounters(); + const refreshed: string[] = []; + let context: string | undefined = "session-a"; + const bridge = createTelegramTrafficStatusBridge({ + counters, + getContext: () => context, + updateStatus: (ctx: string) => { + refreshed.push(ctx); + }, + recordRuntimeEvent: () => {}, + }); + bridge.onCallSucceeded("sendChatAction"); + bridge.onCallSucceeded("editMessageText"); + assert.deepEqual(counters.snapshot(), { received: 0, sent: 0, failed: 0 }); + assert.deepEqual(refreshed, []); + bridge.onCallSucceeded("sendRichMessage"); + assert.equal(counters.snapshot().sent, 1); + assert.deepEqual(refreshed, ["session-a"]); + context = undefined; + bridge.onCallSucceeded("sendMessage"); + assert.equal(counters.snapshot().sent, 2); + assert.deepEqual(refreshed, ["session-a"]); + bridge.onAuthorizedMessage("session-b"); + assert.equal(counters.snapshot().received, 1); + assert.deepEqual(refreshed, ["session-a", "session-b"]); +}); + +test("Traffic bridge counts undelivered messages, reports them and refreshes the status", () => { + const counters = createTelegramTrafficCounters(); + const refreshed: string[] = []; + const failures: string[] = []; + const bridge = createTelegramTrafficStatusBridge({ + counters, + getContext: () => "session", + updateStatus: (ctx: string) => { + refreshed.push(ctx); + }, + recordRuntimeEvent: () => {}, + onDeliveryFailed: (error) => { + failures.push((error as Error).message); + }, + }); + bridge.onCallFailed("getUpdates", new Error("poll failed")); + bridge.onCallFailed("sendChatAction", new Error("typing failed")); + assert.equal(counters.snapshot().failed, 0); + assert.deepEqual(failures, []); + assert.deepEqual(refreshed, []); + bridge.onCallFailed("sendMessage", new Error("HTTP 500")); + assert.equal(counters.snapshot().failed, 1); + assert.equal(counters.snapshot().sent, 0); + assert.deepEqual(failures, ["HTTP 500"]); + assert.deepEqual(refreshed, ["session"]); +}); + +test("Traffic bridge records a failed status refresh instead of breaking the message path", () => { + const counters = createTelegramTrafficCounters(); + const recorded: string[] = []; + const bridge = createTelegramTrafficStatusBridge({ + counters, + getContext: () => "session", + updateStatus: () => { + throw new Error("stale ctx"); + }, + recordRuntimeEvent: (category, error, details) => { + recorded.push(`${category}:${(error as Error).message}:${details?.phase}`); + }, + }); + bridge.onCallSucceeded("sendMessage"); + bridge.onAuthorizedMessage("session"); + bridge.onCallFailed("sendMessage", new Error("HTTP 500")); + assert.equal(counters.snapshot().received, 1); + assert.equal(counters.snapshot().sent, 1); + assert.equal(counters.snapshot().failed, 1); + assert.deepEqual(recorded, [ + "status:stale ctx:traffic-refresh", + "status:stale ctx:traffic-refresh", + "status:stale ctx:traffic-refresh", + ]); +});