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
@@ -329,6 +329,8 @@ export function registerTelegramLifecycleRuntimeHooks({
|
||||
updateStatus,
|
||||
recordRuntimeEvent,
|
||||
}: TelegramLifecycleBindingDeps): void {
|
||||
// Whether this bridge set the host spinner text, so it only clears its own.
|
||||
let telegramWorkingMessageShown = false;
|
||||
const agentEndResetter = Runtime.createTelegramAgentEndResetter({
|
||||
abort,
|
||||
typing,
|
||||
@@ -589,6 +591,14 @@ export function registerTelegramLifecycleRuntimeHooks({
|
||||
await agentStartWithDedupReset(event, ctx);
|
||||
activityRuntime.onAgentStart(activeTurnRuntime.get()?.target);
|
||||
startAgentActivityTypingLoop(ctx);
|
||||
const telegramTurn = activeTurnRuntime.get();
|
||||
if (telegramTurn) {
|
||||
Pi.setExtensionWorkingMessage(
|
||||
ctx,
|
||||
Status.formatTelegramWorkingMessage(telegramTurn.statusSummary),
|
||||
);
|
||||
telegramWorkingMessageShown = true;
|
||||
}
|
||||
},
|
||||
async onToolExecutionStart(event, ctx) {
|
||||
if (!isSessionContextActive(ctx)) return;
|
||||
@@ -638,6 +648,10 @@ export function registerTelegramLifecycleRuntimeHooks({
|
||||
onAgentSettled(_event, ctx) {
|
||||
if (!isSessionContextActive(ctx)) return;
|
||||
activityRuntime.onAgentSettled();
|
||||
if (telegramWorkingMessageShown) {
|
||||
telegramWorkingMessageShown = false;
|
||||
Pi.setExtensionWorkingMessage(ctx, undefined);
|
||||
}
|
||||
},
|
||||
settleOnTerminalAgentEnd: Pi.isOmpHost(pi),
|
||||
onBeforeAgentStart: Prompts.createTelegramProactiveBeforeAgentStartHook({
|
||||
|
||||
+8
-3
@@ -777,6 +777,8 @@ export interface TelegramUserPairingRuntimeDeps<TContext> {
|
||||
setAllowedUserId: (userId: number) => void;
|
||||
persistConfig: () => Promise<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> {
|
||||
@@ -827,13 +829,16 @@ export function createTelegramUserPairingRuntime<TContext>(
|
||||
deps: TelegramUserPairingRuntimeDeps<TContext>,
|
||||
): TelegramUserPairingRuntime<TContext> {
|
||||
return {
|
||||
pairIfNeeded: (userId, ctx) =>
|
||||
pairTelegramUserIfNeeded(userId, {
|
||||
pairIfNeeded: async (userId, ctx) => {
|
||||
const paired = await pairTelegramUserIfNeeded(userId, {
|
||||
allowedUserId: deps.getAllowedUserId(),
|
||||
ctx,
|
||||
setAllowedUserId: deps.setAllowedUserId,
|
||||
persistConfig: deps.persistConfig,
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
return ctx.isIdle();
|
||||
}
|
||||
|
||||
@@ -163,6 +163,7 @@ export function createTelegramPollingControllerRuntime<
|
||||
updateStatus: deps.updateStatus,
|
||||
sleep: deps.sleep,
|
||||
maxUpdateFailures: deps.maxUpdateFailures,
|
||||
onRecovered: deps.onRecovered,
|
||||
recordRuntimeEvent: deps.recordRuntimeEvent,
|
||||
}),
|
||||
updateStatus: deps.updateStatus,
|
||||
@@ -913,6 +914,8 @@ export interface TelegramPollLoopDeps<
|
||||
onStatusReset: () => void;
|
||||
sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
|
||||
maxUpdateFailures?: number;
|
||||
/** Called on the first successful poll after a failed attempt. */
|
||||
onRecovered?: () => void;
|
||||
}
|
||||
|
||||
export interface TelegramPollLoopRunnerDeps<
|
||||
@@ -930,6 +933,8 @@ export interface TelegramPollLoopRunnerDeps<
|
||||
updateStatus: (ctx: TContext, message?: string) => void;
|
||||
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
||||
maxUpdateFailures?: number;
|
||||
/** Called on the first successful poll after a failed attempt. */
|
||||
onRecovered?: (ctx: TContext) => void;
|
||||
}
|
||||
|
||||
export function sleepTelegramPollingRetry(
|
||||
@@ -994,6 +999,15 @@ export function createTelegramPollLoopRunner<
|
||||
},
|
||||
sleep,
|
||||
maxUpdateFailures: deps.maxUpdateFailures,
|
||||
onRecovered: () => {
|
||||
try {
|
||||
deps.onRecovered?.(ctx);
|
||||
} catch (error) {
|
||||
deps.recordRuntimeEvent?.("polling", error, {
|
||||
phase: "recovered-notice",
|
||||
});
|
||||
}
|
||||
},
|
||||
recordRuntimeEvent: deps.recordRuntimeEvent,
|
||||
});
|
||||
}
|
||||
@@ -1041,6 +1055,7 @@ export async function runTelegramPollLoop<
|
||||
const admittedUpdates = new Set<number>();
|
||||
let handledUpdateFailureRethrown = false;
|
||||
let consecutiveGetUpdatesConflicts = 0;
|
||||
let failing = false;
|
||||
while (!deps.signal.aborted) {
|
||||
try {
|
||||
const updates = await deps.getUpdates(
|
||||
@@ -1048,6 +1063,10 @@ export async function runTelegramPollLoop<
|
||||
deps.signal,
|
||||
);
|
||||
consecutiveGetUpdatesConflicts = 0;
|
||||
if (failing) {
|
||||
failing = false;
|
||||
deps.onRecovered?.();
|
||||
}
|
||||
for (const update of updates) {
|
||||
if (admittedUpdates.has(update.update_id)) {
|
||||
deps.config.lastUpdateId = update.update_id;
|
||||
@@ -1105,6 +1124,7 @@ export async function runTelegramPollLoop<
|
||||
continue;
|
||||
}
|
||||
consecutiveGetUpdatesConflicts = 0;
|
||||
failing = true;
|
||||
deps.onErrorStatus(getTelegramPollingErrorMessage(error));
|
||||
await deps.sleep(TELEGRAM_POLLING_RETRY_MS, deps.signal);
|
||||
if (deps.signal.aborted) return;
|
||||
|
||||
@@ -499,6 +499,25 @@ export function formatQueuedTelegramItemsStatus<TContext = unknown>(
|
||||
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(
|
||||
text: string,
|
||||
maxWords = 5,
|
||||
|
||||
@@ -556,6 +556,8 @@ export interface TelegramInboundRouteRuntimeDeps<
|
||||
) => Promise<TResponse>;
|
||||
/** Called for every message from the paired owner, before it is dispatched. */
|
||||
onAuthorizedMessage?: (ctx: TContext) => void;
|
||||
/** Called when the first Telegram user is accepted as the bridge owner. */
|
||||
onPaired?: (userId: number, ctx: TContext) => void;
|
||||
getCurrentInstanceId?: () => string | undefined;
|
||||
getMessageOwnership?: Updates.TelegramMessageOwnershipLookup;
|
||||
getTargetOwnership?: Updates.TelegramTargetOwnershipLookup;
|
||||
@@ -2076,6 +2078,7 @@ export function createTelegramInboundRouteRuntime<
|
||||
setAllowedUserId: deps.configStore.setAllowedUserId,
|
||||
persistConfig: deps.configStore.persist,
|
||||
updateStatus: deps.updateStatus,
|
||||
onPaired: deps.onPaired,
|
||||
removePendingMediaGroupMessages: deps.mediaGroupRuntime.removeMessages,
|
||||
removeQueuedTelegramTurnsByMessageIds:
|
||||
deps.queueMutationRuntime.removeByMessageIds,
|
||||
|
||||
+158
-9
@@ -8,6 +8,8 @@
|
||||
export interface TelegramStatusTraffic {
|
||||
received: number;
|
||||
sent: number;
|
||||
/** Deliveries that failed after the transport retries; absent means none. */
|
||||
failed?: number;
|
||||
lastAtMs?: number;
|
||||
}
|
||||
|
||||
@@ -253,6 +255,8 @@ export interface TelegramStatusBarState {
|
||||
processing: boolean;
|
||||
processingStatus?: string;
|
||||
queuedStatus: string;
|
||||
/** Summaries of the queued turns, in dispatch order. */
|
||||
queuedPreview?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -303,6 +307,7 @@ export interface TelegramBridgeStatusRuntimeDeps<
|
||||
hasPendingModelSwitch: () => boolean;
|
||||
getQueuedItems: () => TQueueItem[];
|
||||
formatQueuedStatus: (items: TQueueItem[]) => string;
|
||||
getQueuedPreview?: (items: TQueueItem[]) => string[];
|
||||
getRecentRuntimeEvents: () => TelegramRuntimeEvent[];
|
||||
getRuntimeLockState?: () => string;
|
||||
getBusRole?: () => TelegramBridgeBusRole | undefined;
|
||||
@@ -597,7 +602,7 @@ export function createTelegramStatusRuntime<
|
||||
const statusKey = deps.statusKey ?? "telegram";
|
||||
return {
|
||||
updateStatus: (ctx, error) => {
|
||||
const text = buildTelegramStatusBarText(
|
||||
const lines = buildTelegramStatusBarLines(
|
||||
ctx.ui.theme,
|
||||
deps.getStatusBarState(ctx, error),
|
||||
);
|
||||
@@ -605,11 +610,11 @@ export function createTelegramStatusRuntime<
|
||||
// The host strips ANSI from status text, so setStatus would drop the
|
||||
// theme colors. String widgets keep them; clear the plain footer entry
|
||||
// so the state is not shown twice.
|
||||
ctx.ui.setWidget(statusKey, [text], { placement: "belowEditor" });
|
||||
ctx.ui.setWidget(statusKey, lines, { placement: "belowEditor" });
|
||||
ctx.ui.setStatus(statusKey, undefined);
|
||||
return;
|
||||
}
|
||||
ctx.ui.setStatus(statusKey, text);
|
||||
ctx.ui.setStatus(statusKey, lines[0]);
|
||||
},
|
||||
getStatusLines: (options) =>
|
||||
buildTelegramBridgeStatusLines(deps.getBridgeStatusLineState(), options),
|
||||
@@ -658,6 +663,7 @@ export function createTelegramBridgeStatusRuntime<
|
||||
queuedItems: queuedItems.length,
|
||||
}),
|
||||
queuedStatus: deps.formatQueuedStatus(queuedItems),
|
||||
queuedPreview: deps.getQueuedPreview?.(queuedItems),
|
||||
error,
|
||||
};
|
||||
},
|
||||
@@ -846,10 +852,15 @@ function buildTelegramStatusBarDetails(
|
||||
state: TelegramStatusBarState,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
if (state.botUsername) parts.push(`@${state.botUsername}`);
|
||||
if (state.botUsername) {
|
||||
parts.push(`@${sanitizeTelegramStatusText(state.botUsername)}`);
|
||||
}
|
||||
if (state.traffic) {
|
||||
const failed = state.traffic.failed
|
||||
? ` ${glyphs.error}${state.traffic.failed}`
|
||||
: "";
|
||||
parts.push(
|
||||
`${glyphs.input}${state.traffic.received} ${glyphs.output}${state.traffic.sent}`,
|
||||
`${glyphs.input}${state.traffic.received} ${glyphs.output}${state.traffic.sent}${failed}`,
|
||||
);
|
||||
if (state.traffic.lastAtMs !== undefined) {
|
||||
parts.push(
|
||||
@@ -871,28 +882,126 @@ function buildTelegramTrafficLines(
|
||||
? ""
|
||||
: `, last ${formatTelegramStatusBarClock(state.traffic.lastAtMs)}`;
|
||||
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(
|
||||
theme: TelegramStatusBarTheme,
|
||||
state: TelegramStatusBarState,
|
||||
): string {
|
||||
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 queued = state.queuedStatus
|
||||
? theme.fg("success", state.queuedStatus)
|
||||
: "";
|
||||
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) {
|
||||
return `${label} ${theme.fg("muted", `${glyphs.disabled} not configured`)}${queued}${hint("/telegram-setup")}`;
|
||||
}
|
||||
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}`)}`;
|
||||
}
|
||||
if (state.busLifecyclePhase === "electing") {
|
||||
@@ -914,6 +1023,46 @@ export function buildTelegramStatusBarText(
|
||||
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(
|
||||
state: Pick<TelegramBridgeStatusLineState, "hasBotToken" | "botUsername">,
|
||||
): string {
|
||||
|
||||
@@ -377,6 +377,8 @@ export interface TelegramBridgeApiRuntimeDeps {
|
||||
) => void;
|
||||
/** Called after a Bot API call or multipart upload succeeded. */
|
||||
onCallSucceeded?: (method: string) => void;
|
||||
/** Called after a Bot API call or multipart upload failed for good. */
|
||||
onCallFailed?: (method: string, error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface TelegramBridgeApiRuntime {
|
||||
@@ -1249,6 +1251,7 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
|
||||
getBotToken: () => string | undefined;
|
||||
recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
|
||||
onCallSucceeded?: TelegramBridgeApiRuntimeDeps["onCallSucceeded"];
|
||||
onCallFailed?: TelegramBridgeApiRuntimeDeps["onCallFailed"];
|
||||
}): TelegramBridgeApiRuntime {
|
||||
return createTelegramBridgeApiRuntime({
|
||||
client: createTelegramApiClient(deps.getBotToken, {
|
||||
@@ -1259,6 +1262,7 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
|
||||
tempFileMaxAgeMs: TELEGRAM_TEMP_FILE_MAX_AGE_MS,
|
||||
recordRuntimeEvent: deps.recordRuntimeEvent,
|
||||
onCallSucceeded: deps.onCallSucceeded,
|
||||
onCallFailed: deps.onCallFailed,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1279,6 +1283,7 @@ export function createTelegramBridgeApiRuntime(
|
||||
error,
|
||||
withTelegramTransportDiagnostics(error, { method }),
|
||||
);
|
||||
deps.onCallFailed?.(method, error);
|
||||
throw error;
|
||||
}
|
||||
deps.onCallSucceeded?.(method);
|
||||
@@ -1316,6 +1321,7 @@ export function createTelegramBridgeApiRuntime(
|
||||
error,
|
||||
withTelegramTransportDiagnostics(error, { method, fileName }),
|
||||
);
|
||||
deps.onCallFailed?.(method, error);
|
||||
throw error;
|
||||
}
|
||||
deps.onCallSucceeded?.(method);
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface TelegramTrafficSnapshot {
|
||||
received: number;
|
||||
/** Messages the bridge delivered to Telegram since the bridge loaded. */
|
||||
sent: number;
|
||||
/** Messages that could not be delivered after the transport retries. */
|
||||
failed: number;
|
||||
/** Time of the latest received or sent message, when there was one. */
|
||||
lastAtMs?: number;
|
||||
}
|
||||
@@ -18,6 +20,7 @@ export interface TelegramTrafficSnapshot {
|
||||
export interface TelegramTrafficCounters {
|
||||
recordReceived: () => void;
|
||||
recordSent: () => void;
|
||||
recordFailed: () => void;
|
||||
snapshot: () => TelegramTrafficSnapshot;
|
||||
}
|
||||
|
||||
@@ -48,6 +51,7 @@ export function createTelegramTrafficCounters(
|
||||
): TelegramTrafficCounters {
|
||||
let received = 0;
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
let lastAtMs: number | undefined;
|
||||
return {
|
||||
recordReceived() {
|
||||
@@ -58,9 +62,13 @@ export function createTelegramTrafficCounters(
|
||||
sent += 1;
|
||||
lastAtMs = now();
|
||||
},
|
||||
recordFailed() {
|
||||
failed += 1;
|
||||
},
|
||||
snapshot: () => ({
|
||||
received,
|
||||
sent,
|
||||
failed,
|
||||
...(lastAtMs === undefined ? {} : { lastAtMs }),
|
||||
}),
|
||||
};
|
||||
@@ -76,11 +84,15 @@ export interface TelegramTrafficStatusBridgeDeps<TContext> {
|
||||
error: unknown,
|
||||
details?: Record<string, unknown>,
|
||||
) => void;
|
||||
/** Told about every message delivery that failed, e.g. to raise a notice. */
|
||||
onDeliveryFailed?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface TelegramTrafficStatusBridge<TContext> {
|
||||
/** Observer for successful Bot API calls; counts delivered messages. */
|
||||
onCallSucceeded: (method: string) => void;
|
||||
/** Observer for failed Bot API calls; counts undelivered messages. */
|
||||
onCallFailed: (method: string, error: unknown) => void;
|
||||
/** Observer for messages accepted from the paired owner. */
|
||||
onAuthorizedMessage: (ctx: TContext) => void;
|
||||
}
|
||||
@@ -107,6 +119,13 @@ export function createTelegramTrafficStatusBridge<TContext>(
|
||||
const ctx = deps.getContext();
|
||||
if (ctx !== undefined) refreshStatus(ctx);
|
||||
},
|
||||
onCallFailed(method, error) {
|
||||
if (!isTelegramOutboundMessageMethod(method)) return;
|
||||
deps.counters.recordFailed();
|
||||
deps.onDeliveryFailed?.(error);
|
||||
const ctx = deps.getContext();
|
||||
if (ctx !== undefined) refreshStatus(ctx);
|
||||
},
|
||||
onAuthorizedMessage(ctx) {
|
||||
deps.counters.recordReceived();
|
||||
refreshStatus(ctx);
|
||||
|
||||
@@ -850,6 +850,7 @@ export function createTelegramPairedUpdateRuntime<
|
||||
setAllowedUserId: deps.setAllowedUserId,
|
||||
persistConfig: deps.persistConfig,
|
||||
updateStatus: deps.updateStatus,
|
||||
onPaired: deps.onPaired,
|
||||
}).pairIfNeeded,
|
||||
answerCallbackQuery: deps.answerCallbackQuery,
|
||||
answerGuestQuery: deps.answerGuestQuery,
|
||||
|
||||
Reference in new issue
Block a user