feat: human error states, operator toasts and a queue/origin view in the omp UI
- classify transport and API failures into a short state with the next step - count undelivered messages and show them next to the sent counter - toast on owner paired, connection recovered and reply not delivered (throttled) - second status line previews the next queued turns - host spinner names the Telegram prompt while its turn runs - sanitize every Telegram-controlled string that reaches the widget
This commit is contained in:
1 parent
9435ff709b
commit
057281a3bb
19 files changed
+862
-22
No files matched your search
@@ -683,6 +683,29 @@ test("Telegram config pairing runtime binds config and status ports", async () =
|
||||
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", () => {
|
||||
const value = getTelegramBotTokenInputDefault(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 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"));
|
||||
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)",
|
||||
]);
|
||||
});
|
||||
|
||||
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,
|
||||
isExtensionContextIdle,
|
||||
isExtensionContextPassiveRunMode,
|
||||
notifyExtensionContext,
|
||||
setExtensionWorkingMessage,
|
||||
} from "../lib/pi.ts";
|
||||
|
||||
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(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"));
|
||||
});
|
||||
@@ -639,6 +639,70 @@ test("Poll loop keeps the paired user when the offset is committed after the ini
|
||||
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:ok",
|
||||
"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 () => {
|
||||
const config = { botToken: "123:abc", lastUpdateId: 1 };
|
||||
const events: string[] = [];
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
executeTelegramControlItemRuntime,
|
||||
executeTelegramQueueDispatchPlan,
|
||||
formatQueuedTelegramItemsStatus,
|
||||
formatQueuedTelegramItemsPreview,
|
||||
getNextTelegramToolExecutionCount,
|
||||
getTelegramQueueItemAdmissionMode,
|
||||
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", () => {
|
||||
const promptItem: TelegramQueueItem = createQueueTestPromptTurn({
|
||||
replyToMessageId: 1,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
buildTelegramBridgeStatusLines,
|
||||
buildTelegramRuntimeEventLines,
|
||||
buildTelegramStatusBarText,
|
||||
buildTelegramStatusBarLines,
|
||||
clearTelegramStatusLineProviders,
|
||||
createTelegramBridgeStatusRuntime,
|
||||
createTelegramRuntimeDiagnosticsSnapshotScheduler,
|
||||
@@ -19,6 +20,9 @@ import {
|
||||
createTelegramStatusSnapshot,
|
||||
createTelegramStatusRuntime,
|
||||
getTelegramStatusBarProcessingStatus,
|
||||
formatTelegramWorkingMessage,
|
||||
sanitizeTelegramStatusText,
|
||||
summarizeTelegramStatusError,
|
||||
recordStructuredTelegramRuntimeEvent,
|
||||
recordTelegramRuntimeEvent,
|
||||
registerTelegramStatusLineProvider,
|
||||
@@ -375,6 +379,179 @@ test("Status runtime keeps theme colors in the TUI through a widget and stays pl
|
||||
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"],
|
||||
["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", () => {
|
||||
const lines = buildTelegramBridgeStatusLines({
|
||||
botUsername: "demo_bot",
|
||||
|
||||
+52
-10
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Regression tests for the Telegram traffic counters
|
||||
* Covers what counts as a message, last-activity tracking, and the /telegram-status line.
|
||||
* Covers what counts as a message, last-activity tracking, failed deliveries, and the /telegram-status line.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
@@ -13,17 +13,20 @@ import {
|
||||
isTelegramOutboundMessageMethod,
|
||||
} from "../lib/traffic.ts";
|
||||
|
||||
test("Traffic counters count both directions and remember the latest activity", () => {
|
||||
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 });
|
||||
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,
|
||||
});
|
||||
});
|
||||
@@ -57,13 +60,22 @@ test("Status lines report the message counters and the time of the last one", ()
|
||||
queuedItems: [],
|
||||
recentRuntimeEvents: [],
|
||||
};
|
||||
const withTraffic = buildTelegramBridgeStatusLines({
|
||||
const lastAtMs = new Date(2026, 9, 1, 14, 32).getTime();
|
||||
const clean = buildTelegramBridgeStatusLines({
|
||||
...baseState,
|
||||
traffic: { received: 12, sent: 11, lastAtMs: new Date(2026, 9, 1, 14, 32).getTime() },
|
||||
traffic: { received: 12, sent: 11, failed: 0, lastAtMs },
|
||||
});
|
||||
assert.ok(
|
||||
withTraffic.includes("- messages: 12 received, 11 sent, last 14:32"),
|
||||
withTraffic.join("\n"),
|
||||
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);
|
||||
@@ -83,7 +95,7 @@ test("Traffic bridge counts delivered messages and refreshes the status with the
|
||||
});
|
||||
bridge.onCallSucceeded("sendChatAction");
|
||||
bridge.onCallSucceeded("editMessageText");
|
||||
assert.deepEqual(counters.snapshot(), { received: 0, sent: 0 });
|
||||
assert.deepEqual(counters.snapshot(), { received: 0, sent: 0, failed: 0 });
|
||||
assert.deepEqual(refreshed, []);
|
||||
bridge.onCallSucceeded("sendRichMessage");
|
||||
assert.equal(counters.snapshot().sent, 1);
|
||||
@@ -97,6 +109,33 @@ test("Traffic bridge counts delivered messages and refreshes the status with the
|
||||
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[] = [];
|
||||
@@ -112,10 +151,13 @@ test("Traffic bridge records a failed status refresh instead of breaking the mes
|
||||
});
|
||||
bridge.onCallSucceeded("sendMessage");
|
||||
bridge.onAuthorizedMessage("session");
|
||||
assert.deepEqual(counters.snapshot().received, 1);
|
||||
assert.deepEqual(counters.snapshot().sent, 1);
|
||||
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