Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d918d56dc3 |
No files matched your search
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **OMP status segment** — publishes `Telegram: conectado` or `Telegram: desconectado` immediately after OMP's `cost` segment through the public `setStatusSegment()` API.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **OMP compatibility** — require `@oh-my-pi/pi-coding-agent >=17.0.0`; README installation commands now use `omp plugin install` and `omp plugin link`.
|
||||||
|
|
||||||
### 0.22.0-evandro.1 — Evandro Fonseca Junior fork (oh-my-pi-telegram)
|
### 0.22.0-evandro.1 — Evandro Fonseca Junior fork (oh-my-pi-telegram)
|
||||||
|
|
||||||
- **Repackaged as `@evandrodevbr/oh-my-pi-telegram`** — version suffix `-evandro.1` keeps the 0.22.0 source line (SHA `afe09c5`) traceable to upstream `llblab/pi-telegram` while making the fork unambiguous in the `omp` extension registry.
|
- **Repackaged as `@evandrodevbr/oh-my-pi-telegram`** — version suffix `-evandro.1` keeps the 0.22.0 source line (SHA `afe09c5`) traceable to upstream `llblab/pi-telegram` while making the fork unambiguous in the `omp` extension registry.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ This fork is built and tested against:
|
|||||||
|
|
||||||
| Component | Version | Status |
|
| Component | Version | Status |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `can1357/oh-my-pi` (CLI: `omp`) | `>=16.5.2` (`@oh-my-pi/pi-coding-agent`) | ✅ verified |
|
| `can1357/oh-my-pi` (CLI: `omp`) | `>=17.0.0` (`@oh-my-pi/pi-coding-agent`) | ✅ verified |
|
||||||
| `@earendil-works/pi-{ai,agent-core,coding-agent}` | `>=0.80.6` | ✅ verified (peerDeps preserved from upstream) |
|
| `@earendil-works/pi-{ai,agent-core,coding-agent}` | `>=0.80.6` | ✅ verified (peerDeps preserved from upstream) |
|
||||||
| `node` | `>=22.19.0` | ✅ verified on Node 24.15.0 |
|
| `node` | `>=22.19.0` | ✅ verified on Node 24.15.0 |
|
||||||
| Telegram Bot API | current | ✅ unchanged from upstream |
|
| Telegram Bot API | current | ✅ unchanged from upstream |
|
||||||
@@ -23,13 +23,13 @@ This fork is built and tested against:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# From npm (after this fork is published):
|
# From npm (after this fork is published):
|
||||||
omp install npm:@evandrodevbr/oh-my-pi-telegram
|
omp plugin install npm:@evandrodevbr/oh-my-pi-telegram
|
||||||
|
|
||||||
# From git (always works):
|
# From git (always works):
|
||||||
omp install git:github.com/evandrodevbr/oh-my-pi-telegram
|
omp plugin install git:github.com/evandrodevbr/oh-my-pi-telegram
|
||||||
|
|
||||||
# From a local checkout (development):
|
# From a local checkout (development):
|
||||||
omp install --link /path/to/oh-my-pi-telegram
|
omp plugin link /path/to/oh-my-pi-telegram
|
||||||
```
|
```
|
||||||
|
|
||||||
Then inside `omp`:
|
Then inside `omp`:
|
||||||
|
|||||||
@@ -1,404 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -278,6 +278,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|||||||
Queue.TelegramQueueItem<Pi.ExtensionContext>
|
Queue.TelegramQueueItem<Pi.ExtensionContext>
|
||||||
>({
|
>({
|
||||||
getConfig: configStore.get,
|
getConfig: configStore.get,
|
||||||
|
setStatusSegment: Pi.setExtensionContextTopStatusSegment,
|
||||||
getActiveProfileName: configStore.getActiveProfileName,
|
getActiveProfileName: configStore.getActiveProfileName,
|
||||||
getDiagnosticPaths: Paths.getTelegramDiagnosticsDisplayPaths,
|
getDiagnosticPaths: Paths.getTelegramDiagnosticsDisplayPaths,
|
||||||
isPollingActive: Polling.createTelegramPollingActivityReader(
|
isPollingActive: Polling.createTelegramPollingActivityReader(
|
||||||
|
|||||||
-104
@@ -1,104 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -95,6 +95,23 @@ export function canStartPollingInExtensionContext(ctx: unknown): boolean {
|
|||||||
return !isExtensionContextPassiveRunMode(ctx);
|
return !isExtensionContextPassiveRunMode(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
|
||||||
export function formatPollingStartBlockedByRunMode(ctx: unknown): string {
|
export function formatPollingStartBlockedByRunMode(ctx: unknown): string {
|
||||||
const mode = getExtensionContextMode(ctx);
|
const mode = getExtensionContextMode(ctx);
|
||||||
return mode
|
return mode
|
||||||
|
|||||||
+24
-5
@@ -232,6 +232,14 @@ export interface TelegramStatusBarState {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTelegramTopStatusSegment(
|
||||||
|
state: Pick<TelegramStatusBarState, "pollingActive">,
|
||||||
|
): string {
|
||||||
|
return state.pollingActive
|
||||||
|
? "Telegram: conectado"
|
||||||
|
: "Telegram: desconectado";
|
||||||
|
}
|
||||||
|
|
||||||
export interface TelegramStatusRuntimeContext {
|
export interface TelegramStatusRuntimeContext {
|
||||||
ui: {
|
ui: {
|
||||||
theme: TelegramStatusBarTheme;
|
theme: TelegramStatusBarTheme;
|
||||||
@@ -245,6 +253,7 @@ export interface TelegramStatusRuntimeDeps<
|
|||||||
statusKey?: string;
|
statusKey?: string;
|
||||||
getStatusBarState: (ctx: TContext, error?: string) => TelegramStatusBarState;
|
getStatusBarState: (ctx: TContext, error?: string) => TelegramStatusBarState;
|
||||||
getBridgeStatusLineState: () => TelegramBridgeStatusLineState;
|
getBridgeStatusLineState: () => TelegramBridgeStatusLineState;
|
||||||
|
setStatusSegment?: (ctx: TContext, key: string, text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TelegramBridgeStatusConfig {
|
export interface TelegramBridgeStatusConfig {
|
||||||
@@ -255,6 +264,7 @@ export interface TelegramBridgeStatusConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TelegramBridgeStatusRuntimeDeps<
|
export interface TelegramBridgeStatusRuntimeDeps<
|
||||||
|
TContext extends TelegramStatusRuntimeContext,
|
||||||
TQueueItem extends { queueLane: TelegramStatusQueueLane },
|
TQueueItem extends { queueLane: TelegramStatusQueueLane },
|
||||||
> {
|
> {
|
||||||
statusKey?: string;
|
statusKey?: string;
|
||||||
@@ -298,6 +308,11 @@ export interface TelegramBridgeStatusRuntimeDeps<
|
|||||||
getInstanceSlot?: () => string | undefined;
|
getInstanceSlot?: () => string | undefined;
|
||||||
getInstanceThreadName?: () => string | undefined;
|
getInstanceThreadName?: () => string | undefined;
|
||||||
getNowMs?: () => number;
|
getNowMs?: () => number;
|
||||||
|
setStatusSegment?: (
|
||||||
|
ctx: TContext,
|
||||||
|
key: string,
|
||||||
|
text: string,
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TelegramBridgeStatusLineOptions {
|
export interface TelegramBridgeStatusLineOptions {
|
||||||
@@ -565,12 +580,15 @@ export function createTelegramStatusRuntime<
|
|||||||
const statusKey = deps.statusKey ?? "telegram";
|
const statusKey = deps.statusKey ?? "telegram";
|
||||||
return {
|
return {
|
||||||
updateStatus: (ctx, error) => {
|
updateStatus: (ctx, error) => {
|
||||||
|
const state = deps.getStatusBarState(ctx, error);
|
||||||
ctx.ui.setStatus(
|
ctx.ui.setStatus(
|
||||||
statusKey,
|
statusKey,
|
||||||
buildTelegramStatusBarText(
|
buildTelegramStatusBarText(ctx.ui.theme, state),
|
||||||
ctx.ui.theme,
|
);
|
||||||
deps.getStatusBarState(ctx, error),
|
deps.setStatusSegment?.(
|
||||||
),
|
ctx,
|
||||||
|
statusKey,
|
||||||
|
getTelegramTopStatusSegment(state),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
getStatusLines: (options) =>
|
getStatusLines: (options) =>
|
||||||
@@ -583,10 +601,11 @@ export function createTelegramBridgeStatusRuntime<
|
|||||||
TContext extends TelegramStatusRuntimeContext,
|
TContext extends TelegramStatusRuntimeContext,
|
||||||
TQueueItem extends { queueLane: TelegramStatusQueueLane },
|
TQueueItem extends { queueLane: TelegramStatusQueueLane },
|
||||||
>(
|
>(
|
||||||
deps: TelegramBridgeStatusRuntimeDeps<TQueueItem>,
|
deps: TelegramBridgeStatusRuntimeDeps<TContext, TQueueItem>,
|
||||||
): TelegramStatusRuntime<TContext> {
|
): TelegramStatusRuntime<TContext> {
|
||||||
return createTelegramStatusRuntime({
|
return createTelegramStatusRuntime({
|
||||||
statusKey: deps.statusKey,
|
statusKey: deps.statusKey,
|
||||||
|
setStatusSegment: deps.setStatusSegment,
|
||||||
getStatusBarState: (_ctx, error) => {
|
getStatusBarState: (_ctx, error) => {
|
||||||
const config = deps.getConfig();
|
const config = deps.getConfig();
|
||||||
const queuedItems = deps.getQueuedItems();
|
const queuedItems = deps.getQueuedItems();
|
||||||
|
|||||||
-134
@@ -1,134 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+1
-1
@@ -67,7 +67,7 @@
|
|||||||
"./index.ts"
|
"./index.ts"
|
||||||
],
|
],
|
||||||
"compatibleWith": {
|
"compatibleWith": {
|
||||||
"@oh-my-pi/pi-coding-agent": ">=16.5.2"
|
"@oh-my-pi/pi-coding-agent": ">=17.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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]/);
|
|
||||||
});
|
|
||||||
@@ -100,7 +100,7 @@ test("Package declares the OMP extension entry point and Pi lifecycle compatibil
|
|||||||
assert.deepEqual(packageJson.omp?.extensions, ["./index.ts"]);
|
assert.deepEqual(packageJson.omp?.extensions, ["./index.ts"]);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
packageJson.omp?.compatibleWith?.["@oh-my-pi/pi-coding-agent"],
|
packageJson.omp?.compatibleWith?.["@oh-my-pi/pi-coding-agent"],
|
||||||
">=16.5.2",
|
">=17.0.0",
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
packageJson.peerDependencies?.["@earendil-works/pi-coding-agent"],
|
packageJson.peerDependencies?.["@earendil-works/pi-coding-agent"],
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
createTelegramStatusHtmlBuilder,
|
createTelegramStatusHtmlBuilder,
|
||||||
createTelegramStatusSnapshot,
|
createTelegramStatusSnapshot,
|
||||||
createTelegramStatusRuntime,
|
createTelegramStatusRuntime,
|
||||||
|
getTelegramTopStatusSegment,
|
||||||
getTelegramStatusBarProcessingStatus,
|
getTelegramStatusBarProcessingStatus,
|
||||||
recordStructuredTelegramRuntimeEvent,
|
recordStructuredTelegramRuntimeEvent,
|
||||||
recordTelegramRuntimeEvent,
|
recordTelegramRuntimeEvent,
|
||||||
@@ -401,6 +402,56 @@ test("Status runtime updates the status bar and exposes bridge lines", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Status runtime publishes the polling state after cost", () => {
|
||||||
|
const published: Array<[string, string, { placement: "after-cost" }]> = [];
|
||||||
|
let pollingActive = true;
|
||||||
|
const runtime = createTelegramBridgeStatusRuntime({
|
||||||
|
getConfig: () => ({ botToken: "token", allowedUserId: 7 }),
|
||||||
|
isPollingActive: () => pollingActive,
|
||||||
|
getActiveSourceMessageIds: () => undefined,
|
||||||
|
hasActiveTurn: () => false,
|
||||||
|
hasDispatchPending: () => false,
|
||||||
|
isCompactionInProgress: () => false,
|
||||||
|
getActiveToolExecutions: () => 0,
|
||||||
|
hasPendingModelSwitch: () => false,
|
||||||
|
getQueuedItems: () => [],
|
||||||
|
formatQueuedStatus: () => "",
|
||||||
|
getRecentRuntimeEvents: () => [],
|
||||||
|
setStatusSegment: (_ctx, key, text) => {
|
||||||
|
published.push([key, text, { placement: "after-cost" }]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ctx = {
|
||||||
|
ui: {
|
||||||
|
theme: { fg: (_token: string, text: string) => text },
|
||||||
|
setStatus: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
getTelegramTopStatusSegment({ pollingActive: true }),
|
||||||
|
"Telegram: conectado",
|
||||||
|
);
|
||||||
|
runtime.updateStatus(ctx);
|
||||||
|
assert.deepEqual(published.pop(), [
|
||||||
|
"telegram",
|
||||||
|
"Telegram: conectado",
|
||||||
|
{ placement: "after-cost" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
pollingActive = false;
|
||||||
|
assert.equal(
|
||||||
|
getTelegramTopStatusSegment({ pollingActive: false }),
|
||||||
|
"Telegram: desconectado",
|
||||||
|
);
|
||||||
|
runtime.updateStatus(ctx);
|
||||||
|
assert.deepEqual(published.pop(), [
|
||||||
|
"telegram",
|
||||||
|
"Telegram: desconectado",
|
||||||
|
{ placement: "after-cost" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("Status lines expose thread reconciliation state", () => {
|
test("Status lines expose thread reconciliation state", () => {
|
||||||
const lines = buildTelegramBridgeStatusLines({
|
const lines = buildTelegramBridgeStatusLines({
|
||||||
botUsername: "demo_bot",
|
botUsername: "demo_bot",
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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