Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97f2b723bb | ||
|
|
a56408711e |
No files matched your search
@@ -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<string, string>` 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<TelegramStatusBarState, "pollingActive">): 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.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Telegram OMP 17 status indicator
|
||||
|
||||
## Goal
|
||||
|
||||
Make the Telegram bridge visible in OMP 17's upper status bar, immediately after the cost segment, and correct the OMP installation guidance. Configure a replacement bot token only through the local interactive OMP setup flow.
|
||||
|
||||
## Scope
|
||||
|
||||
- Add a public OMP extension API for a status-bar segment placed after `cost`.
|
||||
- Have `@evandrodevbr/oh-my-pi-telegram` register one segment through that API.
|
||||
- Update the segment when bridge polling starts, stops, fails, or a session is replaced.
|
||||
- Update the plugin README with OMP 17 plugin commands and secure setup instructions.
|
||||
|
||||
## OMP core API
|
||||
|
||||
OMP owns status-bar ordering and rendering. The API accepts a keyed text segment and exposes only the supported placement:
|
||||
|
||||
- `after: "cost"`
|
||||
- a stable extension key
|
||||
- plain text supplied by the extension
|
||||
|
||||
OMP sanitizes extension text using the existing status-text sanitizer and renders registered segments after its own `cost` segment, before subsequent built-in segments. An extension clears its segment by passing `undefined`. Extension segments cannot choose arbitrary insertion positions or access the status-line component directly.
|
||||
|
||||
The implementation belongs in a versioned OMP source checkout or fork, not the global Bun installation, because `omp update` replaces global package files.
|
||||
|
||||
## Telegram bridge behavior
|
||||
|
||||
The plugin registers the `telegram` segment once the extension UI is available. It derives the value from the existing bridge status runtime:
|
||||
|
||||
| Runtime condition | Status-bar text |
|
||||
| --- | --- |
|
||||
| Telegram polling active | `Telegram: conectado` |
|
||||
| Telegram polling inactive | `Telegram: desconectado` |
|
||||
|
||||
The plugin updates the registered segment through its existing status update flow. This covers `/telegram-connect`, `/telegram-disconnect`, polling errors and recovery, lock ownership changes, and session lifecycle updates. It does not make a network request merely to render a status value.
|
||||
|
||||
## Configuration
|
||||
|
||||
The replacement token is configured only in an interactive local OMP session:
|
||||
|
||||
```text
|
||||
/telegram-setup
|
||||
/telegram-connect
|
||||
```
|
||||
|
||||
The token must not be put in chat, source code, README examples, committed configuration, test fixtures, logs, or terminal output. The plugin continues using its existing local configuration file and profile behavior.
|
||||
|
||||
## README
|
||||
|
||||
The primary OMP installation commands become:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
The README states that an OMP session must be restarted after installation or linking, then documents `/telegram-setup`, `/telegram-connect`, `/telegram-disconnect`, and `/telegram-status`.
|
||||
|
||||
## Verification
|
||||
|
||||
- OMP unit test: a registered extension segment is rendered immediately after `cost`; clearing it removes it; text is sanitized.
|
||||
- Telegram plugin test: active polling renders `Telegram: conectado`; inactive polling renders `Telegram: desconectado`; the current UI registration API is exercised.
|
||||
- Plugin type-check and public-package tests pass.
|
||||
- OMP 17 smoke test: the linked plugin loads and `/telegram-status` returns.
|
||||
- Manual local configuration: `/telegram-setup` validates the new bot token, `/telegram-connect` activates polling, and the status bar changes state.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No direct modification of global Bun-installed OMP files.
|
||||
- No status segment placement other than after `cost`.
|
||||
- No retry policy, network health probe, or separate Telegram connection abstraction.
|
||||
- No token migration or token disclosure.
|
||||
+104
@@ -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<TContext> {
|
||||
/** 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<string, unknown>,
|
||||
) => void;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface TelegramNoticeRuntime<TContext> {
|
||||
/** 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<TContext>(
|
||||
deps: TelegramNoticeRuntimeDeps<TContext>,
|
||||
): TelegramNoticeRuntime<TContext> {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
+134
@@ -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<Record<string, true>> = {
|
||||
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<TContext> {
|
||||
counters: TelegramTrafficCounters;
|
||||
/** Current session context, when the bridge has one. */
|
||||
getContext: () => TContext | undefined;
|
||||
updateStatus: (ctx: TContext) => void;
|
||||
recordRuntimeEvent: (
|
||||
category: string,
|
||||
error: unknown,
|
||||
details?: Record<string, unknown>,
|
||||
) => void;
|
||||
/** Told about every message delivery that failed, e.g. to raise a notice. */
|
||||
onDeliveryFailed?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface TelegramTrafficStatusBridge<TContext> {
|
||||
/** 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<TContext>(
|
||||
deps: TelegramTrafficStatusBridgeDeps<TContext>,
|
||||
): TelegramTrafficStatusBridge<TContext> {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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]/);
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
Reference in new issue
Block a user