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
@@ -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";
|
||||||
@@ -295,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,
|
||||||
@@ -326,11 +328,17 @@ 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({
|
const trafficBridge = Traffic.createTelegramTrafficStatusBridge({
|
||||||
counters: trafficCounters,
|
counters: trafficCounters,
|
||||||
getContext: telegramSessionContextStore.get,
|
getContext: telegramSessionContextStore.get,
|
||||||
updateStatus,
|
updateStatus,
|
||||||
recordRuntimeEvent,
|
recordRuntimeEvent,
|
||||||
|
onDeliveryFailed: notices.deliveryFailed,
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Telegram API ---
|
// --- Telegram API ---
|
||||||
@@ -340,6 +348,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|||||||
getBotToken: configStore.getBotToken,
|
getBotToken: configStore.getBotToken,
|
||||||
recordRuntimeEvent,
|
recordRuntimeEvent,
|
||||||
onCallSucceeded: trafficBridge.onCallSucceeded,
|
onCallSucceeded: trafficBridge.onCallSucceeded,
|
||||||
|
onCallFailed: trafficBridge.onCallFailed,
|
||||||
});
|
});
|
||||||
const telegramBusFollowerClients =
|
const telegramBusFollowerClients =
|
||||||
BusFollower.createTelegramBusFollowerClientRuntime<
|
BusFollower.createTelegramBusFollowerClientRuntime<
|
||||||
@@ -656,6 +665,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|||||||
configStore,
|
configStore,
|
||||||
callApi: callTelegramApi,
|
callApi: callTelegramApi,
|
||||||
onAuthorizedMessage: trafficBridge.onAuthorizedMessage,
|
onAuthorizedMessage: trafficBridge.onAuthorizedMessage,
|
||||||
|
onPaired: notices.paired,
|
||||||
getCurrentInstanceId() {
|
getCurrentInstanceId() {
|
||||||
return telegramInstanceId;
|
return telegramInstanceId;
|
||||||
},
|
},
|
||||||
@@ -833,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({
|
||||||
|
|||||||
+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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* 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): {
|
||||||
|
reason: string;
|
||||||
|
level: TelegramNoticeLevel;
|
||||||
|
} {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const summary = summarizeTelegramStatusError(message);
|
||||||
|
if (summary) {
|
||||||
|
return {
|
||||||
|
reason: summary.label,
|
||||||
|
level: summary.kind === "fatal" ? "error" : "warning",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const raw = sanitizeTelegramStatusText(message);
|
||||||
|
return {
|
||||||
|
reason: raw.length > 80 ? `${raw.slice(0, 79)}…` : raw,
|
||||||
|
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 { reason, level } = describeTelegramDeliveryFailure(error);
|
||||||
|
show(ctx, `Telegram: reply not delivered (${reason})`, 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
@@ -994,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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1041,6 +1055,7 @@ 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;
|
||||||
|
let failing = false;
|
||||||
while (!deps.signal.aborted) {
|
while (!deps.signal.aborted) {
|
||||||
try {
|
try {
|
||||||
const updates = await deps.getUpdates(
|
const updates = await deps.getUpdates(
|
||||||
@@ -1048,6 +1063,10 @@ export async function runTelegramPollLoop<
|
|||||||
deps.signal,
|
deps.signal,
|
||||||
);
|
);
|
||||||
consecutiveGetUpdatesConflicts = 0;
|
consecutiveGetUpdatesConflicts = 0;
|
||||||
|
if (failing) {
|
||||||
|
failing = 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;
|
||||||
@@ -1105,6 +1124,7 @@ export async function runTelegramPollLoop<
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
consecutiveGetUpdatesConflicts = 0;
|
consecutiveGetUpdatesConflicts = 0;
|
||||||
|
failing = true;
|
||||||
deps.onErrorStatus(getTelegramPollingErrorMessage(error));
|
deps.onErrorStatus(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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -556,6 +556,8 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|||||||
) => Promise<TResponse>;
|
) => Promise<TResponse>;
|
||||||
/** Called for every message from the paired owner, before it is dispatched. */
|
/** Called for every message from the paired owner, before it is dispatched. */
|
||||||
onAuthorizedMessage?: (ctx: TContext) => void;
|
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;
|
||||||
@@ -2076,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,
|
||||||
|
|||||||
+158
-9
@@ -8,6 +8,8 @@
|
|||||||
export interface TelegramStatusTraffic {
|
export interface TelegramStatusTraffic {
|
||||||
received: number;
|
received: number;
|
||||||
sent: number;
|
sent: number;
|
||||||
|
/** Deliveries that failed after the transport retries; absent means none. */
|
||||||
|
failed?: number;
|
||||||
lastAtMs?: number;
|
lastAtMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +255,8 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,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;
|
||||||
@@ -597,7 +602,7 @@ export function createTelegramStatusRuntime<
|
|||||||
const statusKey = deps.statusKey ?? "telegram";
|
const statusKey = deps.statusKey ?? "telegram";
|
||||||
return {
|
return {
|
||||||
updateStatus: (ctx, error) => {
|
updateStatus: (ctx, error) => {
|
||||||
const text = buildTelegramStatusBarText(
|
const lines = buildTelegramStatusBarLines(
|
||||||
ctx.ui.theme,
|
ctx.ui.theme,
|
||||||
deps.getStatusBarState(ctx, error),
|
deps.getStatusBarState(ctx, error),
|
||||||
);
|
);
|
||||||
@@ -605,11 +610,11 @@ export function createTelegramStatusRuntime<
|
|||||||
// The host strips ANSI from status text, so setStatus would drop the
|
// The host strips ANSI from status text, so setStatus would drop the
|
||||||
// theme colors. String widgets keep them; clear the plain footer entry
|
// theme colors. String widgets keep them; clear the plain footer entry
|
||||||
// so the state is not shown twice.
|
// so the state is not shown twice.
|
||||||
ctx.ui.setWidget(statusKey, [text], { placement: "belowEditor" });
|
ctx.ui.setWidget(statusKey, lines, { placement: "belowEditor" });
|
||||||
ctx.ui.setStatus(statusKey, undefined);
|
ctx.ui.setStatus(statusKey, undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ctx.ui.setStatus(statusKey, text);
|
ctx.ui.setStatus(statusKey, lines[0]);
|
||||||
},
|
},
|
||||||
getStatusLines: (options) =>
|
getStatusLines: (options) =>
|
||||||
buildTelegramBridgeStatusLines(deps.getBridgeStatusLineState(), options),
|
buildTelegramBridgeStatusLines(deps.getBridgeStatusLineState(), options),
|
||||||
@@ -658,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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -846,10 +852,15 @@ function buildTelegramStatusBarDetails(
|
|||||||
state: TelegramStatusBarState,
|
state: TelegramStatusBarState,
|
||||||
): string {
|
): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (state.botUsername) parts.push(`@${state.botUsername}`);
|
if (state.botUsername) {
|
||||||
|
parts.push(`@${sanitizeTelegramStatusText(state.botUsername)}`);
|
||||||
|
}
|
||||||
if (state.traffic) {
|
if (state.traffic) {
|
||||||
|
const failed = state.traffic.failed
|
||||||
|
? ` ${glyphs.error}${state.traffic.failed}`
|
||||||
|
: "";
|
||||||
parts.push(
|
parts.push(
|
||||||
`${glyphs.input}${state.traffic.received} ${glyphs.output}${state.traffic.sent}`,
|
`${glyphs.input}${state.traffic.received} ${glyphs.output}${state.traffic.sent}${failed}`,
|
||||||
);
|
);
|
||||||
if (state.traffic.lastAtMs !== undefined) {
|
if (state.traffic.lastAtMs !== undefined) {
|
||||||
parts.push(
|
parts.push(
|
||||||
@@ -871,28 +882,126 @@ function buildTelegramTrafficLines(
|
|||||||
? ""
|
? ""
|
||||||
: `, last ${formatTelegramStatusBarClock(state.traffic.lastAtMs)}`;
|
: `, last ${formatTelegramStatusBarClock(state.traffic.lastAtMs)}`;
|
||||||
return [
|
return [
|
||||||
`- messages: ${state.traffic.received} received, ${state.traffic.sent} sent${last}`,
|
`- 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";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (/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 glyphs = resolveTelegramStatusBarGlyphs(theme);
|
const glyphs = resolveTelegramStatusBarGlyphs(theme);
|
||||||
const label = theme.fg("accent", getTelegramStatusBarLabel(state));
|
const label = theme.fg(
|
||||||
|
"accent",
|
||||||
|
sanitizeTelegramStatusText(getTelegramStatusBarLabel(state)),
|
||||||
|
);
|
||||||
const hint = (text: string) => theme.fg("muted", `${glyphs.dot}${text}`);
|
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.error) {
|
if (state.error) {
|
||||||
return `${label} ${theme.fg("error", `${glyphs.error} error`)} ${theme.fg("muted", state.error)}`;
|
return formatTelegramStatusBarError(theme, glyphs, label, state.error);
|
||||||
}
|
}
|
||||||
if (!state.hasBotToken) {
|
if (!state.hasBotToken) {
|
||||||
return `${label} ${theme.fg("muted", `${glyphs.disabled} not configured`)}${queued}${hint("/telegram-setup")}`;
|
return `${label} ${theme.fg("muted", `${glyphs.disabled} not configured`)}${queued}${hint("/telegram-setup")}`;
|
||||||
}
|
}
|
||||||
if (!state.paired) {
|
if (!state.paired) {
|
||||||
const bot = state.botUsername ? `@${state.botUsername}` : "the bot";
|
const bot = state.botUsername
|
||||||
|
? `@${sanitizeTelegramStatusText(state.botUsername)}`
|
||||||
|
: "the bot";
|
||||||
return `${label} ${theme.fg("warning", `${glyphs.warning} awaiting pairing`)}${queued}${hint(`send /start to ${bot}`)}`;
|
return `${label} ${theme.fg("warning", `${glyphs.warning} awaiting pairing`)}${queued}${hint(`send /start to ${bot}`)}`;
|
||||||
}
|
}
|
||||||
if (state.busLifecyclePhase === "electing") {
|
if (state.busLifecyclePhase === "electing") {
|
||||||
@@ -914,6 +1023,46 @@ export function buildTelegramStatusBarText(
|
|||||||
return `${label} ${theme.fg("success", `${glyphs.enabled} ${role}`)}${queued}${details}`;
|
return `${label} ${theme.fg("success", `${glyphs.enabled} ${role}`)}${queued}${details}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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(
|
||||||
state: Pick<TelegramBridgeStatusLineState, "hasBotToken" | "botUsername">,
|
state: Pick<TelegramBridgeStatusLineState, "hasBotToken" | "botUsername">,
|
||||||
): string {
|
): string {
|
||||||
|
|||||||
@@ -377,6 +377,8 @@ export interface TelegramBridgeApiRuntimeDeps {
|
|||||||
) => void;
|
) => void;
|
||||||
/** Called after a Bot API call or multipart upload succeeded. */
|
/** Called after a Bot API call or multipart upload succeeded. */
|
||||||
onCallSucceeded?: (method: string) => void;
|
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 {
|
||||||
@@ -1249,6 +1251,7 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
|
|||||||
getBotToken: () => string | undefined;
|
getBotToken: () => string | undefined;
|
||||||
recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
|
recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
|
||||||
onCallSucceeded?: TelegramBridgeApiRuntimeDeps["onCallSucceeded"];
|
onCallSucceeded?: TelegramBridgeApiRuntimeDeps["onCallSucceeded"];
|
||||||
|
onCallFailed?: TelegramBridgeApiRuntimeDeps["onCallFailed"];
|
||||||
}): TelegramBridgeApiRuntime {
|
}): TelegramBridgeApiRuntime {
|
||||||
return createTelegramBridgeApiRuntime({
|
return createTelegramBridgeApiRuntime({
|
||||||
client: createTelegramApiClient(deps.getBotToken, {
|
client: createTelegramApiClient(deps.getBotToken, {
|
||||||
@@ -1259,6 +1262,7 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
|
|||||||
tempFileMaxAgeMs: TELEGRAM_TEMP_FILE_MAX_AGE_MS,
|
tempFileMaxAgeMs: TELEGRAM_TEMP_FILE_MAX_AGE_MS,
|
||||||
recordRuntimeEvent: deps.recordRuntimeEvent,
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
||||||
onCallSucceeded: deps.onCallSucceeded,
|
onCallSucceeded: deps.onCallSucceeded,
|
||||||
|
onCallFailed: deps.onCallFailed,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1279,6 +1283,7 @@ export function createTelegramBridgeApiRuntime(
|
|||||||
error,
|
error,
|
||||||
withTelegramTransportDiagnostics(error, { method }),
|
withTelegramTransportDiagnostics(error, { method }),
|
||||||
);
|
);
|
||||||
|
deps.onCallFailed?.(method, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
deps.onCallSucceeded?.(method);
|
deps.onCallSucceeded?.(method);
|
||||||
@@ -1316,6 +1321,7 @@ export function createTelegramBridgeApiRuntime(
|
|||||||
error,
|
error,
|
||||||
withTelegramTransportDiagnostics(error, { method, fileName }),
|
withTelegramTransportDiagnostics(error, { method, fileName }),
|
||||||
);
|
);
|
||||||
|
deps.onCallFailed?.(method, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
deps.onCallSucceeded?.(method);
|
deps.onCallSucceeded?.(method);
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface TelegramTrafficSnapshot {
|
|||||||
received: number;
|
received: number;
|
||||||
/** Messages the bridge delivered to Telegram since the bridge loaded. */
|
/** Messages the bridge delivered to Telegram since the bridge loaded. */
|
||||||
sent: number;
|
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. */
|
/** Time of the latest received or sent message, when there was one. */
|
||||||
lastAtMs?: number;
|
lastAtMs?: number;
|
||||||
}
|
}
|
||||||
@@ -18,6 +20,7 @@ export interface TelegramTrafficSnapshot {
|
|||||||
export interface TelegramTrafficCounters {
|
export interface TelegramTrafficCounters {
|
||||||
recordReceived: () => void;
|
recordReceived: () => void;
|
||||||
recordSent: () => void;
|
recordSent: () => void;
|
||||||
|
recordFailed: () => void;
|
||||||
snapshot: () => TelegramTrafficSnapshot;
|
snapshot: () => TelegramTrafficSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +51,7 @@ export function createTelegramTrafficCounters(
|
|||||||
): TelegramTrafficCounters {
|
): TelegramTrafficCounters {
|
||||||
let received = 0;
|
let received = 0;
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
|
let failed = 0;
|
||||||
let lastAtMs: number | undefined;
|
let lastAtMs: number | undefined;
|
||||||
return {
|
return {
|
||||||
recordReceived() {
|
recordReceived() {
|
||||||
@@ -58,9 +62,13 @@ export function createTelegramTrafficCounters(
|
|||||||
sent += 1;
|
sent += 1;
|
||||||
lastAtMs = now();
|
lastAtMs = now();
|
||||||
},
|
},
|
||||||
|
recordFailed() {
|
||||||
|
failed += 1;
|
||||||
|
},
|
||||||
snapshot: () => ({
|
snapshot: () => ({
|
||||||
received,
|
received,
|
||||||
sent,
|
sent,
|
||||||
|
failed,
|
||||||
...(lastAtMs === undefined ? {} : { lastAtMs }),
|
...(lastAtMs === undefined ? {} : { lastAtMs }),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -76,11 +84,15 @@ export interface TelegramTrafficStatusBridgeDeps<TContext> {
|
|||||||
error: unknown,
|
error: unknown,
|
||||||
details?: Record<string, unknown>,
|
details?: Record<string, unknown>,
|
||||||
) => void;
|
) => void;
|
||||||
|
/** Told about every message delivery that failed, e.g. to raise a notice. */
|
||||||
|
onDeliveryFailed?: (error: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TelegramTrafficStatusBridge<TContext> {
|
export interface TelegramTrafficStatusBridge<TContext> {
|
||||||
/** Observer for successful Bot API calls; counts delivered messages. */
|
/** Observer for successful Bot API calls; counts delivered messages. */
|
||||||
onCallSucceeded: (method: string) => void;
|
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. */
|
/** Observer for messages accepted from the paired owner. */
|
||||||
onAuthorizedMessage: (ctx: TContext) => void;
|
onAuthorizedMessage: (ctx: TContext) => void;
|
||||||
}
|
}
|
||||||
@@ -107,6 +119,13 @@ export function createTelegramTrafficStatusBridge<TContext>(
|
|||||||
const ctx = deps.getContext();
|
const ctx = deps.getContext();
|
||||||
if (ctx !== undefined) refreshStatus(ctx);
|
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) {
|
onAuthorizedMessage(ctx) {
|
||||||
deps.counters.recordReceived();
|
deps.counters.recordReceived();
|
||||||
refreshStatus(ctx);
|
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,
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
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"));
|
||||||
|
});
|
||||||
@@ -639,6 +639,70 @@ test("Poll loop keeps the paired user when the offset is committed after the ini
|
|||||||
assert.equal(saved.lastUpdateId, 2);
|
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 () => {
|
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[] = [];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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:/);
|
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", () => {
|
test("Status lines expose thread reconciliation state", () => {
|
||||||
const lines = buildTelegramBridgeStatusLines({
|
const lines = buildTelegramBridgeStatusLines({
|
||||||
botUsername: "demo_bot",
|
botUsername: "demo_bot",
|
||||||
|
|||||||
+52
-10
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Regression tests for the Telegram traffic counters
|
* 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";
|
import assert from "node:assert/strict";
|
||||||
@@ -13,17 +13,20 @@ import {
|
|||||||
isTelegramOutboundMessageMethod,
|
isTelegramOutboundMessageMethod,
|
||||||
} from "../lib/traffic.ts";
|
} 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;
|
let nowMs = 1_000;
|
||||||
const counters = createTelegramTrafficCounters(() => nowMs);
|
const counters = createTelegramTrafficCounters(() => nowMs);
|
||||||
assert.deepEqual(counters.snapshot(), { received: 0, sent: 0 });
|
assert.deepEqual(counters.snapshot(), { received: 0, sent: 0, failed: 0 });
|
||||||
counters.recordReceived();
|
counters.recordReceived();
|
||||||
nowMs = 2_000;
|
nowMs = 2_000;
|
||||||
counters.recordSent();
|
counters.recordSent();
|
||||||
counters.recordSent();
|
counters.recordSent();
|
||||||
|
nowMs = 3_000;
|
||||||
|
counters.recordFailed();
|
||||||
assert.deepEqual(counters.snapshot(), {
|
assert.deepEqual(counters.snapshot(), {
|
||||||
received: 1,
|
received: 1,
|
||||||
sent: 2,
|
sent: 2,
|
||||||
|
failed: 1,
|
||||||
lastAtMs: 2_000,
|
lastAtMs: 2_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -57,13 +60,22 @@ test("Status lines report the message counters and the time of the last one", ()
|
|||||||
queuedItems: [],
|
queuedItems: [],
|
||||||
recentRuntimeEvents: [],
|
recentRuntimeEvents: [],
|
||||||
};
|
};
|
||||||
const withTraffic = buildTelegramBridgeStatusLines({
|
const lastAtMs = new Date(2026, 9, 1, 14, 32).getTime();
|
||||||
|
const clean = buildTelegramBridgeStatusLines({
|
||||||
...baseState,
|
...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(
|
assert.ok(
|
||||||
withTraffic.includes("- messages: 12 received, 11 sent, last 14:32"),
|
clean.includes("- messages: 12 received, 11 sent, last 14:32"),
|
||||||
withTraffic.join("\n"),
|
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);
|
const withoutTraffic = buildTelegramBridgeStatusLines(baseState);
|
||||||
assert.equal(withoutTraffic.some((line) => line.startsWith("- messages:")), false);
|
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("sendChatAction");
|
||||||
bridge.onCallSucceeded("editMessageText");
|
bridge.onCallSucceeded("editMessageText");
|
||||||
assert.deepEqual(counters.snapshot(), { received: 0, sent: 0 });
|
assert.deepEqual(counters.snapshot(), { received: 0, sent: 0, failed: 0 });
|
||||||
assert.deepEqual(refreshed, []);
|
assert.deepEqual(refreshed, []);
|
||||||
bridge.onCallSucceeded("sendRichMessage");
|
bridge.onCallSucceeded("sendRichMessage");
|
||||||
assert.equal(counters.snapshot().sent, 1);
|
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"]);
|
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", () => {
|
test("Traffic bridge records a failed status refresh instead of breaking the message path", () => {
|
||||||
const counters = createTelegramTrafficCounters();
|
const counters = createTelegramTrafficCounters();
|
||||||
const recorded: string[] = [];
|
const recorded: string[] = [];
|
||||||
@@ -112,10 +151,13 @@ test("Traffic bridge records a failed status refresh instead of breaking the mes
|
|||||||
});
|
});
|
||||||
bridge.onCallSucceeded("sendMessage");
|
bridge.onCallSucceeded("sendMessage");
|
||||||
bridge.onAuthorizedMessage("session");
|
bridge.onAuthorizedMessage("session");
|
||||||
assert.deepEqual(counters.snapshot().received, 1);
|
bridge.onCallFailed("sendMessage", new Error("HTTP 500"));
|
||||||
assert.deepEqual(counters.snapshot().sent, 1);
|
assert.equal(counters.snapshot().received, 1);
|
||||||
|
assert.equal(counters.snapshot().sent, 1);
|
||||||
|
assert.equal(counters.snapshot().failed, 1);
|
||||||
assert.deepEqual(recorded, [
|
assert.deepEqual(recorded, [
|
||||||
"status:stale ctx:traffic-refresh",
|
"status:stale ctx:traffic-refresh",
|
||||||
"status:stale ctx:traffic-refresh",
|
"status:stale ctx:traffic-refresh",
|
||||||
|
"status:stale ctx:traffic-refresh",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user