Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58b0f7777a | ||
|
|
5ac99d4584 | ||
|
|
6a6e75fe32 | ||
|
|
bf4a2021e8 | ||
|
|
6f7e266a6e | ||
|
|
057281a3bb | ||
|
|
9435ff709b | ||
|
|
ca05b58f38 | ||
|
|
60254b288b | ||
|
|
033a92f3bb |
No files matched your search
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- `Status Errors`: Failures of the poll loop and of message delivery are classified into a short state with the next step (`invalid token · /telegram-setup`, `rate limited 12s · retrying`, `another poller is active · /telegram-status`, `Telegram unavailable`, `offline · retrying`); unrecognized messages stay verbatim, sanitized and trimmed to 100 characters. A non-idempotent send that hit a transport failure after the request left is reported as `delivery unconfirmed` instead of a definite failure. Impact: the status says what to do instead of showing raw runtime text.
|
||||||
|
- `Status Persistence`: The poll loop cleared the error status after its retry sleep, before the next attempt, so a slow retry (a 12 s rate-limit wait) showed as `connected`. The reset now happens after the next successful `getUpdates`, and recovery is reported only when `getUpdates` itself had failed, not when an update handler did. Impact: an error stays visible until polling works again.
|
||||||
|
- `Delivery Counters`: `lib/traffic.ts` counts undelivered messages (`recordFailed`, `onCallFailed`) and the status shows `✘N` next to the sent counter; `/telegram-status` adds `N failed`. `TelegramBridgeApiRuntime` gains `onCallFailed` beside `onCallSucceeded`.
|
||||||
|
- `Operator Notices`: New leaf-adjacent domain `lib/notices.ts` raises host toasts for owner paired (`onPaired` on the pairing runtime), connection recovered (`onRecovered` on the poll loop) and reply not delivered (throttled to one per 30 s). A failing toast is recorded as a runtime event and never reaches the send, pairing or polling path.
|
||||||
|
- `Queue Preview`: With turns waiting, the TUI widget adds a second line with the next two summaries in dispatch order (`Queue.formatQueuedTelegramItemsPreview`) and a remainder count; the plain `setStatus` entry keeps one line.
|
||||||
|
- `Working Message`: While a Telegram-originated turn runs, the omp spinner reads `Answering Telegram · "<prompt>"` and is restored when the turn settles. `setExtensionWorkingMessage` skips hosts without `ui.setWorkingMessage`, because the text is cosmetic.
|
||||||
|
- `Widget Sanitization`: Widget lines bypass the host's status sanitizer, so `sanitizeTelegramStatusText` strips control characters and bidi overrides from every Telegram-controlled string (thread name, bot name, queued prompts, error text) before it reaches the terminal.
|
||||||
|
- `Status Line`: The line under the omp editor now shows a state glyph, the bot name, received/sent message counters and the time of the last message (`telegram ● connected · @bot · ⤵12 ⤴11 · ⏱ 14:32`), plus the next step for non-working states (`/telegram-setup`, `send /start to @bot`, `/telegram-connect`). Glyphs follow omp's `symbolPreset`. omp strips ANSI from extension status text, so in the TUI the line is rendered as a `belowEditor` widget that keeps the theme colors; other modes keep `setStatus`. Counters come from the new leaf domain `lib/traffic.ts`: received on every owner message, sent on every successful `sendMessage`/`sendRichMessage`/media call, and `/telegram-status` gains a `- messages:` line. Impact: connection state, traffic and recovery steps are visible without opening `/telegram-status`.
|
||||||
|
- `Pairing`: The poll loop persisted the config object it captured when polling started. The store swaps in a fresh object on every persist, so after the initial sync that stale copy lacked later changes and the next offset commit erased the paired owner: `telegram.json` never kept `allowedUserId`, the bridge re-paired on every message and the status flipped back to `awaiting pairing`. The runner now carries only the poll offset onto the store's current config. Impact: first-time pairing sticks, and another Telegram user can no longer be paired by writing before the owner's next message.
|
||||||
- `omp Agent Directory`: Resolve the agent directory from the host's own `getAgentDir()` (registered by the composition root via `setHostAgentDirResolver`) instead of sniffing `execPath`/`argv[1]` for an `omp` prefix. omp installed through Bun runs as `bun …/@oh-my-pi/pi-coding-agent/dist/cli.js`, which matched neither, so with `telegram.json` in `~/.omp/agent` the bridge never connected and wrote its log and bus socket under `~/.pi/agent`. `PI_CODING_AGENT_DIR` still wins; with no host registered the fallback stays `~/.pi/agent`. Impact: a bot token saved by `/telegram-setup` lives in omp's own agent directory and is found again. `lib/paths.ts` stays free of Pi SDK imports, which cost about a second of startup per importing process.
|
- `omp Agent Directory`: Resolve the agent directory from the host's own `getAgentDir()` (registered by the composition root via `setHostAgentDirResolver`) instead of sniffing `execPath`/`argv[1]` for an `omp` prefix. omp installed through Bun runs as `bun …/@oh-my-pi/pi-coding-agent/dist/cli.js`, which matched neither, so with `telegram.json` in `~/.omp/agent` the bridge never connected and wrote its log and bus socket under `~/.pi/agent`. `PI_CODING_AGENT_DIR` still wins; with no host registered the fallback stays `~/.pi/agent`. Impact: a bot token saved by `/telegram-setup` lives in omp's own agent directory and is found again. `lib/paths.ts` stays free of Pi SDK imports, which cost about a second of startup per importing process.
|
||||||
- `omp Settled Boundary`: omp never emits `agent_settled`, the only place the activity runtime finalized a turn. On omp the first terminal `agent_end` (no `willContinue: true`, which omp sets for retries, compaction continuations, todo/plan reminders and async wakes) now drives `onAgentSettled`; hosts that emit `agent_settled` natively are unchanged. Impact: Activity API consumers receive `agent-settled` and the activity id no longer leaks across turns.
|
- `omp Settled Boundary`: omp never emits `agent_settled`, the only place the activity runtime finalized a turn. On omp the first terminal `agent_end` (no `willContinue: true`, which omp sets for retries, compaction continuations, todo/plan reminders and async wakes) now drives `onAgentSettled`; hosts that emit `agent_settled` natively are unchanged. Impact: Activity API consumers receive `agent-settled` and the activity id no longer leaks across turns.
|
||||||
- `omp Current Model`: omp never emits `model_select`, so the stored current model went stale after a local `/model` switch, and omp builds command contexts by spreading a base context, which freezes `ctx.model` at the moment of `/telegram-connect`. On omp the live `ctx.models.current()` now wins over both, and the stored value no longer overrides it. Impact: the Telegram model menu marks the model omp is actually using.
|
- `omp Current Model`: omp never emits `model_select`, so the stored current model went stale after a local `/model` switch, and omp builds command contexts by spreading a base context, which freezes `ctx.model` at the moment of `/telegram-connect`. On omp the live `ctx.models.current()` now wins over both, and the stored value no longer overrides it. Impact: the Telegram model menu marks the model omp is actually using.
|
||||||
|
|||||||
@@ -130,6 +130,36 @@ Config is written to `<agent-dir>/telegram.json`. The agent directory is `PI_COD
|
|||||||
|
|
||||||
Profile names: lowercase ASCII letters and digits, up to 32 characters; `default`, `main` and `active` are reserved. Profiles keep isolated polling, diagnostics, Threaded Mode state and local bus transport.
|
Profile names: lowercase ASCII letters and digits, up to 32 characters; `default`, `main` and `active` are reserved. Profiles keep isolated polling, diagnostics, Threaded Mode state and local bus transport.
|
||||||
|
|
||||||
|
### Status line
|
||||||
|
|
||||||
|
The bridge keeps one live line under the `omp` editor. In the interactive TUI it is a themed widget (`omp` strips color from plain extension status text, so the widget is what keeps the theme colors); in RPC, JSON and print modes it falls back to a plain status entry. The glyphs follow `omp`'s `symbolPreset` (`unicode`, `nerd` or `ascii`).
|
||||||
|
|
||||||
|
```text
|
||||||
|
telegram ● connected · @my_bot · ⤵12 ⤴11 · ⏱ 14:32
|
||||||
|
telegram ⟳ active +3 · @my_bot · ⤵14 ⤴11 · ⏱ 14:33
|
||||||
|
next: "second prompt" · "third prompt" · +1 more
|
||||||
|
telegram ● connected · @my_bot · ⤵12 ⤴10 ✘2 · ⏱ 14:32
|
||||||
|
telegram ⚠ awaiting pairing · send /start to @my_bot
|
||||||
|
telegram ○ disconnected · /telegram-connect
|
||||||
|
telegram ⦸ not configured · /telegram-setup
|
||||||
|
telegram ✘ invalid token · /telegram-setup
|
||||||
|
telegram ⟳ rate limited 12s · retrying
|
||||||
|
telegram ⟳ offline · retrying
|
||||||
|
telegram ⚠ another poller is active · /telegram-status
|
||||||
|
telegram ✘ error <unrecognized message>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `⤵N` counts messages received from the paired owner and `⤴N` counts messages the bridge delivered to Telegram (replies, menus and attachments; typing indicators, drafts and edits are not counted). `✘N` appears only when some deliveries failed or could not be confirmed. `⏱` is the time of the latest message. Counters live in memory, start at zero when the extension loads, and are shown only while the bridge is up.
|
||||||
|
- The state word is the same ladder as before: `not configured`, `awaiting pairing`, `electing`, `disconnected`, then `active` while a turn runs or is queued, and `connected`, `leader` or `follower` otherwise.
|
||||||
|
- While turns wait, a second line previews the next two in dispatch order and how many more follow.
|
||||||
|
- Transport and API failures are classified into a short state with the next step: HTTP 401 (`invalid token`), 429 (`rate limited`), 409 (`another poller is active`), 5xx (`Telegram unavailable`) and network errors (`offline`). Anything unrecognized stays visible verbatim, trimmed to 100 characters.
|
||||||
|
- An error stays on screen until a poll succeeds again, so a slow retry is never shown as healthy. The API client waits out a rate limit itself, so `rate limited` appears once those waits are exhausted, not at the first 429.
|
||||||
|
- Toasts: `Telegram paired with user <id>`, `Telegram reconnected`, and `Telegram: reply not delivered (<reason>)`. When a send hit a transport failure after the request left, the outcome is unknown and the toast says `reply delivery unconfirmed (check the chat)`. Delivery failure toasts are throttled to one per 30 seconds.
|
||||||
|
- While a turn that arrived from Telegram runs, the `omp` spinner reads `Answering Telegram · "<prompt>"`.
|
||||||
|
- Widget lines bypass the host's text sanitizer, so every Telegram-controlled string shown there (thread name, bot name, queued prompts, error text) is stripped of control characters and bidi overrides.
|
||||||
|
- Messages a follower instance sends through the leader are counted by the leader process, not by the follower.
|
||||||
|
- `/telegram-status` repeats the counters as a `- messages:` line.
|
||||||
|
|
||||||
### Tools available to the agent
|
### Tools available to the agent
|
||||||
|
|
||||||
| Tool | Purpose |
|
| Tool | Purpose |
|
||||||
@@ -172,7 +202,7 @@ There is no build step: `omp` loads the TypeScript sources directly, and `npm pa
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run validate # the gate: typecheck + tests + audit + pack:check
|
npm run validate # the gate: typecheck + tests + audit + pack:check
|
||||||
npm run pack:check # npm pack --dry-run, 87 files, no secrets
|
npm run pack:check # npm pack --dry-run, 89 files, no secrets
|
||||||
```
|
```
|
||||||
|
|
||||||
Release flow: bump `package.json` version, add a matching `## <version>` section to `CHANGELOG.md`, then push the tag.
|
Release flow: bump `package.json` version, add a matching `## <version>` section to `CHANGELOG.md`, then push the tag.
|
||||||
@@ -189,9 +219,9 @@ git tag v0.22.0-evandro.2 && git push origin v0.22.0-evandro.2
|
|||||||
```
|
```
|
||||||
index.ts extension entrypoint: composition root and runtime wiring
|
index.ts extension entrypoint: composition root and runtime wiring
|
||||||
api/ public package subpaths (inbound, outbound, delivery, ...)
|
api/ public package subpaths (inbound, outbound, delivery, ...)
|
||||||
lib/ 54 domain modules: config, paths, polling, queue, routing,
|
lib/ 56 domain modules: config, paths, polling, queue, routing,
|
||||||
rendering, outbound, bus leader/follower, locks, voice, menus
|
rendering, outbound, bus leader/follower, locks, voice, menus
|
||||||
tests/ 55 node:test suites (unit + integration, no network)
|
tests/ 57 node:test suites (unit + integration, no network)
|
||||||
docs/ architecture and per-surface contracts
|
docs/ architecture and per-surface contracts
|
||||||
.agents/skills/ project-local agent skills (Telegram Bot API reference, domain DAG)
|
.agents/skills/ project-local agent skills (Telegram Bot API reference, domain DAG)
|
||||||
AGENTS.md engineering and runtime conventions (project context, not docs)
|
AGENTS.md engineering and runtime conventions (project context, not docs)
|
||||||
@@ -208,7 +238,7 @@ Everything below was executed in this repository on Node 24.20.0 / npm 11.19.0 (
|
|||||||
| Command | Result |
|
| Command | Result |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `npm ci` | exit 0, lockfile consistent, `found 0 vulnerabilities` |
|
| `npm ci` | exit 0, lockfile consistent, `found 0 vulnerabilities` |
|
||||||
| `npm run typecheck` | exit 0, `tsc --noEmit` |
|
| `npm run typecheck` | exit 0, `tsc --noEmit` (TypeScript pinned to 7.0.2 in `devDependencies`) |
|
||||||
| `npm test` | exit 0, `tests 1230 / pass 1229 / fail 0 / skipped 1` (the skip is a Windows-only case) |
|
| `npm test` | exit 0, `tests 1230 / pass 1229 / fail 0 / skipped 1` (the skip is a Windows-only case) |
|
||||||
| `npm run audit` | exit 0, `found 0 vulnerabilities` |
|
| `npm run audit` | exit 0, `found 0 vulnerabilities` |
|
||||||
| `npm run pack:check` | exit 0, tarball `evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz` |
|
| `npm run pack:check` | exit 0, tarball `evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz` |
|
||||||
@@ -226,7 +256,8 @@ The test suite is deterministic and offline: transport calls are injected, so no
|
|||||||
- **Not published to npm**: `@evandrodevbr/oh-my-pi-telegram` returns 404 on the registry. Install from a local checkout; on top of that, `omp` 18.1.20's plugin source resolver rejects `npm` sources (`npm plugin sources are not yet supported. Use git-based sources instead.`).
|
- **Not published to npm**: `@evandrodevbr/oh-my-pi-telegram` returns 404 on the registry. Install from a local checkout; on top of that, `omp` 18.1.20's plugin source resolver rejects `npm` sources (`npm plugin sources are not yet supported. Use git-based sources instead.`).
|
||||||
- **No GitHub release yet**: the only tag, `v0.22.0-evandro.1`, produced a failed `Release` run (`CHANGELOG.md has no section for 0.22.0-evandro.1`, because the fork section was a level-3 heading). The heading is fixed in this commit, but the existing tag still points at the pre-fix commit, so the release must be created manually or the tag moved.
|
- **No GitHub release yet**: the only tag, `v0.22.0-evandro.1`, produced a failed `Release` run (`CHANGELOG.md has no section for 0.22.0-evandro.1`, because the fork section was a level-3 heading). The heading is fixed in this commit, but the existing tag still points at the pre-fix commit, so the release must be created manually or the tag moved.
|
||||||
- **Fork lags upstream**: this fork stays on upstream `0.22.0` (SHA `afe09c5`), while `@llblab/pi-telegram` has since published 0.51.6. Upstream changes after 0.22.0 are not included. Upstream 0.51.6 loads in `omp` 18.4.8 but subscribes to five events `omp` never emits (`agent_settled`, `model_select`, `session_compact_failed`, `ui_prompt_start`, `ui_prompt_end`), so it is not a drop-in replacement.
|
- **Fork lags upstream**: this fork stays on upstream `0.22.0` (SHA `afe09c5`), while `@llblab/pi-telegram` has since published 0.51.6. Upstream changes after 0.22.0 are not included. Upstream 0.51.6 loads in `omp` 18.4.8 but subscribes to five events `omp` never emits (`agent_settled`, `model_select`, `session_compact_failed`, `ui_prompt_start`, `ui_prompt_end`), so it is not a drop-in replacement.
|
||||||
- **Verified on one platform only**: Linux with Node 24.20.0. The suite contains Windows-specific cases (and the single skipped test is one of them), but Windows/macOS were not run here.
|
- **Verified on Linux (Node 24.20.0) and, partly, Windows (Node 26.7.0)**. On Windows `npm test` currently reports about 57 failures out of roughly 1270 tests: Unix-domain-socket bus tests (`listen EACCES` on `.sock` paths) and POSIX path assertions (`\tmp` vs `/tmp`). Typecheck, audit and pack:check pass there. macOS was not run.
|
||||||
|
- **Model menu on `omp`**: `settingsManager.reload is not a function` can break the `/start` and model menus on hosts whose settings object lacks `reload` ([issue #1](https://github.com/evandrodevbr/oh-my-pi-telegram/issues/1)); a fix is proposed in [PR #2](https://github.com/evandrodevbr/oh-my-pi-telegram/pull/2).
|
||||||
- **Requires a live `omp` instance and a real bot token** for end-to-end use; the repository suite does not cover the Telegram API surface, and the `omp` 18.4.8 checks above used a fake Bot API, so live Telegram behavior (Rich Messages, Threaded Mode, voice, files) is unverified on `omp`.
|
- **Requires a live `omp` instance and a real bot token** for end-to-end use; the repository suite does not cover the Telegram API surface, and the `omp` 18.4.8 checks above used a fake Bot API, so live Telegram behavior (Rich Messages, Threaded Mode, voice, files) is unverified on `omp`.
|
||||||
- **LLM/token cost applies**: a Telegram prompt is a normal model turn in the active session, so it inherits that session's post-compaction context.
|
- **LLM/token cost applies**: a Telegram prompt is a normal model turn in the active session, so it inherits that session's post-compaction context.
|
||||||
- **Single-owner by design**: the first Telegram user to message the bot is the owner; other users are ignored. No multi-user or group-chat support.
|
- **Single-owner by design**: the first Telegram user to message the bot is the owner; other users are ignored. No multi-user or group-chat support.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import * as MenuQueue from "./lib/menu-queue.ts";
|
|||||||
import * as MenuSettings from "./lib/menu-settings.ts";
|
import * as MenuSettings from "./lib/menu-settings.ts";
|
||||||
import * as Menu from "./lib/menu.ts";
|
import * as Menu from "./lib/menu.ts";
|
||||||
import * as Model from "./lib/model.ts";
|
import * as Model from "./lib/model.ts";
|
||||||
|
import * as Notices from "./lib/notices.ts";
|
||||||
import * as Outbound from "./lib/outbound.ts";
|
import * as Outbound from "./lib/outbound.ts";
|
||||||
import * as Ownership from "./lib/ownership.ts";
|
import * as Ownership from "./lib/ownership.ts";
|
||||||
import * as Paths from "./lib/paths.ts";
|
import * as Paths from "./lib/paths.ts";
|
||||||
@@ -43,6 +44,7 @@ import * as TelegramApi from "./lib/telegram-api.ts";
|
|||||||
import * as TextGroups from "./lib/text-groups.ts";
|
import * as TextGroups from "./lib/text-groups.ts";
|
||||||
import * as ThreadReconciler from "./lib/thread-reconciler.ts";
|
import * as ThreadReconciler from "./lib/thread-reconciler.ts";
|
||||||
import * as TimeInjection from "./lib/time-injection.ts";
|
import * as TimeInjection from "./lib/time-injection.ts";
|
||||||
|
import * as Traffic from "./lib/traffic.ts";
|
||||||
import * as Updates from "./lib/updates.ts";
|
import * as Updates from "./lib/updates.ts";
|
||||||
import * as Voice from "./lib/voice.ts";
|
import * as Voice from "./lib/voice.ts";
|
||||||
|
|
||||||
@@ -274,11 +276,13 @@ export default function (pi: Pi.ExtensionAPI) {
|
|||||||
getFollowerThreadName: telegramBusFollowerRegistrationState.getThreadName,
|
getFollowerThreadName: telegramBusFollowerRegistrationState.getThreadName,
|
||||||
getCurrentIdentity: getCurrentInstanceThreadIdentity,
|
getCurrentIdentity: getCurrentInstanceThreadIdentity,
|
||||||
});
|
});
|
||||||
|
const trafficCounters = Traffic.createTelegramTrafficCounters();
|
||||||
const statusRuntime = Status.createTelegramBridgeStatusRuntime<
|
const statusRuntime = Status.createTelegramBridgeStatusRuntime<
|
||||||
Pi.ExtensionContext,
|
Pi.ExtensionContext,
|
||||||
Queue.TelegramQueueItem<Pi.ExtensionContext>
|
Queue.TelegramQueueItem<Pi.ExtensionContext>
|
||||||
>({
|
>({
|
||||||
getConfig: configStore.get,
|
getConfig: configStore.get,
|
||||||
|
getTraffic: trafficCounters.snapshot,
|
||||||
getActiveProfileName: configStore.getActiveProfileName,
|
getActiveProfileName: configStore.getActiveProfileName,
|
||||||
getDiagnosticPaths: Paths.getTelegramDiagnosticsDisplayPaths,
|
getDiagnosticPaths: Paths.getTelegramDiagnosticsDisplayPaths,
|
||||||
isPollingActive: Polling.createTelegramPollingActivityReader(
|
isPollingActive: Polling.createTelegramPollingActivityReader(
|
||||||
@@ -292,6 +296,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|||||||
hasPendingModelSwitch: pendingModelSwitchStore.has,
|
hasPendingModelSwitch: pendingModelSwitchStore.has,
|
||||||
getQueuedItems: telegramQueueStore.getQueuedItems,
|
getQueuedItems: telegramQueueStore.getQueuedItems,
|
||||||
formatQueuedStatus: Queue.formatQueuedTelegramItemsStatus,
|
formatQueuedStatus: Queue.formatQueuedTelegramItemsStatus,
|
||||||
|
getQueuedPreview: Queue.formatQueuedTelegramItemsPreview,
|
||||||
getRecentRuntimeEvents: runtimeEvents.getEvents,
|
getRecentRuntimeEvents: runtimeEvents.getEvents,
|
||||||
getRuntimeLockState: lockRuntime.getStatusLabel,
|
getRuntimeLockState: lockRuntime.getStatusLabel,
|
||||||
...threadStatusProjectionRuntime,
|
...threadStatusProjectionRuntime,
|
||||||
@@ -323,6 +328,18 @@ export default function (pi: Pi.ExtensionAPI) {
|
|||||||
getCwd: Pi.getExtensionContextCwd,
|
getCwd: Pi.getExtensionContextCwd,
|
||||||
recordRuntimeEvent,
|
recordRuntimeEvent,
|
||||||
});
|
});
|
||||||
|
const notices = Notices.createTelegramNoticeRuntime({
|
||||||
|
getContext: telegramSessionContextStore.get,
|
||||||
|
notify: Pi.notifyExtensionContext,
|
||||||
|
recordRuntimeEvent,
|
||||||
|
});
|
||||||
|
const trafficBridge = Traffic.createTelegramTrafficStatusBridge({
|
||||||
|
counters: trafficCounters,
|
||||||
|
getContext: telegramSessionContextStore.get,
|
||||||
|
updateStatus,
|
||||||
|
recordRuntimeEvent,
|
||||||
|
onDeliveryFailed: notices.deliveryFailed,
|
||||||
|
});
|
||||||
|
|
||||||
// --- Telegram API ---
|
// --- Telegram API ---
|
||||||
|
|
||||||
@@ -330,6 +347,8 @@ export default function (pi: Pi.ExtensionAPI) {
|
|||||||
TelegramApi.createDefaultTelegramBridgeApiRuntime({
|
TelegramApi.createDefaultTelegramBridgeApiRuntime({
|
||||||
getBotToken: configStore.getBotToken,
|
getBotToken: configStore.getBotToken,
|
||||||
recordRuntimeEvent,
|
recordRuntimeEvent,
|
||||||
|
onCallSucceeded: trafficBridge.onCallSucceeded,
|
||||||
|
onCallFailed: trafficBridge.onCallFailed,
|
||||||
});
|
});
|
||||||
const telegramBusFollowerClients =
|
const telegramBusFollowerClients =
|
||||||
BusFollower.createTelegramBusFollowerClientRuntime<
|
BusFollower.createTelegramBusFollowerClientRuntime<
|
||||||
@@ -645,6 +664,8 @@ export default function (pi: Pi.ExtensionAPI) {
|
|||||||
const inboundRouteRuntime = Routing.createTelegramInboundRouteRuntime({
|
const inboundRouteRuntime = Routing.createTelegramInboundRouteRuntime({
|
||||||
configStore,
|
configStore,
|
||||||
callApi: callTelegramApi,
|
callApi: callTelegramApi,
|
||||||
|
onAuthorizedMessage: trafficBridge.onAuthorizedMessage,
|
||||||
|
onPaired: notices.paired,
|
||||||
getCurrentInstanceId() {
|
getCurrentInstanceId() {
|
||||||
return telegramInstanceId;
|
return telegramInstanceId;
|
||||||
},
|
},
|
||||||
@@ -822,6 +843,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|||||||
stopTypingLoop: typing.stop,
|
stopTypingLoop: typing.stop,
|
||||||
updateStatus,
|
updateStatus,
|
||||||
recordRuntimeEvent,
|
recordRuntimeEvent,
|
||||||
|
onRecovered: notices.recovered,
|
||||||
});
|
});
|
||||||
const recoverStaleTelegramTopicApiError = function (
|
const recoverStaleTelegramTopicApiError = function (
|
||||||
apiBody: unknown,
|
apiBody: unknown,
|
||||||
|
|||||||
@@ -329,6 +329,8 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|||||||
updateStatus,
|
updateStatus,
|
||||||
recordRuntimeEvent,
|
recordRuntimeEvent,
|
||||||
}: TelegramLifecycleBindingDeps): void {
|
}: TelegramLifecycleBindingDeps): void {
|
||||||
|
// Whether this bridge set the host spinner text, so it only clears its own.
|
||||||
|
let telegramWorkingMessageShown = false;
|
||||||
const agentEndResetter = Runtime.createTelegramAgentEndResetter({
|
const agentEndResetter = Runtime.createTelegramAgentEndResetter({
|
||||||
abort,
|
abort,
|
||||||
typing,
|
typing,
|
||||||
@@ -589,6 +591,14 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|||||||
await agentStartWithDedupReset(event, ctx);
|
await agentStartWithDedupReset(event, ctx);
|
||||||
activityRuntime.onAgentStart(activeTurnRuntime.get()?.target);
|
activityRuntime.onAgentStart(activeTurnRuntime.get()?.target);
|
||||||
startAgentActivityTypingLoop(ctx);
|
startAgentActivityTypingLoop(ctx);
|
||||||
|
const telegramTurn = activeTurnRuntime.get();
|
||||||
|
if (telegramTurn) {
|
||||||
|
Pi.setExtensionWorkingMessage(
|
||||||
|
ctx,
|
||||||
|
Status.formatTelegramWorkingMessage(telegramTurn.statusSummary),
|
||||||
|
);
|
||||||
|
telegramWorkingMessageShown = true;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async onToolExecutionStart(event, ctx) {
|
async onToolExecutionStart(event, ctx) {
|
||||||
if (!isSessionContextActive(ctx)) return;
|
if (!isSessionContextActive(ctx)) return;
|
||||||
@@ -638,6 +648,10 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|||||||
onAgentSettled(_event, ctx) {
|
onAgentSettled(_event, ctx) {
|
||||||
if (!isSessionContextActive(ctx)) return;
|
if (!isSessionContextActive(ctx)) return;
|
||||||
activityRuntime.onAgentSettled();
|
activityRuntime.onAgentSettled();
|
||||||
|
if (telegramWorkingMessageShown) {
|
||||||
|
telegramWorkingMessageShown = false;
|
||||||
|
Pi.setExtensionWorkingMessage(ctx, undefined);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
settleOnTerminalAgentEnd: Pi.isOmpHost(pi),
|
settleOnTerminalAgentEnd: Pi.isOmpHost(pi),
|
||||||
onBeforeAgentStart: Prompts.createTelegramProactiveBeforeAgentStartHook({
|
onBeforeAgentStart: Prompts.createTelegramProactiveBeforeAgentStartHook({
|
||||||
|
|||||||
+110
-66
@@ -223,76 +223,120 @@ function hasSecretTouchingText(parts: string[]): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CommandTemplateRiskContext {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
parts: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMAND_TEMPLATE_NETWORK_EXECUTABLES = [
|
||||||
|
"curl",
|
||||||
|
"wget",
|
||||||
|
"ssh",
|
||||||
|
"scp",
|
||||||
|
"sftp",
|
||||||
|
"rsync",
|
||||||
|
"nc",
|
||||||
|
"ncat",
|
||||||
|
"telnet",
|
||||||
|
"ftp",
|
||||||
|
];
|
||||||
|
|
||||||
|
const COMMAND_TEMPLATE_PLATFORM_EXECUTABLES = [
|
||||||
|
"systemctl",
|
||||||
|
"launchctl",
|
||||||
|
"osascript",
|
||||||
|
"open",
|
||||||
|
"xdg-open",
|
||||||
|
"powershell",
|
||||||
|
"pwsh",
|
||||||
|
"cmd.exe",
|
||||||
|
"apt",
|
||||||
|
"apt-get",
|
||||||
|
"dnf",
|
||||||
|
"yum",
|
||||||
|
"brew",
|
||||||
|
"pacman",
|
||||||
|
"apk",
|
||||||
|
"xclip",
|
||||||
|
"wl-copy",
|
||||||
|
];
|
||||||
|
|
||||||
|
const COMMAND_TEMPLATE_RISK_RULES: Array<
|
||||||
|
[CommandTemplateRiskLabel, (context: CommandTemplateRiskContext) => boolean]
|
||||||
|
> = [
|
||||||
|
["risk.shell", ({ command }) => ["bash", "sh", "zsh", "fish"].includes(command)],
|
||||||
|
[
|
||||||
|
"risk.eval",
|
||||||
|
({ command, args }) =>
|
||||||
|
(["bash", "sh", "zsh", "fish"].includes(command) &&
|
||||||
|
hasAnyFlag(args, ["-c"])) ||
|
||||||
|
(["node", "deno", "bun"].includes(command) &&
|
||||||
|
hasAnyFlag(args, ["-e", "--eval"])) ||
|
||||||
|
(["python", "python3", "perl", "ruby"].includes(command) &&
|
||||||
|
hasAnyFlag(args, ["-c", "-e"])),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"risk.destructive_fs",
|
||||||
|
({ command, args }) =>
|
||||||
|
command === "rm" &&
|
||||||
|
(args.some((arg) => /^-[^-]*[rf]/.test(arg)) || hasRiskyPathArg(args)),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"risk.broad_fs_write",
|
||||||
|
({ command, args }) =>
|
||||||
|
["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"risk.network",
|
||||||
|
({ command, args }) =>
|
||||||
|
COMMAND_TEMPLATE_NETWORK_EXECUTABLES.includes(command) ||
|
||||||
|
(command === "git" &&
|
||||||
|
hasAnyArg(args, ["clone", "fetch", "pull", "push", "ls-remote"])) ||
|
||||||
|
["npm", "pnpm", "yarn", "pip", "cargo"].includes(command),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"risk.external_side_effect",
|
||||||
|
({ command, args }) =>
|
||||||
|
["gh", "glab", "hub", "kubectl", "terraform"].includes(command) ||
|
||||||
|
(command === "git" && hasAnyArg(args, ["push"])) ||
|
||||||
|
(["npm", "pnpm", "yarn"].includes(command) &&
|
||||||
|
hasAnyArg(args, ["publish", "login", "logout", "deprecate"])),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"risk.long_running",
|
||||||
|
({ command, args }) =>
|
||||||
|
command === "sleep" ||
|
||||||
|
command === "watch" ||
|
||||||
|
(command === "tail" && hasAnyFlag(args, ["-f"])) ||
|
||||||
|
hasAnyArg(args, ["--watch", "--serve", "serve"]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"risk.platform_specific",
|
||||||
|
({ command }) => COMMAND_TEMPLATE_PLATFORM_EXECUTABLES.includes(command),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"risk.secret_touching",
|
||||||
|
({ command, parts }) =>
|
||||||
|
["pass", "gpg", "ssh-add"].includes(command) ||
|
||||||
|
hasSecretTouchingText(parts),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
function getLeafCommandTemplateRiskLabels(
|
function getLeafCommandTemplateRiskLabels(
|
||||||
config: CommandTemplateLeafConfig,
|
config: CommandTemplateLeafConfig,
|
||||||
): CommandTemplateRiskLabel[] {
|
): CommandTemplateRiskLabel[] {
|
||||||
const parts = splitCommandTemplate(config.template);
|
const parts = splitCommandTemplate(config.template);
|
||||||
const command = getExecutableName(parts[0]);
|
const context: CommandTemplateRiskContext = {
|
||||||
const args = parts.slice(1);
|
command: getExecutableName(parts[0]),
|
||||||
const labels = new Set<CommandTemplateRiskLabel>();
|
args: parts.slice(1),
|
||||||
if (["bash", "sh", "zsh", "fish"].includes(command)) {
|
parts,
|
||||||
labels.add("risk.shell");
|
};
|
||||||
if (hasAnyFlag(args, ["-c"])) labels.add("risk.eval");
|
return sortRiskLabels(
|
||||||
}
|
COMMAND_TEMPLATE_RISK_RULES.filter(([, matches]) => matches(context)).map(
|
||||||
if (
|
([label]) => label,
|
||||||
["node", "deno", "bun"].includes(command) &&
|
),
|
||||||
hasAnyFlag(args, ["-e", "--eval"])
|
);
|
||||||
) {
|
|
||||||
labels.add("risk.eval");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
["python", "python3", "perl", "ruby"].includes(command) &&
|
|
||||||
hasAnyFlag(args, ["-c", "-e"])
|
|
||||||
) {
|
|
||||||
labels.add("risk.eval");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
command === "rm" &&
|
|
||||||
(args.some((arg) => /^-[^-]*r/.test(arg) || /^-[^-]*f/.test(arg)) ||
|
|
||||||
hasRiskyPathArg(args))
|
|
||||||
) {
|
|
||||||
labels.add("risk.destructive_fs");
|
|
||||||
}
|
|
||||||
if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
|
|
||||||
labels.add("risk.broad_fs_write");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
["curl", "wget", "ssh", "scp", "sftp", "rsync", "nc", "ncat", "telnet", "ftp"].includes(
|
|
||||||
command,
|
|
||||||
) ||
|
|
||||||
(command === "git" &&
|
|
||||||
hasAnyArg(args, ["clone", "fetch", "pull", "push", "ls-remote"])) ||
|
|
||||||
["npm", "pnpm", "yarn", "pip", "cargo"].includes(command)
|
|
||||||
) {
|
|
||||||
labels.add("risk.network");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
["gh", "glab", "hub", "kubectl", "terraform"].includes(command) ||
|
|
||||||
(command === "git" && hasAnyArg(args, ["push"])) ||
|
|
||||||
(["npm", "pnpm", "yarn"].includes(command) &&
|
|
||||||
hasAnyArg(args, ["publish", "login", "logout", "deprecate"]))
|
|
||||||
) {
|
|
||||||
labels.add("risk.external_side_effect");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
command === "sleep" ||
|
|
||||||
command === "watch" ||
|
|
||||||
(command === "tail" && hasAnyFlag(args, ["-f"])) ||
|
|
||||||
hasAnyArg(args, ["--watch", "--serve", "serve"])
|
|
||||||
) {
|
|
||||||
labels.add("risk.long_running");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
["systemctl", "launchctl", "osascript", "open", "xdg-open", "powershell", "pwsh", "cmd.exe", "apt", "apt-get", "dnf", "yum", "brew", "pacman", "apk", "xclip", "wl-copy"].includes(
|
|
||||||
command,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
labels.add("risk.platform_specific");
|
|
||||||
}
|
|
||||||
if (["pass", "gpg", "ssh-add"].includes(command) || hasSecretTouchingText(parts)) {
|
|
||||||
labels.add("risk.secret_touching");
|
|
||||||
}
|
|
||||||
return sortRiskLabels(labels);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLeafCommandTemplateWarnings(
|
function getLeafCommandTemplateWarnings(
|
||||||
|
|||||||
+8
-3
@@ -777,6 +777,8 @@ export interface TelegramUserPairingRuntimeDeps<TContext> {
|
|||||||
setAllowedUserId: (userId: number) => void;
|
setAllowedUserId: (userId: number) => void;
|
||||||
persistConfig: () => Promise<void>;
|
persistConfig: () => Promise<void>;
|
||||||
updateStatus: (ctx: TContext) => void;
|
updateStatus: (ctx: TContext) => void;
|
||||||
|
/** Called once the user was accepted as owner and the config persisted. */
|
||||||
|
onPaired?: (userId: number, ctx: TContext) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TelegramUserPairingRuntime<TContext> {
|
export interface TelegramUserPairingRuntime<TContext> {
|
||||||
@@ -827,13 +829,16 @@ export function createTelegramUserPairingRuntime<TContext>(
|
|||||||
deps: TelegramUserPairingRuntimeDeps<TContext>,
|
deps: TelegramUserPairingRuntimeDeps<TContext>,
|
||||||
): TelegramUserPairingRuntime<TContext> {
|
): TelegramUserPairingRuntime<TContext> {
|
||||||
return {
|
return {
|
||||||
pairIfNeeded: (userId, ctx) =>
|
pairIfNeeded: async (userId, ctx) => {
|
||||||
pairTelegramUserIfNeeded(userId, {
|
const paired = await pairTelegramUserIfNeeded(userId, {
|
||||||
allowedUserId: deps.getAllowedUserId(),
|
allowedUserId: deps.getAllowedUserId(),
|
||||||
ctx,
|
ctx,
|
||||||
setAllowedUserId: deps.setAllowedUserId,
|
setAllowedUserId: deps.setAllowedUserId,
|
||||||
persistConfig: deps.persistConfig,
|
persistConfig: deps.persistConfig,
|
||||||
updateStatus: deps.updateStatus,
|
updateStatus: deps.updateStatus,
|
||||||
}),
|
});
|
||||||
|
if (paired) deps.onPaired?.(userId, ctx);
|
||||||
|
return paired;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+115
-107
@@ -492,122 +492,130 @@ export async function updateVoiceReplyModeSettingsMessage(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TelegramSettingsPageUpdater = (
|
||||||
|
deps: TelegramSettingsMenuCallbackDeps,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
const TELEGRAM_SETTINGS_PAGE_UPDATERS = new Map<
|
||||||
|
string,
|
||||||
|
TelegramSettingsPageUpdater
|
||||||
|
>([
|
||||||
|
[
|
||||||
|
"settings:list",
|
||||||
|
(deps) => updateTelegramSettingsMenuMessage(deps, deps.sectionRegistry),
|
||||||
|
],
|
||||||
|
["settings:open:proactive", updateProactivePushSettingsMessage],
|
||||||
|
["settings:open:draft-previews", updateDraftPreviewsSettingsMessage],
|
||||||
|
["settings:open:rich-drafts", updateDraftPreviewsSettingsMessage],
|
||||||
|
["settings:open:assistant-rendering", updateAssistantRenderingSettingsMessage],
|
||||||
|
["settings:open:voice-reply", updateVoiceReplyModeSettingsMessage],
|
||||||
|
["settings:open:time-injection", updateTimeInjectionModeSettingsMessage],
|
||||||
|
["settings:open:time", updateTimeInjectionModeSettingsMessage],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Applies one setting value; resolves the toast text, or undefined when the value is invalid */
|
||||||
|
type TelegramSettingsValueApplier = (
|
||||||
|
value: string,
|
||||||
|
deps: TelegramSettingsMenuCallbackDeps,
|
||||||
|
) => Promise<string | undefined>;
|
||||||
|
|
||||||
|
async function applyVoiceReplyModeSetting(
|
||||||
|
mode: string,
|
||||||
|
deps: TelegramSettingsMenuCallbackDeps,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (
|
||||||
|
mode !== "hidden" &&
|
||||||
|
mode !== "manual" &&
|
||||||
|
mode !== "mirror" &&
|
||||||
|
mode !== "always"
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
await deps.setVoiceReplyMode(mode === "hidden" ? undefined : mode);
|
||||||
|
await updateVoiceReplyModeSettingsMessage(deps);
|
||||||
|
return `Voice reply mode: ${mode}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyTimeInjectionModeSetting(
|
||||||
|
mode: string,
|
||||||
|
deps: TelegramSettingsMenuCallbackDeps,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const normalizedMode = mode === "off" ? "hidden" : mode;
|
||||||
|
if (
|
||||||
|
normalizedMode !== "hidden" &&
|
||||||
|
normalizedMode !== "always" &&
|
||||||
|
normalizedMode !== "interval"
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
await deps.setTimeInjectionMode(normalizedMode);
|
||||||
|
await updateTimeInjectionModeSettingsMessage(deps);
|
||||||
|
return `Time injection: ${normalizedMode}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyDraftPreviewsSetting(
|
||||||
|
value: string,
|
||||||
|
deps: TelegramSettingsMenuCallbackDeps,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (value !== "on" && value !== "off") return undefined;
|
||||||
|
const enabled = value === "on";
|
||||||
|
await deps.setDraftPreviewsEnabled(enabled);
|
||||||
|
await updateDraftPreviewsSettingsMessage(deps);
|
||||||
|
return `Draft previews ${enabled ? "enabled" : "disabled"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyAssistantRenderingSetting(
|
||||||
|
mode: string,
|
||||||
|
deps: TelegramSettingsMenuCallbackDeps,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (mode !== "rich" && mode !== "html") return undefined;
|
||||||
|
await deps.setAssistantRenderingMode(mode);
|
||||||
|
await updateAssistantRenderingSettingsMessage(deps);
|
||||||
|
return `Rendering: ${mode}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyProactivePushSetting(
|
||||||
|
value: string,
|
||||||
|
deps: TelegramSettingsMenuCallbackDeps,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (value !== "on" && value !== "off") return undefined;
|
||||||
|
const enabled = value === "on";
|
||||||
|
await deps.setProactivePushEnabled(enabled);
|
||||||
|
await updateProactivePushSettingsMessage(deps);
|
||||||
|
return `Proactive push ${enabled ? "enabled" : "disabled"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TELEGRAM_SETTINGS_VALUE_APPLIERS: Array<
|
||||||
|
[prefix: string, apply: TelegramSettingsValueApplier]
|
||||||
|
> = [
|
||||||
|
["settings:set:voice-reply:", applyVoiceReplyModeSetting],
|
||||||
|
["settings:set:time-injection:", applyTimeInjectionModeSetting],
|
||||||
|
["settings:set:time:", applyTimeInjectionModeSetting],
|
||||||
|
["settings:set:draft-previews:", applyDraftPreviewsSetting],
|
||||||
|
["settings:set:rich-drafts:", applyDraftPreviewsSetting],
|
||||||
|
["settings:set:assistant-rendering:", applyAssistantRenderingSetting],
|
||||||
|
["settings:set:proactive:", applyProactivePushSetting],
|
||||||
|
];
|
||||||
|
|
||||||
export async function handleTelegramSettingsMenuCallbackAction(
|
export async function handleTelegramSettingsMenuCallbackAction(
|
||||||
callbackQueryId: string,
|
callbackQueryId: string,
|
||||||
data: string | undefined,
|
data: string | undefined,
|
||||||
deps: TelegramSettingsMenuCallbackDeps,
|
deps: TelegramSettingsMenuCallbackDeps,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!data?.startsWith("settings:")) return false;
|
if (!data?.startsWith("settings:")) return false;
|
||||||
if (data === "settings:list") {
|
const updatePage = TELEGRAM_SETTINGS_PAGE_UPDATERS.get(data);
|
||||||
await updateTelegramSettingsMenuMessage(deps, deps.sectionRegistry);
|
if (updatePage) {
|
||||||
|
await updatePage(deps);
|
||||||
await deps.answerCallbackQuery(callbackQueryId);
|
await deps.answerCallbackQuery(callbackQueryId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (data === "settings:open:proactive") {
|
const applier = TELEGRAM_SETTINGS_VALUE_APPLIERS.find(([prefix]) =>
|
||||||
await updateProactivePushSettingsMessage(deps);
|
data.startsWith(prefix),
|
||||||
await deps.answerCallbackQuery(callbackQueryId);
|
);
|
||||||
return true;
|
const toast = applier
|
||||||
}
|
? await applier[1](data.slice(applier[0].length), deps)
|
||||||
if (data === "settings:open:draft-previews" || data === "settings:open:rich-drafts") {
|
: undefined;
|
||||||
await updateDraftPreviewsSettingsMessage(deps);
|
await deps.answerCallbackQuery(callbackQueryId, toast);
|
||||||
await deps.answerCallbackQuery(callbackQueryId);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (data === "settings:open:assistant-rendering") {
|
|
||||||
await updateAssistantRenderingSettingsMessage(deps);
|
|
||||||
await deps.answerCallbackQuery(callbackQueryId);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (data === "settings:open:voice-reply") {
|
|
||||||
await updateVoiceReplyModeSettingsMessage(deps);
|
|
||||||
await deps.answerCallbackQuery(callbackQueryId);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
data === "settings:open:time-injection" ||
|
|
||||||
data === "settings:open:time"
|
|
||||||
) {
|
|
||||||
await updateTimeInjectionModeSettingsMessage(deps);
|
|
||||||
await deps.answerCallbackQuery(callbackQueryId);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (data.startsWith("settings:set:voice-reply:")) {
|
|
||||||
const mode = data.slice("settings:set:voice-reply:".length);
|
|
||||||
if (
|
|
||||||
mode === "hidden" ||
|
|
||||||
mode === "manual" ||
|
|
||||||
mode === "mirror" ||
|
|
||||||
mode === "always"
|
|
||||||
) {
|
|
||||||
await deps.setVoiceReplyMode(mode === "hidden" ? undefined : mode);
|
|
||||||
await updateVoiceReplyModeSettingsMessage(deps);
|
|
||||||
await deps.answerCallbackQuery(
|
|
||||||
callbackQueryId,
|
|
||||||
`Voice reply mode: ${mode}`,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
data.startsWith("settings:set:time-injection:") ||
|
|
||||||
data.startsWith("settings:set:time:")
|
|
||||||
) {
|
|
||||||
const mode = data.startsWith("settings:set:time-injection:")
|
|
||||||
? data.slice("settings:set:time-injection:".length)
|
|
||||||
: data.slice("settings:set:time:".length);
|
|
||||||
const normalizedMode = mode === "off" ? "hidden" : mode;
|
|
||||||
if (
|
|
||||||
normalizedMode === "hidden" ||
|
|
||||||
normalizedMode === "always" ||
|
|
||||||
normalizedMode === "interval"
|
|
||||||
) {
|
|
||||||
await deps.setTimeInjectionMode(normalizedMode);
|
|
||||||
await updateTimeInjectionModeSettingsMessage(deps);
|
|
||||||
await deps.answerCallbackQuery(
|
|
||||||
callbackQueryId,
|
|
||||||
`Time injection: ${normalizedMode}`,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
data === "settings:set:draft-previews:on" ||
|
|
||||||
data === "settings:set:draft-previews:off" ||
|
|
||||||
data === "settings:set:rich-drafts:on" ||
|
|
||||||
data === "settings:set:rich-drafts:off"
|
|
||||||
) {
|
|
||||||
const enabled = data.endsWith(":on");
|
|
||||||
await deps.setDraftPreviewsEnabled(enabled);
|
|
||||||
await updateDraftPreviewsSettingsMessage(deps);
|
|
||||||
await deps.answerCallbackQuery(
|
|
||||||
callbackQueryId,
|
|
||||||
`Draft previews ${enabled ? "enabled" : "disabled"}`,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (data.startsWith("settings:set:assistant-rendering:")) {
|
|
||||||
const mode = data.slice("settings:set:assistant-rendering:".length);
|
|
||||||
if (mode === "rich" || mode === "html") {
|
|
||||||
await deps.setAssistantRenderingMode(mode);
|
|
||||||
await updateAssistantRenderingSettingsMessage(deps);
|
|
||||||
await deps.answerCallbackQuery(callbackQueryId, `Rendering: ${mode}`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
data === "settings:set:proactive:on" ||
|
|
||||||
data === "settings:set:proactive:off"
|
|
||||||
) {
|
|
||||||
const enabled = data.endsWith(":on");
|
|
||||||
await deps.setProactivePushEnabled(enabled);
|
|
||||||
await updateProactivePushSettingsMessage(deps);
|
|
||||||
await deps.answerCallbackQuery(
|
|
||||||
callbackQueryId,
|
|
||||||
`Proactive push ${enabled ? "enabled" : "disabled"}`,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
await deps.answerCallbackQuery(callbackQueryId);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+39
-31
@@ -334,45 +334,53 @@ export type TelegramMenuCallbackAction =
|
|||||||
value?: string;
|
value?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TELEGRAM_MENU_STATUS_ACTIONS = [
|
||||||
|
"model",
|
||||||
|
"thinking",
|
||||||
|
"queue",
|
||||||
|
"settings",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TELEGRAM_MENU_MODEL_ACTIONS = [
|
||||||
|
"noop",
|
||||||
|
"scope",
|
||||||
|
"page",
|
||||||
|
"pages",
|
||||||
|
"open",
|
||||||
|
"pick",
|
||||||
|
"pick-selected",
|
||||||
|
"scope-enable",
|
||||||
|
"scope-disable",
|
||||||
|
"scope-toggle",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type TelegramMenuModelAction = Extract<
|
||||||
|
TelegramMenuCallbackAction,
|
||||||
|
{ kind: "model" }
|
||||||
|
>["action"];
|
||||||
|
|
||||||
export function parseTelegramMenuCallbackAction(
|
export function parseTelegramMenuCallbackAction(
|
||||||
data: string | undefined,
|
data: string | undefined,
|
||||||
): TelegramMenuCallbackAction {
|
): TelegramMenuCallbackAction {
|
||||||
if (data === "menu:model" || data === "status:model") {
|
if (!data) return { kind: "ignore" };
|
||||||
return { kind: "status", action: "model" };
|
const statusAction = TELEGRAM_MENU_STATUS_ACTIONS.find(
|
||||||
}
|
(action) => data === `menu:${action}` || data === `status:${action}`,
|
||||||
if (data === "menu:thinking" || data === "status:thinking") {
|
);
|
||||||
return { kind: "status", action: "thinking" };
|
if (statusAction) return { kind: "status", action: statusAction };
|
||||||
}
|
if (data.startsWith("thinking:set:")) {
|
||||||
if (data === "menu:queue" || data === "status:queue") {
|
|
||||||
return { kind: "status", action: "queue" };
|
|
||||||
}
|
|
||||||
if (data === "menu:settings" || data === "status:settings") {
|
|
||||||
return { kind: "status", action: "settings" };
|
|
||||||
}
|
|
||||||
if (data?.startsWith("thinking:set:")) {
|
|
||||||
return {
|
return {
|
||||||
kind: "thinking:set",
|
kind: "thinking:set",
|
||||||
level: data.slice("thinking:set:".length),
|
level: data.slice("thinking:set:".length),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (data?.startsWith("model:")) {
|
if (!data.startsWith("model:")) return { kind: "ignore" };
|
||||||
const [, action, value] = data.split(":");
|
const [, action, value] = data.split(":");
|
||||||
if (
|
const modelAction = TELEGRAM_MENU_MODEL_ACTIONS.find(
|
||||||
action === "noop" ||
|
(candidate): candidate is TelegramMenuModelAction => candidate === action,
|
||||||
action === "scope" ||
|
);
|
||||||
action === "page" ||
|
return modelAction
|
||||||
action === "pages" ||
|
? { kind: "model", action: modelAction, value }
|
||||||
action === "open" ||
|
: { kind: "ignore" };
|
||||||
action === "pick" ||
|
|
||||||
action === "pick-selected" ||
|
|
||||||
action === "scope-enable" ||
|
|
||||||
action === "scope-disable" ||
|
|
||||||
action === "scope-toggle"
|
|
||||||
) {
|
|
||||||
return { kind: "model", action, value };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { kind: "ignore" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleTelegramMenuCallbackEntry(
|
export async function handleTelegramMenuCallbackEntry(
|
||||||
|
|||||||
+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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -260,6 +260,27 @@ export function isTerminalAgentEnd(event: AgentEndEvent): boolean {
|
|||||||
return !("willContinue" in event && event.willContinue === true);
|
return !("willContinue" in event && event.willContinue === true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Show a one-off toast in the host UI; a no-op where the host has no UI. */
|
||||||
|
export function notifyExtensionContext(
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
message: string,
|
||||||
|
level: "info" | "warning" | "error",
|
||||||
|
): void {
|
||||||
|
ctx.ui.notify(message, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the host spinner text, or restore its default with `undefined`.
|
||||||
|
* Pi-compatible hosts do not all expose `setWorkingMessage`; the spinner text
|
||||||
|
* is cosmetic, so a host without it is skipped instead of failing the turn.
|
||||||
|
*/
|
||||||
|
export function setExtensionWorkingMessage(
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
message?: string,
|
||||||
|
): void {
|
||||||
|
ctx.ui.setWorkingMessage?.(message);
|
||||||
|
}
|
||||||
|
|
||||||
export function isExtensionContextIdle(ctx: ExtensionContext): boolean {
|
export function isExtensionContextIdle(ctx: ExtensionContext): boolean {
|
||||||
return ctx.isIdle();
|
return ctx.isIdle();
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-4
@@ -163,6 +163,7 @@ export function createTelegramPollingControllerRuntime<
|
|||||||
updateStatus: deps.updateStatus,
|
updateStatus: deps.updateStatus,
|
||||||
sleep: deps.sleep,
|
sleep: deps.sleep,
|
||||||
maxUpdateFailures: deps.maxUpdateFailures,
|
maxUpdateFailures: deps.maxUpdateFailures,
|
||||||
|
onRecovered: deps.onRecovered,
|
||||||
recordRuntimeEvent: deps.recordRuntimeEvent,
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
||||||
}),
|
}),
|
||||||
updateStatus: deps.updateStatus,
|
updateStatus: deps.updateStatus,
|
||||||
@@ -913,6 +914,8 @@ export interface TelegramPollLoopDeps<
|
|||||||
onStatusReset: () => void;
|
onStatusReset: () => void;
|
||||||
sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
|
sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
|
||||||
maxUpdateFailures?: number;
|
maxUpdateFailures?: number;
|
||||||
|
/** Called on the first successful poll after a failed attempt. */
|
||||||
|
onRecovered?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TelegramPollLoopRunnerDeps<
|
export interface TelegramPollLoopRunnerDeps<
|
||||||
@@ -930,6 +933,8 @@ export interface TelegramPollLoopRunnerDeps<
|
|||||||
updateStatus: (ctx: TContext, message?: string) => void;
|
updateStatus: (ctx: TContext, message?: string) => void;
|
||||||
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
||||||
maxUpdateFailures?: number;
|
maxUpdateFailures?: number;
|
||||||
|
/** Called on the first successful poll after a failed attempt. */
|
||||||
|
onRecovered?: (ctx: TContext) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sleepTelegramPollingRetry(
|
export function sleepTelegramPollingRetry(
|
||||||
@@ -971,7 +976,15 @@ export function createTelegramPollLoopRunner<
|
|||||||
config: deps.getConfig(),
|
config: deps.getConfig(),
|
||||||
deleteWebhook: deps.deleteWebhook,
|
deleteWebhook: deps.deleteWebhook,
|
||||||
getUpdates: deps.getUpdates,
|
getUpdates: deps.getUpdates,
|
||||||
persistConfig: deps.persistConfig,
|
// The loop keeps the config object captured when polling started, but the
|
||||||
|
// store swaps in a fresh object on every persist. Persisting that stale
|
||||||
|
// copy erased later changes such as the paired user, so only the poll
|
||||||
|
// offset is carried onto the store's current config.
|
||||||
|
persistConfig: (polled) => {
|
||||||
|
const current = deps.getConfig();
|
||||||
|
current.lastUpdateId = polled.lastUpdateId;
|
||||||
|
return deps.persistConfig(current);
|
||||||
|
},
|
||||||
handleUpdate: deps.handleUpdate,
|
handleUpdate: deps.handleUpdate,
|
||||||
onErrorStatus: (message) => {
|
onErrorStatus: (message) => {
|
||||||
updateTelegramPollingStatusSafely(deps.updateStatus, ctx, {
|
updateTelegramPollingStatusSafely(deps.updateStatus, ctx, {
|
||||||
@@ -986,6 +999,15 @@ export function createTelegramPollLoopRunner<
|
|||||||
},
|
},
|
||||||
sleep,
|
sleep,
|
||||||
maxUpdateFailures: deps.maxUpdateFailures,
|
maxUpdateFailures: deps.maxUpdateFailures,
|
||||||
|
onRecovered: () => {
|
||||||
|
try {
|
||||||
|
deps.onRecovered?.(ctx);
|
||||||
|
} catch (error) {
|
||||||
|
deps.recordRuntimeEvent?.("polling", error, {
|
||||||
|
phase: "recovered-notice",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
recordRuntimeEvent: deps.recordRuntimeEvent,
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1033,13 +1055,34 @@ export async function runTelegramPollLoop<
|
|||||||
const admittedUpdates = new Set<number>();
|
const admittedUpdates = new Set<number>();
|
||||||
let handledUpdateFailureRethrown = false;
|
let handledUpdateFailureRethrown = false;
|
||||||
let consecutiveGetUpdatesConflicts = 0;
|
let consecutiveGetUpdatesConflicts = 0;
|
||||||
|
// An error status is on screen; it stays until polling works again, so a
|
||||||
|
// slow retry (for example a long rate-limit wait) never shows as healthy.
|
||||||
|
let errorShown = false;
|
||||||
|
// The last getUpdates failed, so the next successful one is a recovery.
|
||||||
|
// Failures of an update handler do not count: the transport kept working.
|
||||||
|
let transportDown = false;
|
||||||
|
const showError = (message: string): void => {
|
||||||
|
errorShown = true;
|
||||||
|
deps.onErrorStatus(message);
|
||||||
|
};
|
||||||
while (!deps.signal.aborted) {
|
while (!deps.signal.aborted) {
|
||||||
|
let inGetUpdates = false;
|
||||||
try {
|
try {
|
||||||
|
inGetUpdates = true;
|
||||||
const updates = await deps.getUpdates(
|
const updates = await deps.getUpdates(
|
||||||
buildTelegramLongPollRequest(deps.config.lastUpdateId),
|
buildTelegramLongPollRequest(deps.config.lastUpdateId),
|
||||||
deps.signal,
|
deps.signal,
|
||||||
);
|
);
|
||||||
|
inGetUpdates = false;
|
||||||
consecutiveGetUpdatesConflicts = 0;
|
consecutiveGetUpdatesConflicts = 0;
|
||||||
|
if (errorShown) {
|
||||||
|
errorShown = false;
|
||||||
|
deps.onStatusReset();
|
||||||
|
}
|
||||||
|
if (transportDown) {
|
||||||
|
transportDown = false;
|
||||||
|
deps.onRecovered?.();
|
||||||
|
}
|
||||||
for (const update of updates) {
|
for (const update of updates) {
|
||||||
if (admittedUpdates.has(update.update_id)) {
|
if (admittedUpdates.has(update.update_id)) {
|
||||||
deps.config.lastUpdateId = update.update_id;
|
deps.config.lastUpdateId = update.update_id;
|
||||||
@@ -1068,7 +1111,7 @@ export async function runTelegramPollLoop<
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const message = getTelegramPollingErrorMessage(error);
|
const message = getTelegramPollingErrorMessage(error);
|
||||||
deps.onErrorStatus(
|
showError(
|
||||||
`skipping Telegram update ${update.update_id} after ${failureCount} failures: ${message}`,
|
`skipping Telegram update ${update.update_id} after ${failureCount} failures: ${message}`,
|
||||||
);
|
);
|
||||||
admittedUpdates.add(update.update_id);
|
admittedUpdates.add(update.update_id);
|
||||||
@@ -1097,10 +1140,10 @@ export async function runTelegramPollLoop<
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
consecutiveGetUpdatesConflicts = 0;
|
consecutiveGetUpdatesConflicts = 0;
|
||||||
deps.onErrorStatus(getTelegramPollingErrorMessage(error));
|
if (inGetUpdates) transportDown = true;
|
||||||
|
showError(getTelegramPollingErrorMessage(error));
|
||||||
await deps.sleep(TELEGRAM_POLLING_RETRY_MS, deps.signal);
|
await deps.sleep(TELEGRAM_POLLING_RETRY_MS, deps.signal);
|
||||||
if (deps.signal.aborted) return;
|
if (deps.signal.aborted) return;
|
||||||
deps.onStatusReset();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -499,6 +499,25 @@ export function formatQueuedTelegramItemsStatus<TContext = unknown>(
|
|||||||
return items.length === 0 ? "" : ` +${items.length}`;
|
return items.length === 0 ? "" : ` +${items.length}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summaries of the waiting items in dispatch order, for the status preview.
|
||||||
|
* Display must never break the status update, so a malformed item only
|
||||||
|
* costs the ordering.
|
||||||
|
*/
|
||||||
|
export function formatQueuedTelegramItemsPreview<TContext = unknown>(
|
||||||
|
items: TelegramQueueItem<TContext>[],
|
||||||
|
): string[] {
|
||||||
|
let ordered = items;
|
||||||
|
try {
|
||||||
|
ordered = [...items].sort(compareTelegramQueueItems);
|
||||||
|
} catch {
|
||||||
|
// Keep the store order.
|
||||||
|
}
|
||||||
|
return ordered
|
||||||
|
.map((item) => item.statusSummary.trim())
|
||||||
|
.filter((summary) => summary.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
export function truncateTelegramQueueSummary(
|
export function truncateTelegramQueueSummary(
|
||||||
text: string,
|
text: string,
|
||||||
maxWords = 5,
|
maxWords = 5,
|
||||||
|
|||||||
+116
-90
@@ -191,81 +191,94 @@ function findMarkdownClosingBracket(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function skipMarkdownWhitespace(text: string, index: number): number {
|
||||||
|
while (index < text.length && /\s/.test(text[index] ?? "")) {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index of the first unescaped `closing` character at or after `index`, or text length */
|
||||||
|
function findUnescapedMarkdownCharacter(
|
||||||
|
text: string,
|
||||||
|
index: number,
|
||||||
|
closing: string,
|
||||||
|
): number {
|
||||||
|
while (
|
||||||
|
index < text.length &&
|
||||||
|
(isEscapedMarkdownCharacter(text, index) || text[index] !== closing)
|
||||||
|
) {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMarkdownAngleDestination(
|
||||||
|
text: string,
|
||||||
|
openIndex: number,
|
||||||
|
): { destination: string; nextIndex: number } | undefined {
|
||||||
|
const closeIndex = findUnescapedMarkdownCharacter(text, openIndex + 1, ">");
|
||||||
|
if (closeIndex >= text.length) return undefined;
|
||||||
|
return {
|
||||||
|
destination: text.slice(openIndex + 1, closeIndex).trim(),
|
||||||
|
nextIndex: closeIndex + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMarkdownBareDestination(
|
||||||
|
text: string,
|
||||||
|
startIndex: number,
|
||||||
|
): { destination: string; nextIndex: number } {
|
||||||
|
let index = startIndex;
|
||||||
|
let parenDepth = 0;
|
||||||
|
while (index < text.length) {
|
||||||
|
if (isEscapedMarkdownCharacter(text, index)) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const char = text[index] ?? "";
|
||||||
|
if (parenDepth === 0 && (char === ")" || /\s/.test(char))) break;
|
||||||
|
if (char === "(") parenDepth += 1;
|
||||||
|
if (char === ")") parenDepth -= 1;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return { destination: text.slice(startIndex, index).trim(), nextIndex: index };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skips an optional quoted or parenthesized title; undefined when it is unterminated */
|
||||||
|
function skipMarkdownLinkTitle(
|
||||||
|
text: string,
|
||||||
|
index: number,
|
||||||
|
): number | undefined {
|
||||||
|
const titleDelimiter = text[index];
|
||||||
|
if (titleDelimiter !== '"' && titleDelimiter !== "'" && titleDelimiter !== "(") {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
const closingDelimiter = titleDelimiter === "(" ? ")" : titleDelimiter;
|
||||||
|
const closeIndex = findUnescapedMarkdownCharacter(
|
||||||
|
text,
|
||||||
|
index + 1,
|
||||||
|
closingDelimiter,
|
||||||
|
);
|
||||||
|
if (closeIndex >= text.length) return undefined;
|
||||||
|
return skipMarkdownWhitespace(text, closeIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
function parseMarkdownLinkTarget(
|
function parseMarkdownLinkTarget(
|
||||||
text: string,
|
text: string,
|
||||||
openParenIndex: number,
|
openParenIndex: number,
|
||||||
): { destination: string; endIndex: number } | undefined {
|
): { destination: string; endIndex: number } | undefined {
|
||||||
let index = openParenIndex + 1;
|
const destinationIndex = skipMarkdownWhitespace(text, openParenIndex + 1);
|
||||||
while (index < text.length && /\s/.test(text[index] ?? "")) {
|
if (destinationIndex >= text.length) return undefined;
|
||||||
index += 1;
|
const parsed =
|
||||||
}
|
text[destinationIndex] === "<"
|
||||||
if (index >= text.length) return undefined;
|
? parseMarkdownAngleDestination(text, destinationIndex)
|
||||||
let destination = "";
|
: parseMarkdownBareDestination(text, destinationIndex);
|
||||||
if (text[index] === "<") {
|
if (!parsed?.destination) return undefined;
|
||||||
const destinationStart = index + 1;
|
const titleIndex = skipMarkdownWhitespace(text, parsed.nextIndex);
|
||||||
index += 1;
|
const endIndex = skipMarkdownLinkTitle(text, titleIndex);
|
||||||
while (index < text.length) {
|
if (endIndex === undefined || text[endIndex] !== ")") return undefined;
|
||||||
if (!isEscapedMarkdownCharacter(text, index) && text[index] === ">") {
|
return { destination: parsed.destination, endIndex };
|
||||||
break;
|
|
||||||
}
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
if (index >= text.length) return undefined;
|
|
||||||
destination = text.slice(destinationStart, index).trim();
|
|
||||||
index += 1;
|
|
||||||
} else {
|
|
||||||
const destinationStart = index;
|
|
||||||
let parenDepth = 0;
|
|
||||||
while (index < text.length) {
|
|
||||||
if (isEscapedMarkdownCharacter(text, index)) {
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const char = text[index] ?? "";
|
|
||||||
if (/\s/.test(char) && parenDepth === 0) break;
|
|
||||||
if (char === "(") {
|
|
||||||
parenDepth += 1;
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (char === ")") {
|
|
||||||
if (parenDepth === 0) break;
|
|
||||||
parenDepth -= 1;
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
destination = text.slice(destinationStart, index).trim();
|
|
||||||
}
|
|
||||||
if (!destination) return undefined;
|
|
||||||
while (index < text.length && /\s/.test(text[index] ?? "")) {
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
index < text.length &&
|
|
||||||
(text[index] === '"' || text[index] === "'" || text[index] === "(")
|
|
||||||
) {
|
|
||||||
const titleDelimiter = text[index] ?? '"';
|
|
||||||
const closingTitleDelimiter = titleDelimiter === "(" ? ")" : titleDelimiter;
|
|
||||||
index += 1;
|
|
||||||
while (index < text.length) {
|
|
||||||
if (
|
|
||||||
!isEscapedMarkdownCharacter(text, index) &&
|
|
||||||
text[index] === closingTitleDelimiter
|
|
||||||
) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
if (index >= text.length) return undefined;
|
|
||||||
index += 1;
|
|
||||||
while (index < text.length && /\s/.test(text[index] ?? "")) {
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (text[index] !== ")") return undefined;
|
|
||||||
return { destination, endIndex: index };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSupportedMarkdownLinkDestination(destination: string): boolean {
|
function isSupportedMarkdownLinkDestination(destination: string): boolean {
|
||||||
@@ -715,27 +728,40 @@ function isTelegramTableEmojiGrapheme(grapheme: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Inclusive code point ranges that occupy two terminal columns */
|
||||||
|
const TELEGRAM_TABLE_WIDE_CODE_POINT_RANGES: ReadonlyArray<
|
||||||
|
readonly [number, number]
|
||||||
|
> = [
|
||||||
|
[0x1100, 0x115f],
|
||||||
|
[0x2329, 0x232a],
|
||||||
|
[0x2e80, 0xa4cf],
|
||||||
|
[0xac00, 0xd7a3],
|
||||||
|
[0xf900, 0xfaff],
|
||||||
|
[0xfe10, 0xfe19],
|
||||||
|
[0xfe30, 0xfe6f],
|
||||||
|
[0xff00, 0xff60],
|
||||||
|
[0xffe0, 0xffe6],
|
||||||
|
];
|
||||||
|
|
||||||
|
function isTelegramTableZeroWidthCodePoint(
|
||||||
|
codePoint: number,
|
||||||
|
char: string,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
codePoint < 32 ||
|
||||||
|
codePoint === 0x200d ||
|
||||||
|
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
|
||||||
|
/\p{Mark}/u.test(char)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getTelegramTableCodePointWidth(char: string): number {
|
function getTelegramTableCodePointWidth(char: string): number {
|
||||||
const codePoint = char.codePointAt(0) ?? 0;
|
const codePoint = char.codePointAt(0) ?? 0;
|
||||||
if (codePoint === 0 || codePoint < 32) return 0;
|
if (isTelegramTableZeroWidthCodePoint(codePoint, char)) return 0;
|
||||||
if (/\p{Mark}/u.test(char)) return 0;
|
const isWide = TELEGRAM_TABLE_WIDE_CODE_POINT_RANGES.some(
|
||||||
if ((codePoint >= 0xfe00 && codePoint <= 0xfe0f) || codePoint === 0x200d)
|
([start, end]) => codePoint >= start && codePoint <= end,
|
||||||
return 0;
|
);
|
||||||
if (
|
return isWide ? 2 : 1;
|
||||||
(codePoint >= 0x1100 && codePoint <= 0x115f) ||
|
|
||||||
codePoint === 0x2329 ||
|
|
||||||
codePoint === 0x232a ||
|
|
||||||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
|
|
||||||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
|
||||||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
|
||||||
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
|
|
||||||
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
|
|
||||||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
|
||||||
(codePoint >= 0xffe0 && codePoint <= 0xffe6)
|
|
||||||
) {
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTelegramTableGraphemes(text: string): string[] {
|
function getTelegramTableGraphemes(text: string): string[] {
|
||||||
|
|||||||
@@ -554,6 +554,10 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|||||||
method: string,
|
method: string,
|
||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
) => Promise<TResponse>;
|
) => Promise<TResponse>;
|
||||||
|
/** Called for every message from the paired owner, before it is dispatched. */
|
||||||
|
onAuthorizedMessage?: (ctx: TContext) => void;
|
||||||
|
/** Called when the first Telegram user is accepted as the bridge owner. */
|
||||||
|
onPaired?: (userId: number, ctx: TContext) => void;
|
||||||
getCurrentInstanceId?: () => string | undefined;
|
getCurrentInstanceId?: () => string | undefined;
|
||||||
getMessageOwnership?: Updates.TelegramMessageOwnershipLookup;
|
getMessageOwnership?: Updates.TelegramMessageOwnershipLookup;
|
||||||
getTargetOwnership?: Updates.TelegramTargetOwnershipLookup;
|
getTargetOwnership?: Updates.TelegramTargetOwnershipLookup;
|
||||||
@@ -2074,6 +2078,7 @@ export function createTelegramInboundRouteRuntime<
|
|||||||
setAllowedUserId: deps.configStore.setAllowedUserId,
|
setAllowedUserId: deps.configStore.setAllowedUserId,
|
||||||
persistConfig: deps.configStore.persist,
|
persistConfig: deps.configStore.persist,
|
||||||
updateStatus: deps.updateStatus,
|
updateStatus: deps.updateStatus,
|
||||||
|
onPaired: deps.onPaired,
|
||||||
removePendingMediaGroupMessages: deps.mediaGroupRuntime.removeMessages,
|
removePendingMediaGroupMessages: deps.mediaGroupRuntime.removeMessages,
|
||||||
removeQueuedTelegramTurnsByMessageIds:
|
removeQueuedTelegramTurnsByMessageIds:
|
||||||
deps.queueMutationRuntime.removeByMessageIds,
|
deps.queueMutationRuntime.removeByMessageIds,
|
||||||
@@ -2086,6 +2091,7 @@ export function createTelegramInboundRouteRuntime<
|
|||||||
handleAuthorizedTelegramCallbackQuery: callbackHandler,
|
handleAuthorizedTelegramCallbackQuery: callbackHandler,
|
||||||
sendTextReply: deps.sendTextReply,
|
sendTextReply: deps.sendTextReply,
|
||||||
handleAuthorizedTelegramMessage: async (message, ctx) => {
|
handleAuthorizedTelegramMessage: async (message, ctx) => {
|
||||||
|
deps.onAuthorizedMessage?.(ctx);
|
||||||
if (typeof message.message_thread_id === "number") {
|
if (typeof message.message_thread_id === "number") {
|
||||||
await deps.handleTelegramThreadTargetObserved?.(
|
await deps.handleTelegramThreadTargetObserved?.(
|
||||||
{
|
{
|
||||||
|
|||||||
+307
-25
@@ -4,6 +4,15 @@
|
|||||||
* Builds usage, cost, and context summaries for the interactive Telegram status view
|
* Builds usage, cost, and context summaries for the interactive Telegram status view
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** Message counters the status shows; structurally satisfied by the traffic domain. */
|
||||||
|
export interface TelegramStatusTraffic {
|
||||||
|
received: number;
|
||||||
|
sent: number;
|
||||||
|
/** Deliveries that failed after the transport retries; absent means none. */
|
||||||
|
failed?: number;
|
||||||
|
lastAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type TelegramStatusQueueLane = "control" | "priority" | "default";
|
export type TelegramStatusQueueLane = "control" | "priority" | "default";
|
||||||
|
|
||||||
export interface TelegramUsageStats {
|
export interface TelegramUsageStats {
|
||||||
@@ -191,6 +200,7 @@ export interface TelegramBridgeStatusLineState {
|
|||||||
instanceSlot?: string;
|
instanceSlot?: string;
|
||||||
instanceThreadName?: string;
|
instanceThreadName?: string;
|
||||||
lockState?: string;
|
lockState?: string;
|
||||||
|
traffic?: TelegramStatusTraffic;
|
||||||
pollingActive: boolean;
|
pollingActive: boolean;
|
||||||
lastUpdateId?: number;
|
lastUpdateId?: number;
|
||||||
activeSourceMessageIds?: number[];
|
activeSourceMessageIds?: number[];
|
||||||
@@ -215,12 +225,28 @@ export interface TelegramStatusBarTheme {
|
|||||||
token: "accent" | "error" | "muted" | "warning" | "success",
|
token: "accent" | "error" | "muted" | "warning" | "success",
|
||||||
text: string,
|
text: string,
|
||||||
) => string;
|
) => string;
|
||||||
|
/**
|
||||||
|
* omp symbol presets (unicode, nerd font, ascii) so the status follows the
|
||||||
|
* user's glyph set. Hosts that do not expose them get the unicode fallbacks.
|
||||||
|
*/
|
||||||
|
status?: {
|
||||||
|
enabled?: string;
|
||||||
|
shadowed?: string;
|
||||||
|
running?: string;
|
||||||
|
warning?: string;
|
||||||
|
error?: string;
|
||||||
|
disabled?: string;
|
||||||
|
};
|
||||||
|
sep?: { dot?: string };
|
||||||
|
icon?: { input?: string; output?: string; time?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TelegramStatusBarState {
|
export interface TelegramStatusBarState {
|
||||||
hasBotToken: boolean;
|
hasBotToken: boolean;
|
||||||
pollingActive: boolean;
|
pollingActive: boolean;
|
||||||
paired: boolean;
|
paired: boolean;
|
||||||
|
botUsername?: string;
|
||||||
|
traffic?: TelegramStatusTraffic;
|
||||||
busRole?: TelegramBridgeBusRole;
|
busRole?: TelegramBridgeBusRole;
|
||||||
busLifecyclePhase?: TelegramBridgeBusLifecyclePhase;
|
busLifecyclePhase?: TelegramBridgeBusLifecyclePhase;
|
||||||
instanceSlot?: string;
|
instanceSlot?: string;
|
||||||
@@ -229,13 +255,22 @@ export interface TelegramStatusBarState {
|
|||||||
processing: boolean;
|
processing: boolean;
|
||||||
processingStatus?: string;
|
processingStatus?: string;
|
||||||
queuedStatus: string;
|
queuedStatus: string;
|
||||||
|
/** Summaries of the queued turns, in dispatch order. */
|
||||||
|
queuedPreview?: string[];
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TelegramStatusRuntimeContext {
|
export interface TelegramStatusRuntimeContext {
|
||||||
|
/** Host run mode; the colored widget is only used in the interactive TUI. */
|
||||||
|
mode?: string;
|
||||||
ui: {
|
ui: {
|
||||||
theme: TelegramStatusBarTheme;
|
theme: TelegramStatusBarTheme;
|
||||||
setStatus: (key: string, text: string) => void;
|
setStatus(key: string, text: string | undefined): void;
|
||||||
|
setWidget?(
|
||||||
|
key: string,
|
||||||
|
content: string[] | undefined,
|
||||||
|
options?: { placement?: "aboveEditor" | "belowEditor" },
|
||||||
|
): void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,6 +307,7 @@ export interface TelegramBridgeStatusRuntimeDeps<
|
|||||||
hasPendingModelSwitch: () => boolean;
|
hasPendingModelSwitch: () => boolean;
|
||||||
getQueuedItems: () => TQueueItem[];
|
getQueuedItems: () => TQueueItem[];
|
||||||
formatQueuedStatus: (items: TQueueItem[]) => string;
|
formatQueuedStatus: (items: TQueueItem[]) => string;
|
||||||
|
getQueuedPreview?: (items: TQueueItem[]) => string[];
|
||||||
getRecentRuntimeEvents: () => TelegramRuntimeEvent[];
|
getRecentRuntimeEvents: () => TelegramRuntimeEvent[];
|
||||||
getRuntimeLockState?: () => string;
|
getRuntimeLockState?: () => string;
|
||||||
getBusRole?: () => TelegramBridgeBusRole | undefined;
|
getBusRole?: () => TelegramBridgeBusRole | undefined;
|
||||||
@@ -298,6 +334,7 @@ export interface TelegramBridgeStatusRuntimeDeps<
|
|||||||
getInstanceSlot?: () => string | undefined;
|
getInstanceSlot?: () => string | undefined;
|
||||||
getInstanceThreadName?: () => string | undefined;
|
getInstanceThreadName?: () => string | undefined;
|
||||||
getNowMs?: () => number;
|
getNowMs?: () => number;
|
||||||
|
getTraffic?: () => TelegramStatusTraffic;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TelegramBridgeStatusLineOptions {
|
export interface TelegramBridgeStatusLineOptions {
|
||||||
@@ -565,13 +602,19 @@ export function createTelegramStatusRuntime<
|
|||||||
const statusKey = deps.statusKey ?? "telegram";
|
const statusKey = deps.statusKey ?? "telegram";
|
||||||
return {
|
return {
|
||||||
updateStatus: (ctx, error) => {
|
updateStatus: (ctx, error) => {
|
||||||
ctx.ui.setStatus(
|
const lines = buildTelegramStatusBarLines(
|
||||||
statusKey,
|
ctx.ui.theme,
|
||||||
buildTelegramStatusBarText(
|
deps.getStatusBarState(ctx, error),
|
||||||
ctx.ui.theme,
|
|
||||||
deps.getStatusBarState(ctx, error),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
if (ctx.mode === "tui" && ctx.ui.setWidget) {
|
||||||
|
// The host strips ANSI from status text, so setStatus would drop the
|
||||||
|
// theme colors. String widgets keep them; clear the plain footer entry
|
||||||
|
// so the state is not shown twice.
|
||||||
|
ctx.ui.setWidget(statusKey, lines, { placement: "belowEditor" });
|
||||||
|
ctx.ui.setStatus(statusKey, undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.ui.setStatus(statusKey, lines[0]);
|
||||||
},
|
},
|
||||||
getStatusLines: (options) =>
|
getStatusLines: (options) =>
|
||||||
buildTelegramBridgeStatusLines(deps.getBridgeStatusLineState(), options),
|
buildTelegramBridgeStatusLines(deps.getBridgeStatusLineState(), options),
|
||||||
@@ -599,6 +642,8 @@ export function createTelegramBridgeStatusRuntime<
|
|||||||
hasBotToken: !!config.botToken,
|
hasBotToken: !!config.botToken,
|
||||||
pollingActive: deps.isPollingActive(),
|
pollingActive: deps.isPollingActive(),
|
||||||
paired: !!config.allowedUserId,
|
paired: !!config.allowedUserId,
|
||||||
|
botUsername: config.botUsername,
|
||||||
|
traffic: deps.getTraffic?.(),
|
||||||
busRole: deps.getBusRole?.(),
|
busRole: deps.getBusRole?.(),
|
||||||
busLifecyclePhase: deps.getBusLifecyclePhase?.(),
|
busLifecyclePhase: deps.getBusLifecyclePhase?.(),
|
||||||
instanceSlot: deps.getInstanceSlot?.(),
|
instanceSlot: deps.getInstanceSlot?.(),
|
||||||
@@ -618,6 +663,7 @@ export function createTelegramBridgeStatusRuntime<
|
|||||||
queuedItems: queuedItems.length,
|
queuedItems: queuedItems.length,
|
||||||
}),
|
}),
|
||||||
queuedStatus: deps.formatQueuedStatus(queuedItems),
|
queuedStatus: deps.formatQueuedStatus(queuedItems),
|
||||||
|
queuedPreview: deps.getQueuedPreview?.(queuedItems),
|
||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -639,6 +685,7 @@ export function createTelegramBridgeStatusRuntime<
|
|||||||
instanceSlot: deps.getInstanceSlot?.(),
|
instanceSlot: deps.getInstanceSlot?.(),
|
||||||
instanceThreadName: deps.getInstanceThreadName?.(),
|
instanceThreadName: deps.getInstanceThreadName?.(),
|
||||||
lockState: deps.getRuntimeLockState?.(),
|
lockState: deps.getRuntimeLockState?.(),
|
||||||
|
traffic: deps.getTraffic?.(),
|
||||||
pollingActive: deps.isPollingActive(),
|
pollingActive: deps.isPollingActive(),
|
||||||
lastUpdateId: config.lastUpdateId,
|
lastUpdateId: config.lastUpdateId,
|
||||||
activeSourceMessageIds: deps.getActiveSourceMessageIds(),
|
activeSourceMessageIds: deps.getActiveSourceMessageIds(),
|
||||||
@@ -761,38 +808,271 @@ function getTelegramStatusBarLabel(state: TelegramStatusBarState): string {
|
|||||||
return threadName;
|
return threadName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TelegramStatusBarGlyphs {
|
||||||
|
enabled: string;
|
||||||
|
shadowed: string;
|
||||||
|
running: string;
|
||||||
|
warning: string;
|
||||||
|
error: string;
|
||||||
|
disabled: string;
|
||||||
|
dot: string;
|
||||||
|
input: string;
|
||||||
|
output: string;
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTelegramStatusBarGlyphs(
|
||||||
|
theme: TelegramStatusBarTheme,
|
||||||
|
): TelegramStatusBarGlyphs {
|
||||||
|
return {
|
||||||
|
enabled: theme.status?.enabled ?? "●",
|
||||||
|
shadowed: theme.status?.shadowed ?? "○",
|
||||||
|
running: theme.status?.running ?? "⟳",
|
||||||
|
warning: theme.status?.warning ?? "⚠",
|
||||||
|
error: theme.status?.error ?? "✘",
|
||||||
|
disabled: theme.status?.disabled ?? "⦸",
|
||||||
|
dot: theme.sep?.dot ?? " · ",
|
||||||
|
input: theme.icon?.input ?? "↓",
|
||||||
|
output: theme.icon?.output ?? "↑",
|
||||||
|
time: theme.icon?.time ?? "⏱",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTelegramStatusBarClock(timestampMs: number): string {
|
||||||
|
const date = new Date(timestampMs);
|
||||||
|
const hours = String(date.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||||
|
return `${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bot name, message counters and last activity, each behind a muted dot. */
|
||||||
|
function buildTelegramStatusBarDetails(
|
||||||
|
theme: TelegramStatusBarTheme,
|
||||||
|
glyphs: TelegramStatusBarGlyphs,
|
||||||
|
state: TelegramStatusBarState,
|
||||||
|
): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (state.botUsername) {
|
||||||
|
parts.push(`@${sanitizeTelegramStatusText(state.botUsername)}`);
|
||||||
|
}
|
||||||
|
if (state.traffic) {
|
||||||
|
const failed = state.traffic.failed
|
||||||
|
? ` ${glyphs.error}${state.traffic.failed}`
|
||||||
|
: "";
|
||||||
|
parts.push(
|
||||||
|
`${glyphs.input}${state.traffic.received} ${glyphs.output}${state.traffic.sent}${failed}`,
|
||||||
|
);
|
||||||
|
if (state.traffic.lastAtMs !== undefined) {
|
||||||
|
parts.push(
|
||||||
|
`${glyphs.time} ${formatTelegramStatusBarClock(state.traffic.lastAtMs)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
.map((part) => theme.fg("muted", `${glyphs.dot}${part}`))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTelegramTrafficLines(
|
||||||
|
state: Pick<TelegramBridgeStatusLineState, "traffic">,
|
||||||
|
): string[] {
|
||||||
|
if (!state.traffic) return [];
|
||||||
|
const last =
|
||||||
|
state.traffic.lastAtMs === undefined
|
||||||
|
? ""
|
||||||
|
: `, last ${formatTelegramStatusBarClock(state.traffic.lastAtMs)}`;
|
||||||
|
return [
|
||||||
|
`- messages: ${state.traffic.received} received, ${state.traffic.sent} sent${state.traffic.failed ? `, ${state.traffic.failed} failed` : ""}${last}`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip control characters and bidi overrides. Widget lines bypass the host's
|
||||||
|
* status sanitizer, and thread names, bot names, queue summaries and error text
|
||||||
|
* can all originate from Telegram, so none may carry terminal escapes.
|
||||||
|
*/
|
||||||
|
export function sanitizeTelegramStatusText(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(
|
||||||
|
/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g,
|
||||||
|
" ",
|
||||||
|
)
|
||||||
|
.replace(/ +/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelegramStatusErrorSummary {
|
||||||
|
/** Short state text such as "invalid token". */
|
||||||
|
label: string;
|
||||||
|
/** What the operator can do next, or "retrying" while the bridge retries. */
|
||||||
|
hint?: string;
|
||||||
|
/** fatal needs the operator, attention needs a look, retrying heals itself. */
|
||||||
|
kind: "fatal" | "attention" | "retrying";
|
||||||
|
/**
|
||||||
|
* The failed call may still have reached Telegram: a non-idempotent send hit
|
||||||
|
* a transport failure after the request left, so the outcome is unknown.
|
||||||
|
*/
|
||||||
|
deliveryUnknown?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a raw transport or API failure into a short state and next step.
|
||||||
|
* Returns undefined for anything unrecognized so the raw text stays visible.
|
||||||
|
*/
|
||||||
|
export function summarizeTelegramStatusError(
|
||||||
|
message: string,
|
||||||
|
): TelegramStatusErrorSummary | undefined {
|
||||||
|
if (/may have committed before transport failed/i.test(message)) {
|
||||||
|
return {
|
||||||
|
kind: "attention",
|
||||||
|
label: "delivery unconfirmed",
|
||||||
|
deliveryUnknown: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (/HTTP 401\b|\bUnauthorized\b/i.test(message)) {
|
||||||
|
return { kind: "fatal", label: "invalid token", hint: "/telegram-setup" };
|
||||||
|
}
|
||||||
|
if (/HTTP 429\b|Too Many Requests|retry[ _]after/i.test(message)) {
|
||||||
|
const seconds = /retry[ _]after[:= ]+(\d+)/i.exec(message)?.[1];
|
||||||
|
return {
|
||||||
|
kind: "retrying",
|
||||||
|
label: seconds ? `rate limited ${seconds}s` : "rate limited",
|
||||||
|
hint: "retrying",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/HTTP 409\b|\bConflict\b|terminated by other getUpdates/i.test(message)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: "attention",
|
||||||
|
label: "another poller is active",
|
||||||
|
hint: "/telegram-status",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/HTTP 5\d\d\b|Bad Gateway|Service Unavailable|Internal Server Error/i.test(
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return { kind: "retrying", label: "Telegram unavailable", hint: "retrying" };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/Unable to connect|ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|fetch failed|network|timed? out|socket hang up/i.test(
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return { kind: "retrying", label: "offline", hint: "retrying" };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTelegramStatusBarError(
|
||||||
|
theme: TelegramStatusBarTheme,
|
||||||
|
glyphs: TelegramStatusBarGlyphs,
|
||||||
|
label: string,
|
||||||
|
error: string,
|
||||||
|
): string {
|
||||||
|
const summary = summarizeTelegramStatusError(error);
|
||||||
|
if (!summary) {
|
||||||
|
const raw = sanitizeTelegramStatusText(error);
|
||||||
|
const shown = raw.length > 100 ? `${raw.slice(0, 99)}…` : raw;
|
||||||
|
return `${label} ${theme.fg("error", `${glyphs.error} error`)} ${theme.fg("muted", shown)}`;
|
||||||
|
}
|
||||||
|
const token = summary.kind === "fatal" ? "error" : "warning";
|
||||||
|
const glyph =
|
||||||
|
summary.kind === "fatal"
|
||||||
|
? glyphs.error
|
||||||
|
: summary.kind === "retrying"
|
||||||
|
? glyphs.running
|
||||||
|
: glyphs.warning;
|
||||||
|
const hint = summary.hint
|
||||||
|
? theme.fg("muted", `${glyphs.dot}${summary.hint}`)
|
||||||
|
: "";
|
||||||
|
return `${label} ${theme.fg(token, `${glyph} ${summary.label}`)}${hint}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildTelegramStatusBarText(
|
export function buildTelegramStatusBarText(
|
||||||
theme: TelegramStatusBarTheme,
|
theme: TelegramStatusBarTheme,
|
||||||
state: TelegramStatusBarState,
|
state: TelegramStatusBarState,
|
||||||
): string {
|
): string {
|
||||||
const label = theme.fg("accent", getTelegramStatusBarLabel(state));
|
const glyphs = resolveTelegramStatusBarGlyphs(theme);
|
||||||
if (state.error) {
|
const label = theme.fg(
|
||||||
return `${label} ${theme.fg("error", "error")} ${theme.fg("muted", state.error)}`;
|
"accent",
|
||||||
}
|
sanitizeTelegramStatusText(getTelegramStatusBarLabel(state)),
|
||||||
|
);
|
||||||
|
const hint = (text: string) => theme.fg("muted", `${glyphs.dot}${text}`);
|
||||||
const queued = state.queuedStatus
|
const queued = state.queuedStatus
|
||||||
? theme.fg("success", state.queuedStatus)
|
? theme.fg("success", state.queuedStatus)
|
||||||
: "";
|
: "";
|
||||||
if (!state.hasBotToken)
|
if (state.error) {
|
||||||
return `${label} ${theme.fg("muted", "not configured")}${queued}`;
|
return formatTelegramStatusBarError(theme, glyphs, label, state.error);
|
||||||
if (!state.paired)
|
}
|
||||||
return `${label} ${theme.fg("warning", "awaiting pairing")}${queued}`;
|
if (!state.hasBotToken) {
|
||||||
if (state.busLifecyclePhase === "electing")
|
return `${label} ${theme.fg("muted", `${glyphs.disabled} not configured`)}${queued}${hint("/telegram-setup")}`;
|
||||||
return `${label} ${theme.fg("warning", "electing")}${queued}`;
|
}
|
||||||
if (!state.pollingActive && state.busRole !== "follower")
|
if (!state.paired) {
|
||||||
return `${theme.fg("accent", "telegram")} ${theme.fg("muted", "disconnected")}${queued}`;
|
const bot = state.botUsername
|
||||||
|
? `@${sanitizeTelegramStatusText(state.botUsername)}`
|
||||||
|
: "the bot";
|
||||||
|
return `${label} ${theme.fg("warning", `${glyphs.warning} awaiting pairing`)}${queued}${hint(`send /start to ${bot}`)}`;
|
||||||
|
}
|
||||||
|
if (state.busLifecyclePhase === "electing") {
|
||||||
|
return `${label} ${theme.fg("warning", `${glyphs.running} electing`)}${queued}`;
|
||||||
|
}
|
||||||
|
if (!state.pollingActive && state.busRole !== "follower") {
|
||||||
|
return `${theme.fg("accent", "telegram")} ${theme.fg("muted", `${glyphs.shadowed} disconnected`)}${queued}${hint("/telegram-connect")}`;
|
||||||
|
}
|
||||||
|
const details = buildTelegramStatusBarDetails(theme, glyphs, state);
|
||||||
if (state.processing) {
|
if (state.processing) {
|
||||||
const processingStatus = state.queuedStatus
|
const processingStatus = state.queuedStatus
|
||||||
? "active"
|
? "active"
|
||||||
: (state.processingStatus ?? "processing");
|
: (state.processingStatus ?? "processing");
|
||||||
const processingToken =
|
const processingToken =
|
||||||
processingStatus === "active" ? "warning" : "accent";
|
processingStatus === "active" ? "warning" : "accent";
|
||||||
return `${label} ${theme.fg(processingToken, processingStatus)}${queued}`;
|
return `${label} ${theme.fg(processingToken, `${glyphs.running} ${processingStatus}`)}${queued}${details}`;
|
||||||
}
|
}
|
||||||
if (state.busRole === "follower")
|
const role = state.busRole ?? "connected";
|
||||||
return `${label} ${theme.fg("success", "follower")}${queued}`;
|
return `${label} ${theme.fg("success", `${glyphs.enabled} ${role}`)}${queued}${details}`;
|
||||||
if (state.busRole === "leader")
|
}
|
||||||
return `${label} ${theme.fg("success", "leader")}`;
|
|
||||||
return `${label} ${theme.fg("success", "connected")}`;
|
/** Up to two queued turns and a remainder count, only while the bridge is up. */
|
||||||
|
function buildTelegramStatusBarQueueLine(
|
||||||
|
theme: TelegramStatusBarTheme,
|
||||||
|
state: TelegramStatusBarState,
|
||||||
|
): string | undefined {
|
||||||
|
const operational =
|
||||||
|
state.hasBotToken &&
|
||||||
|
state.paired &&
|
||||||
|
!state.error &&
|
||||||
|
state.busLifecyclePhase !== "electing" &&
|
||||||
|
(state.pollingActive || state.busRole === "follower");
|
||||||
|
const preview = (state.queuedPreview ?? [])
|
||||||
|
.map(sanitizeTelegramStatusText)
|
||||||
|
.filter((summary) => summary.length > 0);
|
||||||
|
if (!operational || preview.length === 0) return undefined;
|
||||||
|
const glyphs = resolveTelegramStatusBarGlyphs(theme);
|
||||||
|
const shown = preview.slice(0, 2).map((summary) => `"${summary}"`);
|
||||||
|
const more =
|
||||||
|
preview.length > 2 ? `${glyphs.dot}+${preview.length - 2} more` : "";
|
||||||
|
return theme.fg("muted", ` next: ${shown.join(glyphs.dot)}${more}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The main status line, plus a queue preview line when turns are waiting. */
|
||||||
|
export function buildTelegramStatusBarLines(
|
||||||
|
theme: TelegramStatusBarTheme,
|
||||||
|
state: TelegramStatusBarState,
|
||||||
|
): string[] {
|
||||||
|
const queueLine = buildTelegramStatusBarQueueLine(theme, state);
|
||||||
|
return [
|
||||||
|
buildTelegramStatusBarText(theme, state),
|
||||||
|
...(queueLine ? [queueLine] : []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Host spinner text while a turn that arrived from Telegram is running. */
|
||||||
|
export function formatTelegramWorkingMessage(summary: string): string {
|
||||||
|
const text = sanitizeTelegramStatusText(summary);
|
||||||
|
return text ? `Answering Telegram · "${text}"` : "Answering Telegram";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTelegramBridgeBotStatus(
|
function formatTelegramBridgeBotStatus(
|
||||||
@@ -1079,6 +1359,7 @@ function buildTelegramBridgeCompactStatusLines(
|
|||||||
`- bot: ${formatTelegramBridgeBotStatus(state)}`,
|
`- bot: ${formatTelegramBridgeBotStatus(state)}`,
|
||||||
...(state.activeProfileName ? [`- profile: ${state.activeProfileName}`] : []),
|
...(state.activeProfileName ? [`- profile: ${state.activeProfileName}`] : []),
|
||||||
`- user: ${state.allowedUserId ?? "not paired"}`,
|
`- user: ${state.allowedUserId ?? "not paired"}`,
|
||||||
|
...buildTelegramTrafficLines(state),
|
||||||
...(state.botThreadMode ? [`- thread mode: ${state.botThreadMode}`] : []),
|
...(state.botThreadMode ? [`- thread mode: ${state.botThreadMode}`] : []),
|
||||||
...(state.busRole ? [`- role: ${state.busRole}`] : []),
|
...(state.busRole ? [`- role: ${state.busRole}`] : []),
|
||||||
...(state.busLifecyclePhase
|
...(state.busLifecyclePhase
|
||||||
@@ -1134,6 +1415,7 @@ export function buildTelegramBridgeDiagnosticStatusLines(
|
|||||||
`- bot: ${formatTelegramBridgeBotStatus(state)}`,
|
`- bot: ${formatTelegramBridgeBotStatus(state)}`,
|
||||||
...(state.activeProfileName ? [`- profile: ${state.activeProfileName}`] : []),
|
...(state.activeProfileName ? [`- profile: ${state.activeProfileName}`] : []),
|
||||||
`- allowed user: ${state.allowedUserId ?? "not paired"}`,
|
`- allowed user: ${state.allowedUserId ?? "not paired"}`,
|
||||||
|
...buildTelegramTrafficLines(state),
|
||||||
...(state.botThreadMode
|
...(state.botThreadMode
|
||||||
? [
|
? [
|
||||||
`- thread mode: ${state.botThreadMode}${state.botThreadModeAction ? ` reconcile=${state.botThreadModeAction}` : ""}`,
|
`- thread mode: ${state.botThreadMode}${state.botThreadModeAction ? ` reconcile=${state.botThreadModeAction}` : ""}`,
|
||||||
|
|||||||
+26
-10
@@ -375,6 +375,10 @@ export interface TelegramBridgeApiRuntimeDeps {
|
|||||||
error: unknown,
|
error: unknown,
|
||||||
details?: Record<string, unknown>,
|
details?: Record<string, unknown>,
|
||||||
) => void;
|
) => void;
|
||||||
|
/** Called after a Bot API call or multipart upload succeeded. */
|
||||||
|
onCallSucceeded?: (method: string) => void;
|
||||||
|
/** Called after a Bot API call or multipart upload failed for good. */
|
||||||
|
onCallFailed?: (method: string, error: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TelegramBridgeApiRuntime {
|
export interface TelegramBridgeApiRuntime {
|
||||||
@@ -1246,6 +1250,8 @@ export function createTelegramAssistantDraftSender(deps: {
|
|||||||
export function createDefaultTelegramBridgeApiRuntime(deps: {
|
export function createDefaultTelegramBridgeApiRuntime(deps: {
|
||||||
getBotToken: () => string | undefined;
|
getBotToken: () => string | undefined;
|
||||||
recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
|
recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
|
||||||
|
onCallSucceeded?: TelegramBridgeApiRuntimeDeps["onCallSucceeded"];
|
||||||
|
onCallFailed?: TelegramBridgeApiRuntimeDeps["onCallFailed"];
|
||||||
}): TelegramBridgeApiRuntime {
|
}): TelegramBridgeApiRuntime {
|
||||||
return createTelegramBridgeApiRuntime({
|
return createTelegramBridgeApiRuntime({
|
||||||
client: createTelegramApiClient(deps.getBotToken, {
|
client: createTelegramApiClient(deps.getBotToken, {
|
||||||
@@ -1255,6 +1261,8 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
|
|||||||
maxFileSizeBytes: TELEGRAM_INBOUND_FILE_MAX_BYTES,
|
maxFileSizeBytes: TELEGRAM_INBOUND_FILE_MAX_BYTES,
|
||||||
tempFileMaxAgeMs: TELEGRAM_TEMP_FILE_MAX_AGE_MS,
|
tempFileMaxAgeMs: TELEGRAM_TEMP_FILE_MAX_AGE_MS,
|
||||||
recordRuntimeEvent: deps.recordRuntimeEvent,
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
||||||
|
onCallSucceeded: deps.onCallSucceeded,
|
||||||
|
onCallFailed: deps.onCallFailed,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1266,16 +1274,20 @@ export function createTelegramBridgeApiRuntime(
|
|||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
options?: TelegramApiCallOptions,
|
options?: TelegramApiCallOptions,
|
||||||
): Promise<TResponse> => {
|
): Promise<TResponse> => {
|
||||||
|
let response: TResponse;
|
||||||
try {
|
try {
|
||||||
return await deps.client.call<TResponse>(method, body, options);
|
response = await deps.client.call<TResponse>(method, body, options);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
deps.recordRuntimeEvent(
|
deps.recordRuntimeEvent(
|
||||||
"api",
|
"api",
|
||||||
error,
|
error,
|
||||||
withTelegramTransportDiagnostics(error, { method }),
|
withTelegramTransportDiagnostics(error, { method }),
|
||||||
);
|
);
|
||||||
|
deps.onCallFailed?.(method, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
deps.onCallSucceeded?.(method);
|
||||||
|
return response;
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
call: callRecorded,
|
call: callRecorded,
|
||||||
@@ -1285,16 +1297,17 @@ export function createTelegramBridgeApiRuntime(
|
|||||||
* photos, documents, animations, etc.).
|
* photos, documents, animations, etc.).
|
||||||
* Errors are recorded under the "multipart" category for diagnostics.
|
* Errors are recorded under the "multipart" category for diagnostics.
|
||||||
*/
|
*/
|
||||||
callMultipart: async (
|
callMultipart: async <TResponse>(
|
||||||
method,
|
method: string,
|
||||||
fields,
|
fields: Record<string, string>,
|
||||||
fileField,
|
fileField: string,
|
||||||
filePath,
|
filePath: string,
|
||||||
fileName,
|
fileName: string,
|
||||||
options,
|
options?: TelegramApiCallOptions,
|
||||||
) => {
|
): Promise<TResponse> => {
|
||||||
|
let response: TResponse;
|
||||||
try {
|
try {
|
||||||
return await deps.client.callMultipart(
|
response = await deps.client.callMultipart<TResponse>(
|
||||||
method,
|
method,
|
||||||
fields,
|
fields,
|
||||||
fileField,
|
fileField,
|
||||||
@@ -1308,8 +1321,11 @@ export function createTelegramBridgeApiRuntime(
|
|||||||
error,
|
error,
|
||||||
withTelegramTransportDiagnostics(error, { method, fileName }),
|
withTelegramTransportDiagnostics(error, { method, fileName }),
|
||||||
);
|
);
|
||||||
|
deps.onCallFailed?.(method, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
deps.onCallSucceeded?.(method);
|
||||||
|
return response;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -850,6 +850,7 @@ export function createTelegramPairedUpdateRuntime<
|
|||||||
setAllowedUserId: deps.setAllowedUserId,
|
setAllowedUserId: deps.setAllowedUserId,
|
||||||
persistConfig: deps.persistConfig,
|
persistConfig: deps.persistConfig,
|
||||||
updateStatus: deps.updateStatus,
|
updateStatus: deps.updateStatus,
|
||||||
|
onPaired: deps.onPaired,
|
||||||
}).pairIfNeeded,
|
}).pairIfNeeded,
|
||||||
answerCallbackQuery: deps.answerCallbackQuery,
|
answerCallbackQuery: deps.answerCallbackQuery,
|
||||||
answerGuestQuery: deps.answerGuestQuery,
|
answerGuestQuery: deps.answerGuestQuery,
|
||||||
|
|||||||
Generated
+2
-2
@@ -9,8 +9,8 @@
|
|||||||
"version": "0.22.0-evandro.1",
|
"version": "0.22.0-evandro.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "latest",
|
"@types/node": "26.1.1",
|
||||||
"typescript": "latest"
|
"typescript": "7.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.19.0"
|
"node": ">=22.19.0"
|
||||||
|
|||||||
+2
-2
@@ -77,8 +77,8 @@
|
|||||||
"@sinclair/typebox": "*"
|
"@sinclair/typebox": "*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "latest",
|
"@types/node": "26.1.1",
|
||||||
"typescript": "latest"
|
"typescript": "7.0.2"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"protobufjs": "7.6.6",
|
"protobufjs": "7.6.6",
|
||||||
|
|||||||
@@ -350,3 +350,47 @@ test("Command templates report missing required placeholders", () => {
|
|||||||
/Missing command template value: missing/,
|
/Missing command template value: missing/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Command template risk labels cover each detection branch and its inverse", () => {
|
||||||
|
const cases: Array<[string, string[]]> = [
|
||||||
|
["echo hello", []],
|
||||||
|
["bash script.sh", ["risk.shell"]],
|
||||||
|
["sh -c {script}", ["risk.shell", "risk.eval"]],
|
||||||
|
["node app.js", []],
|
||||||
|
["node -e {code}", ["risk.eval"]],
|
||||||
|
["bun --eval {code}", ["risk.eval"]],
|
||||||
|
["python3 script.py", []],
|
||||||
|
["python3 -c {code}", ["risk.eval"]],
|
||||||
|
["ruby -e {code}", ["risk.eval"]],
|
||||||
|
["rm file.txt", []],
|
||||||
|
["rm -rf build", ["risk.destructive_fs"]],
|
||||||
|
["rm -f build", ["risk.destructive_fs"]],
|
||||||
|
["rm /etc/hosts", ["risk.destructive_fs"]],
|
||||||
|
["mv a.txt b.txt", []],
|
||||||
|
["cp a.txt ~/b.txt", ["risk.broad_fs_write"]],
|
||||||
|
["rsync a b", ["risk.network"]],
|
||||||
|
["rsync a /dst", ["risk.broad_fs_write", "risk.network"]],
|
||||||
|
["curl https://example.com", ["risk.network"]],
|
||||||
|
["git status", []],
|
||||||
|
["git clone {url}", ["risk.network"]],
|
||||||
|
["git push origin main", ["risk.external_side_effect", "risk.network"]],
|
||||||
|
["npm install", ["risk.network"]],
|
||||||
|
["npm publish", ["risk.external_side_effect", "risk.network"]],
|
||||||
|
["kubectl get pods", ["risk.external_side_effect"]],
|
||||||
|
["sleep 5", ["risk.long_running"]],
|
||||||
|
["tail file.log", []],
|
||||||
|
["tail -f file.log", ["risk.long_running"]],
|
||||||
|
["tool --serve", ["risk.long_running"]],
|
||||||
|
["systemctl restart x", ["risk.platform_specific"]],
|
||||||
|
["gpg --decrypt {file}", ["risk.secret_touching"]],
|
||||||
|
["tool --api-key {key}", ["risk.secret_touching"]],
|
||||||
|
["tool --name value", []],
|
||||||
|
];
|
||||||
|
for (const [template, expected] of cases) {
|
||||||
|
assert.deepEqual(
|
||||||
|
getCommandTemplateRiskLabels(template),
|
||||||
|
expected,
|
||||||
|
template,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -683,6 +683,29 @@ test("Telegram config pairing runtime binds config and status ports", async () =
|
|||||||
assert.deepEqual(events, ["set:7", "persist", "status:ctx"]);
|
assert.deepEqual(events, ["set:7", "persist", "status:ctx"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Telegram config pairing runtime reports the new owner only after persisting", async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
let allowedUserId: number | undefined;
|
||||||
|
const runtime = createTelegramUserPairingRuntime({
|
||||||
|
getAllowedUserId: () => allowedUserId,
|
||||||
|
setAllowedUserId: (userId) => {
|
||||||
|
allowedUserId = userId;
|
||||||
|
},
|
||||||
|
persistConfig: async () => {
|
||||||
|
events.push("persist");
|
||||||
|
},
|
||||||
|
updateStatus: () => {
|
||||||
|
events.push("status");
|
||||||
|
},
|
||||||
|
onPaired: (userId, ctx: string) => {
|
||||||
|
events.push(`paired:${userId}:${ctx}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(await runtime.pairIfNeeded(7, "ctx"), true);
|
||||||
|
assert.equal(await runtime.pairIfNeeded(8, "ctx"), false);
|
||||||
|
assert.deepEqual(events, ["persist", "status", "paired:7:ctx"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("Bot token input prefers stored config over env vars", () => {
|
test("Bot token input prefers stored config over env vars", () => {
|
||||||
const value = getTelegramBotTokenInputDefault(
|
const value = getTelegramBotTokenInputDefault(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -261,3 +261,109 @@ test("Settings runtime opens menus and applies stale-message fallback toggles",
|
|||||||
"answer:Time injection: hidden",
|
"answer:Time injection: hidden",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createSettingsCallbackHarness() {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const deps = {
|
||||||
|
isProactivePushEnabled: () => false,
|
||||||
|
getVoiceReplyMode: () => "manual" as const,
|
||||||
|
isVoiceReplyModeConfigured: () => true,
|
||||||
|
getTimeInjectionMode: () => "hidden" as const,
|
||||||
|
areDraftPreviewsEnabled: () => false,
|
||||||
|
getAssistantRenderingMode: () => "rich" as const,
|
||||||
|
setProactivePushEnabled: async (enabled: boolean) => {
|
||||||
|
calls.push(`proactive:${enabled}`);
|
||||||
|
},
|
||||||
|
setDraftPreviewsEnabled: async (enabled: boolean) => {
|
||||||
|
calls.push(`draft-previews:${enabled}`);
|
||||||
|
},
|
||||||
|
setAssistantRenderingMode: async (mode: "rich" | "html") => {
|
||||||
|
calls.push(`rendering:${mode}`);
|
||||||
|
},
|
||||||
|
setVoiceReplyMode: async (
|
||||||
|
mode: "manual" | "mirror" | "always" | undefined,
|
||||||
|
) => {
|
||||||
|
calls.push(`voice:${mode ?? "hidden"}`);
|
||||||
|
},
|
||||||
|
setTimeInjectionMode: async (mode: "hidden" | "always" | "interval") => {
|
||||||
|
calls.push(`time:${mode}`);
|
||||||
|
},
|
||||||
|
updateSettingsMessage: async (text: string) => {
|
||||||
|
calls.push(`update:${text.split("\n")[0]}`);
|
||||||
|
},
|
||||||
|
answerCallbackQuery: async (_id: string, text?: string) => {
|
||||||
|
calls.push(`answer:${text ?? ""}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { calls, deps };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Settings callback action opens each page, including legacy aliases", async () => {
|
||||||
|
const pages: Array<[string, string]> = [
|
||||||
|
["settings:list", "update:<b>⚙️ Settings:</b>"],
|
||||||
|
["settings:open:proactive", "update:<b>📌 Proactive push:</b> <code>off</code>"],
|
||||||
|
["settings:open:draft-previews", "update:<b>📝 Draft previews:</b> <code>off</code>"],
|
||||||
|
["settings:open:rich-drafts", "update:<b>📝 Draft previews:</b> <code>off</code>"],
|
||||||
|
["settings:open:assistant-rendering", "update:<b>🧾 Assistant rendering:</b> <code>rich</code>"],
|
||||||
|
["settings:open:voice-reply", "update:<b>👄 Voice reply mode:</b> <code>manual</code>"],
|
||||||
|
["settings:open:time-injection", "update:<b>🕒 Time injection mode:</b> <code>hidden</code>"],
|
||||||
|
["settings:open:time", "update:<b>🕒 Time injection mode:</b> <code>hidden</code>"],
|
||||||
|
];
|
||||||
|
for (const [data, update] of pages) {
|
||||||
|
const { calls, deps } = createSettingsCallbackHarness();
|
||||||
|
assert.equal(
|
||||||
|
await handleTelegramSettingsMenuCallbackAction("q", data, deps),
|
||||||
|
true,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls, [update, "answer:"], data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Settings callback action applies each mode and the inverse branch of each toggle", async () => {
|
||||||
|
const cases: Array<[string, string[]]> = [
|
||||||
|
["settings:set:voice-reply:mirror", ["voice:mirror", "update:<b>👄 Voice reply mode:</b> <code>manual</code>", "answer:Voice reply mode: mirror"]],
|
||||||
|
["settings:set:voice-reply:always", ["voice:always", "update:<b>👄 Voice reply mode:</b> <code>manual</code>", "answer:Voice reply mode: always"]],
|
||||||
|
["settings:set:time-injection:interval", ["time:interval", "update:<b>🕒 Time injection mode:</b> <code>hidden</code>", "answer:Time injection: interval"]],
|
||||||
|
["settings:set:time-injection:off", ["time:hidden", "update:<b>🕒 Time injection mode:</b> <code>hidden</code>", "answer:Time injection: hidden"]],
|
||||||
|
["settings:set:time:always", ["time:always", "update:<b>🕒 Time injection mode:</b> <code>hidden</code>", "answer:Time injection: always"]],
|
||||||
|
["settings:set:draft-previews:off", ["draft-previews:false", "update:<b>📝 Draft previews:</b> <code>off</code>", "answer:Draft previews disabled"]],
|
||||||
|
["settings:set:rich-drafts:on", ["draft-previews:true", "update:<b>📝 Draft previews:</b> <code>off</code>", "answer:Draft previews enabled"]],
|
||||||
|
["settings:set:assistant-rendering:rich", ["rendering:rich", "update:<b>🧾 Assistant rendering:</b> <code>rich</code>", "answer:Rendering: rich"]],
|
||||||
|
["settings:set:proactive:off", ["proactive:false", "update:<b>📌 Proactive push:</b> <code>off</code>", "answer:Proactive push disabled"]],
|
||||||
|
];
|
||||||
|
for (const [data, expected] of cases) {
|
||||||
|
const { calls, deps } = createSettingsCallbackHarness();
|
||||||
|
assert.equal(
|
||||||
|
await handleTelegramSettingsMenuCallbackAction("q", data, deps),
|
||||||
|
true,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls, expected, data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Settings callback action only acknowledges invalid or unknown settings values", async () => {
|
||||||
|
for (const data of [
|
||||||
|
"settings:set:voice-reply:bogus",
|
||||||
|
"settings:set:time:bogus",
|
||||||
|
"settings:set:assistant-rendering:bogus",
|
||||||
|
"settings:set:draft-previews:maybe",
|
||||||
|
"settings:set:proactive:maybe",
|
||||||
|
"settings:unknown",
|
||||||
|
]) {
|
||||||
|
const { calls, deps } = createSettingsCallbackHarness();
|
||||||
|
assert.equal(
|
||||||
|
await handleTelegramSettingsMenuCallbackAction("q", data, deps),
|
||||||
|
true,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls, ["answer:"], data);
|
||||||
|
}
|
||||||
|
const { calls, deps } = createSettingsCallbackHarness();
|
||||||
|
assert.equal(
|
||||||
|
await handleTelegramSettingsMenuCallbackAction("q", undefined, deps),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
@@ -2394,3 +2394,45 @@ test("Settings menu marks one-line on/off checkbox controls symmetrically", () =
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Menu callback parser maps every status alias and model action, and ignores the rest", () => {
|
||||||
|
for (const action of ["model", "thinking", "queue", "settings"] as const) {
|
||||||
|
for (const prefix of ["menu", "status"]) {
|
||||||
|
assert.deepEqual(parseTelegramMenuCallbackAction(`${prefix}:${action}`), {
|
||||||
|
kind: "status",
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const action of [
|
||||||
|
"noop",
|
||||||
|
"scope",
|
||||||
|
"page",
|
||||||
|
"pages",
|
||||||
|
"open",
|
||||||
|
"pick",
|
||||||
|
"pick-selected",
|
||||||
|
"scope-enable",
|
||||||
|
"scope-disable",
|
||||||
|
"scope-toggle",
|
||||||
|
]) {
|
||||||
|
assert.deepEqual(parseTelegramMenuCallbackAction(`model:${action}:7`), {
|
||||||
|
kind: "model",
|
||||||
|
action,
|
||||||
|
value: "7",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert.deepEqual(parseTelegramMenuCallbackAction("thinking:set:"), {
|
||||||
|
kind: "thinking:set",
|
||||||
|
level: "",
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseTelegramMenuCallbackAction("model:unknown:1"), {
|
||||||
|
kind: "ignore",
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseTelegramMenuCallbackAction("menu:other"), {
|
||||||
|
kind: "ignore",
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseTelegramMenuCallbackAction(undefined), {
|
||||||
|
kind: "ignore",
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]/);
|
||||||
|
});
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
hasExtensionContextPendingMessages,
|
hasExtensionContextPendingMessages,
|
||||||
isExtensionContextIdle,
|
isExtensionContextIdle,
|
||||||
isExtensionContextPassiveRunMode,
|
isExtensionContextPassiveRunMode,
|
||||||
|
notifyExtensionContext,
|
||||||
|
setExtensionWorkingMessage,
|
||||||
} from "../lib/pi.ts";
|
} from "../lib/pi.ts";
|
||||||
|
|
||||||
type PiRuntimeApiHarness = Parameters<
|
type PiRuntimeApiHarness = Parameters<
|
||||||
@@ -169,3 +171,24 @@ test("Pi context model prefers omp's live models facade over a frozen ctx.model"
|
|||||||
assert.equal(getExtensionContextModel(ompCtx), live);
|
assert.equal(getExtensionContextModel(ompCtx), live);
|
||||||
assert.equal(getExtensionContextModel(piCtx), frozen);
|
assert.equal(getExtensionContextModel(piCtx), frozen);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Host UI helpers forward to the context and tolerate a host without a spinner API", () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const full = {
|
||||||
|
ui: {
|
||||||
|
notify: (message: string, level: string) => {
|
||||||
|
calls.push(`notify:${level}:${message}`);
|
||||||
|
},
|
||||||
|
setWorkingMessage: (message?: string) => {
|
||||||
|
calls.push(`working:${message}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as ExtensionContext;
|
||||||
|
notifyExtensionContext(full, "hello", "warning");
|
||||||
|
setExtensionWorkingMessage(full, "busy");
|
||||||
|
setExtensionWorkingMessage(full, undefined);
|
||||||
|
assert.deepEqual(calls, ["notify:warning:hello", "working:busy", "working:undefined"]);
|
||||||
|
|
||||||
|
const withoutSpinner = { ui: {} } as unknown as ExtensionContext;
|
||||||
|
assert.doesNotThrow(() => setExtensionWorkingMessage(withoutSpinner, "busy"));
|
||||||
|
});
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtempSync, readFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +32,7 @@ import {
|
|||||||
stopTelegramPollingRuntime,
|
stopTelegramPollingRuntime,
|
||||||
TELEGRAM_ALLOWED_UPDATES,
|
TELEGRAM_ALLOWED_UPDATES,
|
||||||
} from "../lib/polling.ts";
|
} from "../lib/polling.ts";
|
||||||
|
import { createTelegramConfigStore } from "../lib/config.ts";
|
||||||
|
|
||||||
const TEST_CONTEXT = "ctx";
|
const TEST_CONTEXT = "ctx";
|
||||||
|
|
||||||
@@ -601,6 +605,103 @@ test("Poll loop runner binds config, status, and transport ports", async () => {
|
|||||||
assert.deepEqual(events, ["deleteWebhook", "handle:ctx:6", "persist:6"]);
|
assert.deepEqual(events, ["deleteWebhook", "handle:ctx:6", "persist:6"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Poll loop keeps the paired user when the offset is committed after the initial sync replaced the stored config", async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "telegram-pairing-"));
|
||||||
|
const configPath = join(dir, "telegram.json");
|
||||||
|
const store = createTelegramConfigStore({ agentDir: dir, configPath });
|
||||||
|
await store.load();
|
||||||
|
store.update((config) => {
|
||||||
|
config.botToken = "123:abc";
|
||||||
|
});
|
||||||
|
await store.persist();
|
||||||
|
let calls = 0;
|
||||||
|
const runPollLoop = createTelegramPollLoopRunner({
|
||||||
|
getConfig: store.get,
|
||||||
|
deleteWebhook: async () => {},
|
||||||
|
getUpdates: async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) return [{ update_id: 1 }]; // initial sync
|
||||||
|
if (calls === 2) return [{ update_id: 2 }]; // the owner's /start
|
||||||
|
throw new DOMException("stop", "AbortError");
|
||||||
|
},
|
||||||
|
persistConfig: store.persist,
|
||||||
|
handleUpdate: async () => {
|
||||||
|
store.setAllowedUserId(777);
|
||||||
|
await store.persist();
|
||||||
|
},
|
||||||
|
updateStatus: () => {},
|
||||||
|
sleep: async () => {},
|
||||||
|
});
|
||||||
|
await runPollLoop("ctx", new AbortController().signal);
|
||||||
|
assert.equal(store.getAllowedUserId(), 777);
|
||||||
|
const saved = JSON.parse(readFileSync(configPath, "utf8"));
|
||||||
|
assert.equal(saved.allowedUserId, 777);
|
||||||
|
assert.equal(saved.lastUpdateId, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Poll loop reports recovery once, only after a failed attempt", async () => {
|
||||||
|
const run = async (failures: number): Promise<string[]> => {
|
||||||
|
const events: string[] = [];
|
||||||
|
let calls = 0;
|
||||||
|
const runPollLoop = createTelegramPollLoopRunner({
|
||||||
|
getConfig: () => ({ botToken: "123:abc", lastUpdateId: 1 }),
|
||||||
|
deleteWebhook: async () => {},
|
||||||
|
getUpdates: async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls <= failures) throw new Error("network down");
|
||||||
|
if (calls <= failures + 2) return [];
|
||||||
|
throw new DOMException("stop", "AbortError");
|
||||||
|
},
|
||||||
|
persistConfig: async () => {},
|
||||||
|
handleUpdate: async () => {},
|
||||||
|
updateStatus: (_ctx: string, message?: string) => {
|
||||||
|
events.push(`status:${message ?? "ok"}`);
|
||||||
|
},
|
||||||
|
onRecovered: (ctx: string) => {
|
||||||
|
events.push(`recovered:${ctx}`);
|
||||||
|
},
|
||||||
|
sleep: async () => {},
|
||||||
|
});
|
||||||
|
await runPollLoop("ctx", new AbortController().signal);
|
||||||
|
return events;
|
||||||
|
};
|
||||||
|
assert.deepEqual(await run(2), [
|
||||||
|
"status:network down",
|
||||||
|
"status:network down",
|
||||||
|
"status:ok",
|
||||||
|
"recovered:ctx",
|
||||||
|
]);
|
||||||
|
assert.deepEqual(await run(0), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Poll loop keeps polling when the recovery notice itself fails", async () => {
|
||||||
|
const recorded: string[] = [];
|
||||||
|
let calls = 0;
|
||||||
|
const runPollLoop = createTelegramPollLoopRunner({
|
||||||
|
getConfig: () => ({ botToken: "123:abc", lastUpdateId: 1 }),
|
||||||
|
deleteWebhook: async () => {},
|
||||||
|
getUpdates: async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) throw new Error("network down");
|
||||||
|
if (calls === 2) return [];
|
||||||
|
throw new DOMException("stop", "AbortError");
|
||||||
|
},
|
||||||
|
persistConfig: async () => {},
|
||||||
|
handleUpdate: async () => {},
|
||||||
|
updateStatus: () => {},
|
||||||
|
onRecovered: () => {
|
||||||
|
throw new Error("toast broke");
|
||||||
|
},
|
||||||
|
sleep: async () => {},
|
||||||
|
recordRuntimeEvent: (category, error, details) => {
|
||||||
|
recorded.push(`${category}:${(error as Error).message}:${details?.phase}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await runPollLoop("ctx", new AbortController().signal);
|
||||||
|
assert.equal(calls, 3);
|
||||||
|
assert.ok(recorded.includes("polling:toast broke:recovered-notice"), recorded.join("|"));
|
||||||
|
});
|
||||||
|
|
||||||
test("Poll loop runner ignores stale-context status failures while retrying", async () => {
|
test("Poll loop runner ignores stale-context status failures while retrying", async () => {
|
||||||
const config = { botToken: "123:abc", lastUpdateId: 1 };
|
const config = { botToken: "123:abc", lastUpdateId: 1 };
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
@@ -612,6 +713,7 @@ test("Poll loop runner ignores stale-context status failures while retrying", as
|
|||||||
getUpdates: async () => {
|
getUpdates: async () => {
|
||||||
calls += 1;
|
calls += 1;
|
||||||
if (calls === 1) throw new Error("network down");
|
if (calls === 1) throw new Error("network down");
|
||||||
|
if (calls === 2) return [];
|
||||||
throw new DOMException("stop", "AbortError");
|
throw new DOMException("stop", "AbortError");
|
||||||
},
|
},
|
||||||
persistConfig: async () => {},
|
persistConfig: async () => {},
|
||||||
@@ -637,6 +739,74 @@ test("Poll loop runner ignores stale-context status failures while retrying", as
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Poll loop keeps the error status until a poll succeeds", async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
let calls = 0;
|
||||||
|
await runTelegramPollLoop({
|
||||||
|
ctx: TEST_CONTEXT,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
config: { botToken: "123:abc", lastUpdateId: 1 },
|
||||||
|
deleteWebhook: async () => {},
|
||||||
|
getUpdates: async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls <= 2) throw new Error("rate limited");
|
||||||
|
if (calls === 3) return [];
|
||||||
|
throw new DOMException("stop", "AbortError");
|
||||||
|
},
|
||||||
|
persistConfig: async () => {},
|
||||||
|
handleUpdate: async () => {},
|
||||||
|
onErrorStatus: (message) => {
|
||||||
|
events.push(`error:${message}`);
|
||||||
|
},
|
||||||
|
onStatusReset: () => {
|
||||||
|
events.push("reset");
|
||||||
|
},
|
||||||
|
sleep: async () => {
|
||||||
|
events.push("sleep");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(events, [
|
||||||
|
"error:rate limited",
|
||||||
|
"sleep",
|
||||||
|
"error:rate limited",
|
||||||
|
"sleep",
|
||||||
|
"reset",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Poll loop does not report recovery when only an update handler failed", async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
let calls = 0;
|
||||||
|
await runTelegramPollLoop({
|
||||||
|
ctx: TEST_CONTEXT,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
config: { botToken: "123:abc", lastUpdateId: 1 },
|
||||||
|
maxUpdateFailures: 3,
|
||||||
|
deleteWebhook: async () => {},
|
||||||
|
getUpdates: async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls <= 3) return [{ update_id: 6 }];
|
||||||
|
throw new DOMException("stop", "AbortError");
|
||||||
|
},
|
||||||
|
persistConfig: async () => {},
|
||||||
|
handleUpdate: async () => {
|
||||||
|
throw new Error("handler failed");
|
||||||
|
},
|
||||||
|
onErrorStatus: (message) => {
|
||||||
|
events.push(`error:${message}`);
|
||||||
|
},
|
||||||
|
onStatusReset: () => {
|
||||||
|
events.push("reset");
|
||||||
|
},
|
||||||
|
onRecovered: () => {
|
||||||
|
events.push("recovered");
|
||||||
|
},
|
||||||
|
sleep: async () => {},
|
||||||
|
});
|
||||||
|
assert.equal(events.includes("recovered"), false, events.join("|"));
|
||||||
|
assert.ok(events.includes("reset"), events.join("|"));
|
||||||
|
});
|
||||||
|
|
||||||
test("Poll loop initializes lastUpdateId and processes updates", async () => {
|
test("Poll loop initializes lastUpdateId and processes updates", async () => {
|
||||||
const handled: number[] = [];
|
const handled: number[] = [];
|
||||||
const config: { botToken: string; lastUpdateId?: number } = {
|
const config: { botToken: string; lastUpdateId?: number } = {
|
||||||
@@ -893,6 +1063,7 @@ test("Poll loop reports retryable errors and sleeps before retrying", async () =
|
|||||||
if (calls === 1) {
|
if (calls === 1) {
|
||||||
throw new Error("network down");
|
throw new Error("network down");
|
||||||
}
|
}
|
||||||
|
if (calls === 2) return [];
|
||||||
throw new DOMException("stop", "AbortError");
|
throw new DOMException("stop", "AbortError");
|
||||||
},
|
},
|
||||||
persistConfig: async () => {},
|
persistConfig: async () => {},
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
executeTelegramControlItemRuntime,
|
executeTelegramControlItemRuntime,
|
||||||
executeTelegramQueueDispatchPlan,
|
executeTelegramQueueDispatchPlan,
|
||||||
formatQueuedTelegramItemsStatus,
|
formatQueuedTelegramItemsStatus,
|
||||||
|
formatQueuedTelegramItemsPreview,
|
||||||
getNextTelegramToolExecutionCount,
|
getNextTelegramToolExecutionCount,
|
||||||
getTelegramQueueItemAdmissionMode,
|
getTelegramQueueItemAdmissionMode,
|
||||||
getTelegramQueueLaneContract,
|
getTelegramQueueLaneContract,
|
||||||
@@ -405,6 +406,42 @@ test("Control-lane items sort before priority and default prompt items", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Queued item preview lists summaries in dispatch order and skips empty ones", () => {
|
||||||
|
const defaultPrompt: TelegramQueueItem = createQueueTestPromptTurn({
|
||||||
|
queueOrder: 10,
|
||||||
|
queueLane: "default",
|
||||||
|
laneOrder: 0,
|
||||||
|
statusSummary: "default",
|
||||||
|
});
|
||||||
|
const emptyPrompt: TelegramQueueItem = createQueueTestPromptTurn({
|
||||||
|
queueOrder: 13,
|
||||||
|
queueLane: "default",
|
||||||
|
laneOrder: 1,
|
||||||
|
statusSummary: " ",
|
||||||
|
});
|
||||||
|
const priorityPrompt: TelegramQueueItem = createQueueTestPromptTurn({
|
||||||
|
queueOrder: 11,
|
||||||
|
queueLane: "priority",
|
||||||
|
laneOrder: 0,
|
||||||
|
statusSummary: "priority",
|
||||||
|
});
|
||||||
|
const controlItem: TelegramQueueItem = createQueueTestControlItem({
|
||||||
|
queueOrder: 12,
|
||||||
|
statusSummary: "control",
|
||||||
|
});
|
||||||
|
const stored = [defaultPrompt, emptyPrompt, controlItem, priorityPrompt];
|
||||||
|
assert.deepEqual(formatQueuedTelegramItemsPreview(stored), [
|
||||||
|
"control",
|
||||||
|
"priority",
|
||||||
|
"default",
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
stored.map((item) => item.statusSummary),
|
||||||
|
["default", " ", "control", "priority"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(formatQueuedTelegramItemsPreview([]), []);
|
||||||
|
});
|
||||||
|
|
||||||
test("Queue mutation helpers remove prompt items by Telegram message id", () => {
|
test("Queue mutation helpers remove prompt items by Telegram message id", () => {
|
||||||
const promptItem: TelegramQueueItem = createQueueTestPromptTurn({
|
const promptItem: TelegramQueueItem = createQueueTestPromptTurn({
|
||||||
replyToMessageId: 1,
|
replyToMessageId: 1,
|
||||||
|
|||||||
@@ -294,6 +294,82 @@ test("Markdown table padding handles CJK and combining marks", () => {
|
|||||||
assert.match(chunks[0]?.text ?? "", /東京 \| city /u);
|
assert.match(chunks[0]?.text ?? "", /東京 \| city /u);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Markdown table padding measures wide, zero-width, and control code points", () => {
|
||||||
|
// [cell, expected display width]
|
||||||
|
const cells: Array<[string, number]> = [
|
||||||
|
["a", 1],
|
||||||
|
["\u{d55c}", 2], // Hangul syllable
|
||||||
|
["\u{ff21}", 2], // fullwidth Latin
|
||||||
|
["\u{2329}", 2], // angle bracket
|
||||||
|
["\u{f900}", 2], // CJK compatibility ideograph
|
||||||
|
["\u{fe10}", 2], // vertical form
|
||||||
|
["\u{fe30}", 2], // CJK compatibility form
|
||||||
|
["\u{ffe0}", 2], // fullwidth sign
|
||||||
|
["\u{1100}", 2], // Hangul Jamo
|
||||||
|
["é", 1], // combining mark
|
||||||
|
["ab", 2], // zero-width joiner
|
||||||
|
["a️", 1], // variation selector
|
||||||
|
["a\u0001b", 2], // control character
|
||||||
|
["ab", 2],
|
||||||
|
];
|
||||||
|
const markdown = [
|
||||||
|
"| W | N |",
|
||||||
|
"| --- | --- |",
|
||||||
|
...cells.map(([cell], index) => `| ${cell} | c${index} |`),
|
||||||
|
].join("\n");
|
||||||
|
const chunks = renderTelegramMessage(markdown, { mode: "markdown" });
|
||||||
|
assert.equal(chunks.length, 1);
|
||||||
|
const expectedRows = [
|
||||||
|
"W | N ",
|
||||||
|
"--- | ---",
|
||||||
|
...cells.map(
|
||||||
|
([cell, width], index) =>
|
||||||
|
`${cell}${" ".repeat(3 - width)} | ${`c${index}`.padEnd(3)}`,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
assert.equal(
|
||||||
|
chunks[0]?.text,
|
||||||
|
`<pre><code class="language-markdown">${expectedRows.join("\n")}</code></pre>`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Markdown link targets accept valid destinations and titles and reject malformed ones", () => {
|
||||||
|
const link = (href: string) => `<a href="${href}">a</a>`;
|
||||||
|
const cases: Array<[string, string]> = [
|
||||||
|
// plain destinations
|
||||||
|
["[a](https://x.com)", link("https://x.com")],
|
||||||
|
["[a]( https://x.com )", link("https://x.com")],
|
||||||
|
["[a](https://x.com/a_(b))", link("https://x.com/a_(b)")],
|
||||||
|
[String.raw`[a](https://x.com/a\)b)`, link(String.raw`https://x.com/a\)b`)],
|
||||||
|
[String.raw`[a](https://x.com/a\ b)`, link(String.raw`https://x.com/a\ b`)],
|
||||||
|
["[a](https://x.com/a b)", "[a](https://x.com/a b)"],
|
||||||
|
["[a](https://x.com/a(b)", "[a](https://x.com/a(b)"],
|
||||||
|
// angle-bracket destinations
|
||||||
|
["[a](<https://x.com/a b>)", link("https://x.com/a b")],
|
||||||
|
[String.raw`[a](<https://x.com/a\>b>)`, link(String.raw`https://x.com/a\>b`)],
|
||||||
|
["[a](<https://x.com/a", "[a](<https://x.com/a"],
|
||||||
|
["[a](<>)", "[a](<>)"],
|
||||||
|
// missing destination or end of input
|
||||||
|
["[a]()", "[a]()"],
|
||||||
|
["[a](", "[a]("],
|
||||||
|
["[a]( ", "[a]( "],
|
||||||
|
// titles
|
||||||
|
['[a](https://x.com "T")', link("https://x.com")],
|
||||||
|
["[a](https://x.com 'T')", link("https://x.com")],
|
||||||
|
["[a](https://x.com (T))", link("https://x.com")],
|
||||||
|
['[a](<https://x.com> "T")', link("https://x.com")],
|
||||||
|
["[a](https://x.com )", link("https://x.com")],
|
||||||
|
[String.raw`[a](https://x.com "T\"q")`, link("https://x.com")],
|
||||||
|
['[a](https://x.com "T)', '[a](https://x.com "T)'],
|
||||||
|
[String.raw`[a](https://x.com "T\")`, String.raw`[a](https://x.com "T\")`],
|
||||||
|
["[a](https://x.com T)", "[a](https://x.com T)"],
|
||||||
|
];
|
||||||
|
for (const [input, expected] of cases) {
|
||||||
|
const chunks = renderTelegramMessage(input, { mode: "markdown" });
|
||||||
|
assert.equal(chunks[0]?.text, expected, input);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("Links, code spans, and underscore-heavy text coexist safely", () => {
|
test("Links, code spans, and underscore-heavy text coexist safely", () => {
|
||||||
const chunks = renderTelegramMessage(
|
const chunks = renderTelegramMessage(
|
||||||
"See [docs](https://example.com), run `foo_bar()` and keep foo_bar.txt literal",
|
"See [docs](https://example.com), run `foo_bar()` and keep foo_bar.txt literal",
|
||||||
|
|||||||
+394
-242
@@ -10,6 +10,7 @@ import {
|
|||||||
buildTelegramBridgeStatusLines,
|
buildTelegramBridgeStatusLines,
|
||||||
buildTelegramRuntimeEventLines,
|
buildTelegramRuntimeEventLines,
|
||||||
buildTelegramStatusBarText,
|
buildTelegramStatusBarText,
|
||||||
|
buildTelegramStatusBarLines,
|
||||||
clearTelegramStatusLineProviders,
|
clearTelegramStatusLineProviders,
|
||||||
createTelegramBridgeStatusRuntime,
|
createTelegramBridgeStatusRuntime,
|
||||||
createTelegramRuntimeDiagnosticsSnapshotScheduler,
|
createTelegramRuntimeDiagnosticsSnapshotScheduler,
|
||||||
@@ -19,6 +20,9 @@ import {
|
|||||||
createTelegramStatusSnapshot,
|
createTelegramStatusSnapshot,
|
||||||
createTelegramStatusRuntime,
|
createTelegramStatusRuntime,
|
||||||
getTelegramStatusBarProcessingStatus,
|
getTelegramStatusBarProcessingStatus,
|
||||||
|
formatTelegramWorkingMessage,
|
||||||
|
sanitizeTelegramStatusText,
|
||||||
|
summarizeTelegramStatusError,
|
||||||
recordStructuredTelegramRuntimeEvent,
|
recordStructuredTelegramRuntimeEvent,
|
||||||
recordTelegramRuntimeEvent,
|
recordTelegramRuntimeEvent,
|
||||||
registerTelegramStatusLineProvider,
|
registerTelegramStatusLineProvider,
|
||||||
@@ -111,248 +115,165 @@ test("Status runtime diagnostics scheduler coalesces snapshot persists", async (
|
|||||||
assert.deepEqual(errors, []);
|
assert.deepEqual(errors, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Status bar text renders bridge connection and queue states", () => {
|
const statusBarTheme = {
|
||||||
const theme = {
|
fg: (token: string, text: string) => `<${token}>${text}</${token}>`,
|
||||||
fg: (token: string, text: string) => `<${token}>${text}</${token}>`,
|
};
|
||||||
|
|
||||||
|
function plainStatusText(text: string): string {
|
||||||
|
return text.replace(/<\/?[a-z]+>/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(text: string): string {
|
||||||
|
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Status bar text keeps the label, state color and queue for every connection state", () => {
|
||||||
|
const base = {
|
||||||
|
hasBotToken: true,
|
||||||
|
pollingActive: true,
|
||||||
|
paired: true,
|
||||||
|
compactionInProgress: false,
|
||||||
|
processing: false,
|
||||||
|
queuedStatus: "",
|
||||||
};
|
};
|
||||||
assert.equal(
|
const cases: Array<{
|
||||||
buildTelegramStatusBarText(theme, {
|
state: Partial<Parameters<typeof buildTelegramStatusBarText>[1]>;
|
||||||
hasBotToken: false,
|
label: string;
|
||||||
pollingActive: false,
|
token: string;
|
||||||
paired: false,
|
word: string;
|
||||||
compactionInProgress: false,
|
queued?: string;
|
||||||
processing: false,
|
}> = [
|
||||||
queuedStatus: "",
|
{ state: { hasBotToken: false, pollingActive: false, paired: false }, label: "telegram", token: "muted", word: "not configured" },
|
||||||
}),
|
{ state: { processing: true, queuedStatus: " +1" }, label: "telegram", token: "warning", word: "active", queued: " +1" },
|
||||||
"<accent>telegram</accent> <muted>not configured</muted>",
|
{ state: { processing: true, processingStatus: "dispatching", queuedStatus: " +1" }, label: "telegram", token: "warning", word: "active", queued: " +1" },
|
||||||
);
|
{ state: { processing: true, processingStatus: "active" }, label: "telegram", token: "warning", word: "active" },
|
||||||
assert.equal(
|
{ state: { compactionInProgress: true }, label: "telegram", token: "success", word: "connected" },
|
||||||
buildTelegramStatusBarText(theme, {
|
{ state: { compactionInProgress: true, processing: true, processingStatus: "active" }, label: "telegram", token: "warning", word: "active" },
|
||||||
hasBotToken: true,
|
{ state: { pollingActive: false, instanceThreadName: "Aurora", processing: true, processingStatus: "queued", queuedStatus: " +2" }, label: "telegram", token: "muted", word: "disconnected", queued: " +2" },
|
||||||
pollingActive: true,
|
{ state: { pollingActive: false, busRole: "follower" }, label: "telegram", token: "success", word: "follower" },
|
||||||
paired: true,
|
{ state: { pollingActive: false, busRole: "follower", instanceThreadName: "Amber", processing: true, processingStatus: "active" }, label: "Amber", token: "warning", word: "active" },
|
||||||
compactionInProgress: false,
|
{ state: { pollingActive: false, busRole: "follower", busLifecyclePhase: "electing" }, label: "telegram", token: "warning", word: "electing" },
|
||||||
processing: true,
|
{ state: { pollingActive: false, busLifecyclePhase: "electing" }, label: "telegram", token: "warning", word: "electing" },
|
||||||
queuedStatus: " +1",
|
{ state: { pollingActive: false, busRole: "follower", instanceThreadName: "Follower" }, label: "telegram", token: "success", word: "follower" },
|
||||||
}),
|
{ state: { pollingActive: false, busRole: "follower", instanceThreadName: "Lname" }, label: "Lname", token: "success", word: "follower" },
|
||||||
"<accent>telegram</accent> <warning>active</warning><success> +1</success>",
|
{ state: { pollingActive: false, busRole: "follower", instanceSlot: "O", instanceThreadName: "extensions Follower" }, label: "extensions Follower", token: "success", word: "follower" },
|
||||||
);
|
{ state: { pollingActive: false, busRole: "follower", instanceSlot: "O", instanceThreadName: "Oname" }, label: "Oname", token: "success", word: "follower" },
|
||||||
assert.equal(
|
{ state: { busRole: "leader" }, label: "telegram", token: "success", word: "leader" },
|
||||||
buildTelegramStatusBarText(theme, {
|
{ state: { busRole: "leader", instanceThreadName: "🌙 A-identity" }, label: "🌙 A-identity", token: "success", word: "leader" },
|
||||||
hasBotToken: true,
|
{ state: { paired: false, processing: true, processingStatus: "queued", queuedStatus: " +1" }, label: "telegram", token: "warning", word: "awaiting pairing", queued: " +1" },
|
||||||
pollingActive: true,
|
];
|
||||||
paired: true,
|
for (const { state, label, token, word, queued } of cases) {
|
||||||
compactionInProgress: false,
|
const text = buildTelegramStatusBarText(statusBarTheme, { ...base, ...state });
|
||||||
processing: true,
|
const expected = new RegExp(
|
||||||
processingStatus: "dispatching",
|
`^<accent>${escapeRegExp(label)}</accent> <${token}>\\S+ ${word}</${token}>${queued ? `<success>${escapeRegExp(queued)}</success>` : ""}`,
|
||||||
queuedStatus: " +1",
|
);
|
||||||
}),
|
assert.match(text, expected, JSON.stringify(state));
|
||||||
"<accent>telegram</accent> <warning>active</warning><success> +1</success>",
|
}
|
||||||
);
|
assert.match(
|
||||||
assert.equal(
|
buildTelegramStatusBarText(statusBarTheme, {
|
||||||
buildTelegramStatusBarText(theme, {
|
...base,
|
||||||
hasBotToken: true,
|
|
||||||
pollingActive: true,
|
|
||||||
paired: true,
|
|
||||||
compactionInProgress: false,
|
|
||||||
processing: true,
|
|
||||||
processingStatus: "active",
|
|
||||||
queuedStatus: "",
|
|
||||||
}),
|
|
||||||
"<accent>telegram</accent> <warning>active</warning>",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
buildTelegramStatusBarText(theme, {
|
|
||||||
hasBotToken: true,
|
|
||||||
pollingActive: true,
|
|
||||||
paired: true,
|
|
||||||
compactionInProgress: true,
|
|
||||||
processing: false,
|
|
||||||
queuedStatus: "",
|
|
||||||
}),
|
|
||||||
"<accent>telegram</accent> <success>connected</success>",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
buildTelegramStatusBarText(theme, {
|
|
||||||
hasBotToken: true,
|
|
||||||
pollingActive: true,
|
|
||||||
paired: true,
|
|
||||||
compactionInProgress: true,
|
|
||||||
processing: true,
|
|
||||||
processingStatus: "active",
|
|
||||||
queuedStatus: "",
|
|
||||||
}),
|
|
||||||
"<accent>telegram</accent> <warning>active</warning>",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
buildTelegramStatusBarText(theme, {
|
|
||||||
hasBotToken: true,
|
|
||||||
pollingActive: false,
|
|
||||||
paired: true,
|
|
||||||
instanceThreadName: "Aurora",
|
|
||||||
compactionInProgress: false,
|
|
||||||
processing: true,
|
|
||||||
processingStatus: "queued",
|
|
||||||
queuedStatus: " +2",
|
|
||||||
}),
|
|
||||||
"<accent>telegram</accent> <muted>disconnected</muted><success> +2</success>",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
buildTelegramStatusBarText(theme, {
|
|
||||||
hasBotToken: true,
|
|
||||||
pollingActive: false,
|
|
||||||
paired: true,
|
|
||||||
busRole: "follower",
|
|
||||||
compactionInProgress: false,
|
|
||||||
processing: false,
|
|
||||||
queuedStatus: "",
|
|
||||||
}),
|
|
||||||
"<accent>telegram</accent> <success>follower</success>",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
buildTelegramStatusBarText(theme, {
|
|
||||||
hasBotToken: true,
|
|
||||||
pollingActive: false,
|
|
||||||
paired: true,
|
|
||||||
busRole: "follower",
|
|
||||||
instanceThreadName: "Amber",
|
|
||||||
compactionInProgress: false,
|
|
||||||
processing: true,
|
|
||||||
processingStatus: "active",
|
|
||||||
queuedStatus: "",
|
|
||||||
}),
|
|
||||||
"<accent>Amber</accent> <warning>active</warning>",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
buildTelegramStatusBarText(theme, {
|
|
||||||
hasBotToken: true,
|
|
||||||
pollingActive: false,
|
|
||||||
paired: true,
|
|
||||||
busRole: "follower",
|
|
||||||
busLifecyclePhase: "electing",
|
|
||||||
compactionInProgress: false,
|
|
||||||
processing: false,
|
|
||||||
queuedStatus: "",
|
|
||||||
}),
|
|
||||||
"<accent>telegram</accent> <warning>electing</warning>",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
buildTelegramStatusBarText(theme, {
|
|
||||||
hasBotToken: true,
|
|
||||||
pollingActive: false,
|
|
||||||
paired: true,
|
|
||||||
busLifecyclePhase: "electing",
|
|
||||||
compactionInProgress: false,
|
|
||||||
processing: false,
|
|
||||||
queuedStatus: "",
|
|
||||||
}),
|
|
||||||
"<accent>telegram</accent> <warning>electing</warning>",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
buildTelegramStatusBarText(theme, {
|
|
||||||
hasBotToken: true,
|
|
||||||
pollingActive: false,
|
|
||||||
paired: true,
|
|
||||||
busRole: "follower",
|
|
||||||
instanceThreadName: "Follower",
|
|
||||||
compactionInProgress: false,
|
|
||||||
processing: false,
|
|
||||||
queuedStatus: "",
|
|
||||||
}),
|
|
||||||
"<accent>telegram</accent> <success>follower</success>",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
buildTelegramStatusBarText(theme, {
|
|
||||||
hasBotToken: true,
|
|
||||||
pollingActive: false,
|
|
||||||
paired: true,
|
|
||||||
busRole: "follower",
|
|
||||||
instanceThreadName: "Lname",
|
|
||||||
compactionInProgress: false,
|
|
||||||
processing: false,
|
|
||||||
queuedStatus: "",
|
|
||||||
}),
|
|
||||||
"<accent>Lname</accent> <success>follower</success>",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
buildTelegramStatusBarText(theme, {
|
|
||||||
hasBotToken: true,
|
|
||||||
pollingActive: false,
|
|
||||||
paired: true,
|
|
||||||
busRole: "follower",
|
|
||||||
instanceSlot: "O",
|
|
||||||
instanceThreadName: "extensions Follower",
|
|
||||||
compactionInProgress: false,
|
|
||||||
processing: false,
|
|
||||||
queuedStatus: "",
|
|
||||||
}),
|
|
||||||
"<accent>extensions Follower</accent> <success>follower</success>",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
buildTelegramStatusBarText(theme, {
|
|
||||||
hasBotToken: true,
|
|
||||||
pollingActive: false,
|
|
||||||
paired: true,
|
|
||||||
busRole: "follower",
|
|
||||||
instanceSlot: "O",
|
|
||||||
instanceThreadName: "Oname",
|
|
||||||
compactionInProgress: false,
|
|
||||||
processing: false,
|
|
||||||
queuedStatus: "",
|
|
||||||
}),
|
|
||||||
"<accent>Oname</accent> <success>follower</success>",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
buildTelegramStatusBarText(theme, {
|
|
||||||
hasBotToken: true,
|
|
||||||
pollingActive: true,
|
|
||||||
paired: true,
|
|
||||||
busRole: "leader",
|
|
||||||
compactionInProgress: false,
|
|
||||||
processing: false,
|
|
||||||
queuedStatus: "",
|
|
||||||
}),
|
|
||||||
"<accent>telegram</accent> <success>leader</success>",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
buildTelegramStatusBarText(theme, {
|
|
||||||
hasBotToken: true,
|
|
||||||
pollingActive: true,
|
|
||||||
paired: true,
|
|
||||||
busRole: "leader",
|
|
||||||
instanceThreadName: "🌙 A-identity",
|
|
||||||
compactionInProgress: false,
|
|
||||||
processing: false,
|
|
||||||
queuedStatus: "",
|
|
||||||
}),
|
|
||||||
"<accent>🌙 A-identity</accent> <success>leader</success>",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
buildTelegramStatusBarText(theme, {
|
|
||||||
hasBotToken: true,
|
|
||||||
pollingActive: true,
|
|
||||||
paired: false,
|
|
||||||
compactionInProgress: false,
|
|
||||||
processing: true,
|
|
||||||
processingStatus: "queued",
|
|
||||||
queuedStatus: " +1",
|
|
||||||
}),
|
|
||||||
"<accent>telegram</accent> <warning>awaiting pairing</warning><success> +1</success>",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
buildTelegramStatusBarText(theme, {
|
|
||||||
hasBotToken: true,
|
|
||||||
pollingActive: true,
|
|
||||||
paired: true,
|
|
||||||
compactionInProgress: false,
|
|
||||||
processing: false,
|
|
||||||
queuedStatus: "",
|
|
||||||
error: "typing failed",
|
error: "typing failed",
|
||||||
}),
|
}),
|
||||||
"<accent>telegram</accent> <error>error</error> <muted>typing failed</muted>",
|
/^<accent>telegram<\/accent> <error>\S+ error<\/error> <muted>typing failed<\/muted>$/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Status bar shows bot, message counters and last activity only while the bridge is up", () => {
|
||||||
|
const lastAtMs = new Date(2026, 9, 1, 14, 32).getTime();
|
||||||
|
const connected = {
|
||||||
|
hasBotToken: true,
|
||||||
|
pollingActive: true,
|
||||||
|
paired: true,
|
||||||
|
compactionInProgress: false,
|
||||||
|
processing: false,
|
||||||
|
queuedStatus: "",
|
||||||
|
botUsername: "demo_bot",
|
||||||
|
traffic: { received: 12, sent: 11, lastAtMs },
|
||||||
|
};
|
||||||
|
const up = plainStatusText(buildTelegramStatusBarText(statusBarTheme, connected));
|
||||||
|
assert.match(up, /connected/);
|
||||||
|
assert.match(up, /@demo_bot/);
|
||||||
|
assert.match(up, /12 \S*11/);
|
||||||
|
assert.match(up, /14:32/);
|
||||||
|
|
||||||
|
const withoutActivity = plainStatusText(
|
||||||
|
buildTelegramStatusBarText(statusBarTheme, {
|
||||||
|
...connected,
|
||||||
|
traffic: { received: 0, sent: 0 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.match(withoutActivity, /0 \S*0/);
|
||||||
|
assert.doesNotMatch(withoutActivity, /\d\d:\d\d/);
|
||||||
|
|
||||||
|
const working = plainStatusText(
|
||||||
|
buildTelegramStatusBarText(statusBarTheme, {
|
||||||
|
...connected,
|
||||||
|
processing: true,
|
||||||
|
processingStatus: "active",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.match(working, /active/);
|
||||||
|
assert.match(working, /@demo_bot/);
|
||||||
|
assert.match(working, /12 \S*11/);
|
||||||
|
|
||||||
|
const offline = plainStatusText(
|
||||||
|
buildTelegramStatusBarText(statusBarTheme, { ...connected, pollingActive: false }),
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(offline, /@demo_bot|14:32/);
|
||||||
|
assert.match(offline, /disconnected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Status bar tells the operator how to leave a non-working state", () => {
|
||||||
|
const base = {
|
||||||
|
hasBotToken: true,
|
||||||
|
pollingActive: false,
|
||||||
|
paired: true,
|
||||||
|
compactionInProgress: false,
|
||||||
|
processing: false,
|
||||||
|
queuedStatus: "",
|
||||||
|
};
|
||||||
|
assert.match(
|
||||||
|
plainStatusText(buildTelegramStatusBarText(statusBarTheme, { ...base, hasBotToken: false, paired: false })),
|
||||||
|
/\/telegram-setup/,
|
||||||
|
);
|
||||||
|
const pairing = plainStatusText(
|
||||||
|
buildTelegramStatusBarText(statusBarTheme, { ...base, paired: false, botUsername: "demo_bot" }),
|
||||||
|
);
|
||||||
|
assert.match(pairing, /\/start/);
|
||||||
|
assert.match(pairing, /@demo_bot/);
|
||||||
|
assert.match(
|
||||||
|
plainStatusText(buildTelegramStatusBarText(statusBarTheme, base)),
|
||||||
|
/\/telegram-connect/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Status bar follows the host symbol preset and falls back without one", () => {
|
||||||
|
const state = {
|
||||||
|
hasBotToken: true,
|
||||||
|
pollingActive: true,
|
||||||
|
paired: true,
|
||||||
|
compactionInProgress: false,
|
||||||
|
processing: false,
|
||||||
|
queuedStatus: "",
|
||||||
|
botUsername: "demo_bot",
|
||||||
|
traffic: { received: 3, sent: 2, lastAtMs: new Date(2026, 9, 1, 9, 5).getTime() },
|
||||||
|
};
|
||||||
|
const ascii = plainStatusText(
|
||||||
|
buildTelegramStatusBarText(
|
||||||
|
{
|
||||||
|
...statusBarTheme,
|
||||||
|
status: { enabled: "[x]" },
|
||||||
|
sep: { dot: " - " },
|
||||||
|
icon: { input: "in:", output: "out:", time: "t:" },
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(ascii, "telegram [x] connected - @demo_bot - in:3 out:2 - t: 09:05");
|
||||||
|
const fallback = plainStatusText(buildTelegramStatusBarText(statusBarTheme, state));
|
||||||
|
assert.match(fallback, /^telegram \S+ connected · @demo_bot · \S*3 \S*2 · \S+ 09:05$/u);
|
||||||
|
});
|
||||||
|
|
||||||
test("Status runtime updates the status bar and exposes bridge lines", () => {
|
test("Status runtime updates the status bar and exposes bridge lines", () => {
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
const ctx = {
|
const ctx = {
|
||||||
@@ -390,9 +311,9 @@ test("Status runtime updates the status bar and exposes bridge lines", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
runtime.updateStatus(ctx, "demo error");
|
runtime.updateStatus(ctx, "demo error");
|
||||||
assert.equal(
|
assert.match(
|
||||||
events[0],
|
events[0],
|
||||||
"telegram:<accent>telegram</accent> <error>error</error> <muted>demo error</muted>",
|
/^telegram:<accent>telegram<\/accent> <error>\S+ error<\/error> <muted>demo error<\/muted>$/,
|
||||||
);
|
);
|
||||||
assert.deepEqual(runtime.getStatusLines().slice(0, 3), [
|
assert.deepEqual(runtime.getStatusLines().slice(0, 3), [
|
||||||
"connection:",
|
"connection:",
|
||||||
@@ -401,6 +322,237 @@ test("Status runtime updates the status bar and exposes bridge lines", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Status runtime keeps theme colors in the TUI through a widget and stays plain elsewhere", () => {
|
||||||
|
const createContext = (mode: string | undefined) => {
|
||||||
|
const events: string[] = [];
|
||||||
|
return {
|
||||||
|
events,
|
||||||
|
ctx: {
|
||||||
|
mode,
|
||||||
|
ui: {
|
||||||
|
theme: statusBarTheme,
|
||||||
|
setStatus: (key: string, text: string | undefined) => {
|
||||||
|
events.push(`status:${key}:${text}`);
|
||||||
|
},
|
||||||
|
setWidget: (
|
||||||
|
key: string,
|
||||||
|
content: string[] | undefined,
|
||||||
|
options?: { placement?: string },
|
||||||
|
) => {
|
||||||
|
events.push(`widget:${key}:${options?.placement}:${content?.join("|")}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const runtime = createTelegramStatusRuntime({
|
||||||
|
getStatusBarState: () => ({
|
||||||
|
hasBotToken: true,
|
||||||
|
pollingActive: true,
|
||||||
|
paired: true,
|
||||||
|
compactionInProgress: false,
|
||||||
|
processing: false,
|
||||||
|
queuedStatus: "",
|
||||||
|
}),
|
||||||
|
getBridgeStatusLineState: () => {
|
||||||
|
throw new Error("not used");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tui = createContext("tui");
|
||||||
|
runtime.updateStatus(tui.ctx);
|
||||||
|
assert.equal(tui.events.length, 2);
|
||||||
|
assert.match(tui.events[0], /^widget:telegram:belowEditor:<accent>telegram<\/accent> <success>\S+ connected<\/success>$/);
|
||||||
|
assert.equal(tui.events[1], "status:telegram:undefined");
|
||||||
|
|
||||||
|
for (const mode of ["rpc", "json", "print", undefined]) {
|
||||||
|
const other = createContext(mode);
|
||||||
|
runtime.updateStatus(other.ctx);
|
||||||
|
assert.equal(other.events.length, 1, String(mode));
|
||||||
|
assert.match(other.events[0], /^status:telegram:<accent>telegram<\/accent> <success>\S+ connected<\/success>$/);
|
||||||
|
}
|
||||||
|
|
||||||
|
const noWidgetApi = createContext("tui");
|
||||||
|
delete (noWidgetApi.ctx.ui as { setWidget?: unknown }).setWidget;
|
||||||
|
runtime.updateStatus(noWidgetApi.ctx);
|
||||||
|
assert.equal(noWidgetApi.events.length, 1);
|
||||||
|
assert.match(noWidgetApi.events[0], /^status:telegram:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Error summaries turn transport failures into a short state and next step", () => {
|
||||||
|
const cases: Array<[string, string | undefined, string | undefined, string | undefined]> = [
|
||||||
|
["Telegram API getUpdates failed: HTTP 401: Unauthorized", "fatal", "invalid token", "/telegram-setup"],
|
||||||
|
["Telegram API sendMessage failed: HTTP 429: Too Many Requests: retry after 12", "retrying", "rate limited 12s", "retrying"],
|
||||||
|
["Telegram API sendMessage failed: HTTP 429", "retrying", "rate limited", "retrying"],
|
||||||
|
["Conflict: terminated by other getUpdates request; make sure that only one bot instance is running", "attention", "another poller is active", "/telegram-status"],
|
||||||
|
["Telegram API getUpdates failed: HTTP 502: Bad Gateway", "retrying", "Telegram unavailable", "retrying"],
|
||||||
|
["Unable to connect. Is the computer able to access the url?", "retrying", "offline", "retrying"],
|
||||||
|
["fetch failed", "retrying", "offline", "retrying"],
|
||||||
|
["Telegram API sendRichMessage may have committed before transport failed.", "attention", "delivery unconfirmed", undefined],
|
||||||
|
["typing failed", undefined, undefined, undefined],
|
||||||
|
["settingsManager.reload is not a function", undefined, undefined, undefined],
|
||||||
|
];
|
||||||
|
for (const [message, kind, label, hint] of cases) {
|
||||||
|
const summary = summarizeTelegramStatusError(message);
|
||||||
|
assert.equal(summary?.kind, kind, message);
|
||||||
|
assert.equal(summary?.label, label, message);
|
||||||
|
assert.equal(summary?.hint, hint, message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Status bar shows classified errors with their next step and keeps unknown ones verbatim", () => {
|
||||||
|
const base = {
|
||||||
|
hasBotToken: true,
|
||||||
|
pollingActive: true,
|
||||||
|
paired: true,
|
||||||
|
compactionInProgress: false,
|
||||||
|
processing: false,
|
||||||
|
queuedStatus: "",
|
||||||
|
};
|
||||||
|
const fatal = buildTelegramStatusBarText(statusBarTheme, {
|
||||||
|
...base,
|
||||||
|
error: "Telegram API getUpdates failed: HTTP 401: Unauthorized",
|
||||||
|
});
|
||||||
|
assert.match(fatal, /<error>\S+ invalid token<\/error>/);
|
||||||
|
assert.match(plainStatusText(fatal), /\/telegram-setup/);
|
||||||
|
const retrying = buildTelegramStatusBarText(statusBarTheme, {
|
||||||
|
...base,
|
||||||
|
error: "Unable to connect. Is the computer able to access the url?",
|
||||||
|
});
|
||||||
|
assert.match(retrying, /<warning>\S+ offline<\/warning>/);
|
||||||
|
assert.match(plainStatusText(retrying), /retrying/);
|
||||||
|
const attention = buildTelegramStatusBarText(statusBarTheme, {
|
||||||
|
...base,
|
||||||
|
error: "HTTP 409 Conflict",
|
||||||
|
});
|
||||||
|
assert.match(attention, /<warning>\S+ another poller is active<\/warning>/);
|
||||||
|
assert.match(plainStatusText(attention), /\/telegram-status/);
|
||||||
|
assert.match(
|
||||||
|
buildTelegramStatusBarText(statusBarTheme, { ...base, error: "typing failed" }),
|
||||||
|
/<error>\S+ error<\/error> <muted>typing failed<\/muted>/,
|
||||||
|
);
|
||||||
|
const long = plainStatusText(
|
||||||
|
buildTelegramStatusBarText(statusBarTheme, { ...base, error: "x".repeat(200) }),
|
||||||
|
);
|
||||||
|
assert.ok(long.includes("…"));
|
||||||
|
assert.equal(long.includes("x".repeat(101)), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Status bar lines carry no terminal control sequences from Telegram-controlled text", () => {
|
||||||
|
const hostile = "\u001b]0;pwned\u0007\u001b[31mred\u001b[0m\u202e";
|
||||||
|
const base = {
|
||||||
|
hasBotToken: true,
|
||||||
|
pollingActive: true,
|
||||||
|
paired: true,
|
||||||
|
compactionInProgress: false,
|
||||||
|
processing: true,
|
||||||
|
queuedStatus: " +1",
|
||||||
|
instanceThreadName: `thread${hostile}`,
|
||||||
|
botUsername: `bot${hostile}`,
|
||||||
|
queuedPreview: [`queued${hostile}`],
|
||||||
|
};
|
||||||
|
const unsafe = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e]/;
|
||||||
|
for (const state of [
|
||||||
|
base,
|
||||||
|
{ ...base, error: `boom${hostile}` },
|
||||||
|
{ ...base, paired: false },
|
||||||
|
]) {
|
||||||
|
for (const line of buildTelegramStatusBarLines(statusBarTheme, state)) {
|
||||||
|
assert.doesNotMatch(line, unsafe, JSON.stringify(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(sanitizeTelegramStatusText(" a\u001b[0m b\u0000 "), "a [0m b");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Status bar previews the next queued turns only while the bridge is up", () => {
|
||||||
|
const base = {
|
||||||
|
hasBotToken: true,
|
||||||
|
pollingActive: true,
|
||||||
|
paired: true,
|
||||||
|
compactionInProgress: false,
|
||||||
|
processing: true,
|
||||||
|
queuedStatus: " +3",
|
||||||
|
};
|
||||||
|
const three = buildTelegramStatusBarLines(statusBarTheme, {
|
||||||
|
...base,
|
||||||
|
queuedPreview: ["one", "two", "three"],
|
||||||
|
});
|
||||||
|
assert.equal(three.length, 2);
|
||||||
|
assert.match(plainStatusText(three[1]), /next: "one" · "two" · \+1 more/);
|
||||||
|
const two = buildTelegramStatusBarLines(statusBarTheme, {
|
||||||
|
...base,
|
||||||
|
queuedPreview: ["one", "two"],
|
||||||
|
});
|
||||||
|
assert.match(plainStatusText(two[1]), /next: "one" · "two"$/);
|
||||||
|
for (const state of [
|
||||||
|
{ ...base, queuedPreview: [] },
|
||||||
|
{ ...base, queuedPreview: ["one"], pollingActive: false },
|
||||||
|
{ ...base, queuedPreview: ["one"], paired: false },
|
||||||
|
{ ...base, queuedPreview: ["one"], error: "typing failed" },
|
||||||
|
]) {
|
||||||
|
assert.equal(buildTelegramStatusBarLines(statusBarTheme, state).length, 1, JSON.stringify(state));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Status bar surfaces failed deliveries only when there are some", () => {
|
||||||
|
const state = {
|
||||||
|
hasBotToken: true,
|
||||||
|
pollingActive: true,
|
||||||
|
paired: true,
|
||||||
|
compactionInProgress: false,
|
||||||
|
processing: false,
|
||||||
|
queuedStatus: "",
|
||||||
|
traffic: { received: 12, sent: 11, failed: 2 },
|
||||||
|
};
|
||||||
|
assert.match(plainStatusText(buildTelegramStatusBarText(statusBarTheme, state)), /12 \S*11 \S*2/);
|
||||||
|
const none = plainStatusText(
|
||||||
|
buildTelegramStatusBarText(statusBarTheme, {
|
||||||
|
...state,
|
||||||
|
traffic: { received: 12, sent: 11, failed: 0 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(none.includes("✘"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Status runtime puts the queue preview in the widget and keeps the plain status to one line", () => {
|
||||||
|
const runtime = createTelegramStatusRuntime({
|
||||||
|
getStatusBarState: () => ({
|
||||||
|
hasBotToken: true,
|
||||||
|
pollingActive: true,
|
||||||
|
paired: true,
|
||||||
|
compactionInProgress: false,
|
||||||
|
processing: true,
|
||||||
|
queuedStatus: " +1",
|
||||||
|
queuedPreview: ["one"],
|
||||||
|
}),
|
||||||
|
getBridgeStatusLineState: () => {
|
||||||
|
throw new Error("not used");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const widgets: string[][] = [];
|
||||||
|
const statuses: Array<string | undefined> = [];
|
||||||
|
const ui = {
|
||||||
|
theme: statusBarTheme,
|
||||||
|
setStatus: (_key: string, text: string | undefined) => {
|
||||||
|
statuses.push(text);
|
||||||
|
},
|
||||||
|
setWidget: (_key: string, content: string[] | undefined) => {
|
||||||
|
widgets.push(content ?? []);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
runtime.updateStatus({ mode: "tui", ui });
|
||||||
|
assert.equal(widgets[0].length, 2);
|
||||||
|
assert.match(plainStatusText(widgets[0][1]), /next: "one"/);
|
||||||
|
runtime.updateStatus({ mode: "rpc", ui });
|
||||||
|
assert.equal(statuses.at(-1)?.includes("next:"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Working message names the Telegram prompt and ignores control characters", () => {
|
||||||
|
assert.equal(formatTelegramWorkingMessage("say pong"), 'Answering Telegram · "say pong"');
|
||||||
|
assert.equal(formatTelegramWorkingMessage(" "), "Answering Telegram");
|
||||||
|
assert.equal(formatTelegramWorkingMessage("a\u001b[0mb"), 'Answering Telegram · "a [0mb"');
|
||||||
|
});
|
||||||
|
|
||||||
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",
|
||||||
@@ -550,9 +702,9 @@ test("Bridge status runtime stays active while tools run after queue changes", (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(
|
assert.match(
|
||||||
events[0],
|
events[0],
|
||||||
"telegram:<accent>telegram</accent> <warning>active</warning>",
|
/^telegram:<accent>telegram<\/accent> <warning>\S+ active<\/warning>/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -589,9 +741,9 @@ test("Bridge status runtime builds status state from live ports", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(
|
assert.match(
|
||||||
events[0],
|
events[0],
|
||||||
"telegram:<accent>telegram</accent> <warning>active</warning><success> +1</success>",
|
/^telegram:<accent>telegram<\/accent> <warning>\S+ active<\/warning><success> \+1<\/success>/,
|
||||||
);
|
);
|
||||||
assert.deepEqual(runtime.getStatusLines(), [
|
assert.deepEqual(runtime.getStatusLines(), [
|
||||||
"connection:",
|
"connection:",
|
||||||
|
|||||||
@@ -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