diff --git a/index.ts b/index.ts index 2fa90b1..bae52b1 100644 --- a/index.ts +++ b/index.ts @@ -43,6 +43,7 @@ import * as TelegramApi from "./lib/telegram-api.ts"; import * as TextGroups from "./lib/text-groups.ts"; import * as ThreadReconciler from "./lib/thread-reconciler.ts"; import * as TimeInjection from "./lib/time-injection.ts"; +import * as Traffic from "./lib/traffic.ts"; import * as Updates from "./lib/updates.ts"; import * as Voice from "./lib/voice.ts"; @@ -274,11 +275,13 @@ export default function (pi: Pi.ExtensionAPI) { getFollowerThreadName: telegramBusFollowerRegistrationState.getThreadName, getCurrentIdentity: getCurrentInstanceThreadIdentity, }); + const trafficCounters = Traffic.createTelegramTrafficCounters(); const statusRuntime = Status.createTelegramBridgeStatusRuntime< Pi.ExtensionContext, Queue.TelegramQueueItem >({ getConfig: configStore.get, + getTraffic: trafficCounters.snapshot, getActiveProfileName: configStore.getActiveProfileName, getDiagnosticPaths: Paths.getTelegramDiagnosticsDisplayPaths, isPollingActive: Polling.createTelegramPollingActivityReader( @@ -323,6 +326,12 @@ export default function (pi: Pi.ExtensionAPI) { getCwd: Pi.getExtensionContextCwd, recordRuntimeEvent, }); + const trafficBridge = Traffic.createTelegramTrafficStatusBridge({ + counters: trafficCounters, + getContext: telegramSessionContextStore.get, + updateStatus, + recordRuntimeEvent, + }); // --- Telegram API --- @@ -330,6 +339,7 @@ export default function (pi: Pi.ExtensionAPI) { TelegramApi.createDefaultTelegramBridgeApiRuntime({ getBotToken: configStore.getBotToken, recordRuntimeEvent, + onCallSucceeded: trafficBridge.onCallSucceeded, }); const telegramBusFollowerClients = BusFollower.createTelegramBusFollowerClientRuntime< @@ -645,6 +655,7 @@ export default function (pi: Pi.ExtensionAPI) { const inboundRouteRuntime = Routing.createTelegramInboundRouteRuntime({ configStore, callApi: callTelegramApi, + onAuthorizedMessage: trafficBridge.onAuthorizedMessage, getCurrentInstanceId() { return telegramInstanceId; }, diff --git a/lib/routing.ts b/lib/routing.ts index f106660..35e2c95 100644 --- a/lib/routing.ts +++ b/lib/routing.ts @@ -554,6 +554,8 @@ export interface TelegramInboundRouteRuntimeDeps< method: string, body: Record, ) => Promise; + /** Called for every message from the paired owner, before it is dispatched. */ + onAuthorizedMessage?: (ctx: TContext) => void; getCurrentInstanceId?: () => string | undefined; getMessageOwnership?: Updates.TelegramMessageOwnershipLookup; getTargetOwnership?: Updates.TelegramTargetOwnershipLookup; @@ -2086,6 +2088,7 @@ export function createTelegramInboundRouteRuntime< handleAuthorizedTelegramCallbackQuery: callbackHandler, sendTextReply: deps.sendTextReply, handleAuthorizedTelegramMessage: async (message, ctx) => { + deps.onAuthorizedMessage?.(ctx); if (typeof message.message_thread_id === "number") { await deps.handleTelegramThreadTargetObserved?.( { diff --git a/lib/status.ts b/lib/status.ts index 9c3321a..c0f44ea 100644 --- a/lib/status.ts +++ b/lib/status.ts @@ -4,6 +4,13 @@ * Builds usage, cost, and context summaries for the interactive Telegram status view */ +/** Message counters the status shows; structurally satisfied by the traffic domain. */ +export interface TelegramStatusTraffic { + received: number; + sent: number; + lastAtMs?: number; +} + export type TelegramStatusQueueLane = "control" | "priority" | "default"; export interface TelegramUsageStats { @@ -191,6 +198,7 @@ export interface TelegramBridgeStatusLineState { instanceSlot?: string; instanceThreadName?: string; lockState?: string; + traffic?: TelegramStatusTraffic; pollingActive: boolean; lastUpdateId?: number; activeSourceMessageIds?: number[]; @@ -215,12 +223,28 @@ export interface TelegramStatusBarTheme { token: "accent" | "error" | "muted" | "warning" | "success", text: string, ) => string; + /** + * omp symbol presets (unicode, nerd font, ascii) so the status follows the + * user's glyph set. Hosts that do not expose them get the unicode fallbacks. + */ + status?: { + enabled?: string; + shadowed?: string; + running?: string; + warning?: string; + error?: string; + disabled?: string; + }; + sep?: { dot?: string }; + icon?: { input?: string; output?: string; time?: string }; } export interface TelegramStatusBarState { hasBotToken: boolean; pollingActive: boolean; paired: boolean; + botUsername?: string; + traffic?: TelegramStatusTraffic; busRole?: TelegramBridgeBusRole; busLifecyclePhase?: TelegramBridgeBusLifecyclePhase; instanceSlot?: string; @@ -298,6 +322,7 @@ export interface TelegramBridgeStatusRuntimeDeps< getInstanceSlot?: () => string | undefined; getInstanceThreadName?: () => string | undefined; getNowMs?: () => number; + getTraffic?: () => TelegramStatusTraffic; } export interface TelegramBridgeStatusLineOptions { @@ -599,6 +624,8 @@ export function createTelegramBridgeStatusRuntime< hasBotToken: !!config.botToken, pollingActive: deps.isPollingActive(), paired: !!config.allowedUserId, + botUsername: config.botUsername, + traffic: deps.getTraffic?.(), busRole: deps.getBusRole?.(), busLifecyclePhase: deps.getBusLifecyclePhase?.(), instanceSlot: deps.getInstanceSlot?.(), @@ -639,6 +666,7 @@ export function createTelegramBridgeStatusRuntime< instanceSlot: deps.getInstanceSlot?.(), instanceThreadName: deps.getInstanceThreadName?.(), lockState: deps.getRuntimeLockState?.(), + traffic: deps.getTraffic?.(), pollingActive: deps.isPollingActive(), lastUpdateId: config.lastUpdateId, activeSourceMessageIds: deps.getActiveSourceMessageIds(), @@ -761,38 +789,116 @@ function getTelegramStatusBarLabel(state: TelegramStatusBarState): string { return threadName; } +interface TelegramStatusBarGlyphs { + enabled: string; + shadowed: string; + running: string; + warning: string; + error: string; + disabled: string; + dot: string; + input: string; + output: string; + time: string; +} + +function resolveTelegramStatusBarGlyphs( + theme: TelegramStatusBarTheme, +): TelegramStatusBarGlyphs { + return { + enabled: theme.status?.enabled ?? "●", + shadowed: theme.status?.shadowed ?? "○", + running: theme.status?.running ?? "⟳", + warning: theme.status?.warning ?? "⚠", + error: theme.status?.error ?? "✘", + disabled: theme.status?.disabled ?? "⦸", + dot: theme.sep?.dot ?? " · ", + input: theme.icon?.input ?? "↓", + output: theme.icon?.output ?? "↑", + time: theme.icon?.time ?? "⏱", + }; +} + +function formatTelegramStatusBarClock(timestampMs: number): string { + const date = new Date(timestampMs); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + return `${hours}:${minutes}`; +} + +/** Bot name, message counters and last activity, each behind a muted dot. */ +function buildTelegramStatusBarDetails( + theme: TelegramStatusBarTheme, + glyphs: TelegramStatusBarGlyphs, + state: TelegramStatusBarState, +): string { + const parts: string[] = []; + if (state.botUsername) parts.push(`@${state.botUsername}`); + if (state.traffic) { + parts.push( + `${glyphs.input}${state.traffic.received} ${glyphs.output}${state.traffic.sent}`, + ); + if (state.traffic.lastAtMs !== undefined) { + parts.push( + `${glyphs.time} ${formatTelegramStatusBarClock(state.traffic.lastAtMs)}`, + ); + } + } + return parts + .map((part) => theme.fg("muted", `${glyphs.dot}${part}`)) + .join(""); +} + +function buildTelegramTrafficLines( + state: Pick, +): string[] { + if (!state.traffic) return []; + const last = + state.traffic.lastAtMs === undefined + ? "" + : `, last ${formatTelegramStatusBarClock(state.traffic.lastAtMs)}`; + return [ + `- messages: ${state.traffic.received} received, ${state.traffic.sent} sent${last}`, + ]; +} + export function buildTelegramStatusBarText( theme: TelegramStatusBarTheme, state: TelegramStatusBarState, ): string { + const glyphs = resolveTelegramStatusBarGlyphs(theme); const label = theme.fg("accent", getTelegramStatusBarLabel(state)); - if (state.error) { - return `${label} ${theme.fg("error", "error")} ${theme.fg("muted", state.error)}`; - } + const hint = (text: string) => theme.fg("muted", `${glyphs.dot}${text}`); const queued = state.queuedStatus ? theme.fg("success", state.queuedStatus) : ""; - if (!state.hasBotToken) - return `${label} ${theme.fg("muted", "not configured")}${queued}`; - if (!state.paired) - return `${label} ${theme.fg("warning", "awaiting pairing")}${queued}`; - if (state.busLifecyclePhase === "electing") - return `${label} ${theme.fg("warning", "electing")}${queued}`; - if (!state.pollingActive && state.busRole !== "follower") - return `${theme.fg("accent", "telegram")} ${theme.fg("muted", "disconnected")}${queued}`; + if (state.error) { + return `${label} ${theme.fg("error", `${glyphs.error} error`)} ${theme.fg("muted", 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"; + return `${label} ${theme.fg("warning", `${glyphs.warning} awaiting pairing`)}${queued}${hint(`send /start to ${bot}`)}`; + } + if (state.busLifecyclePhase === "electing") { + return `${label} ${theme.fg("warning", `${glyphs.running} electing`)}${queued}`; + } + if (!state.pollingActive && state.busRole !== "follower") { + return `${theme.fg("accent", "telegram")} ${theme.fg("muted", `${glyphs.shadowed} disconnected`)}${queued}${hint("/telegram-connect")}`; + } + const details = buildTelegramStatusBarDetails(theme, glyphs, state); if (state.processing) { const processingStatus = state.queuedStatus ? "active" : (state.processingStatus ?? "processing"); const processingToken = processingStatus === "active" ? "warning" : "accent"; - return `${label} ${theme.fg(processingToken, processingStatus)}${queued}`; + return `${label} ${theme.fg(processingToken, `${glyphs.running} ${processingStatus}`)}${queued}${details}`; } - if (state.busRole === "follower") - return `${label} ${theme.fg("success", "follower")}${queued}`; - if (state.busRole === "leader") - return `${label} ${theme.fg("success", "leader")}`; - return `${label} ${theme.fg("success", "connected")}`; + const role = state.busRole ?? "connected"; + return `${label} ${theme.fg("success", `${glyphs.enabled} ${role}`)}${queued}${details}`; } function formatTelegramBridgeBotStatus( @@ -1079,6 +1185,7 @@ function buildTelegramBridgeCompactStatusLines( `- bot: ${formatTelegramBridgeBotStatus(state)}`, ...(state.activeProfileName ? [`- profile: ${state.activeProfileName}`] : []), `- user: ${state.allowedUserId ?? "not paired"}`, + ...buildTelegramTrafficLines(state), ...(state.botThreadMode ? [`- thread mode: ${state.botThreadMode}`] : []), ...(state.busRole ? [`- role: ${state.busRole}`] : []), ...(state.busLifecyclePhase @@ -1134,6 +1241,7 @@ export function buildTelegramBridgeDiagnosticStatusLines( `- bot: ${formatTelegramBridgeBotStatus(state)}`, ...(state.activeProfileName ? [`- profile: ${state.activeProfileName}`] : []), `- allowed user: ${state.allowedUserId ?? "not paired"}`, + ...buildTelegramTrafficLines(state), ...(state.botThreadMode ? [ `- thread mode: ${state.botThreadMode}${state.botThreadModeAction ? ` reconcile=${state.botThreadModeAction}` : ""}`, diff --git a/lib/telegram-api.ts b/lib/telegram-api.ts index 7f78fb9..65a9529 100644 --- a/lib/telegram-api.ts +++ b/lib/telegram-api.ts @@ -375,6 +375,8 @@ export interface TelegramBridgeApiRuntimeDeps { error: unknown, details?: Record, ) => void; + /** Called after a Bot API call or multipart upload succeeded. */ + onCallSucceeded?: (method: string) => void; } export interface TelegramBridgeApiRuntime { @@ -1246,6 +1248,7 @@ export function createTelegramAssistantDraftSender(deps: { export function createDefaultTelegramBridgeApiRuntime(deps: { getBotToken: () => string | undefined; recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"]; + onCallSucceeded?: TelegramBridgeApiRuntimeDeps["onCallSucceeded"]; }): TelegramBridgeApiRuntime { return createTelegramBridgeApiRuntime({ client: createTelegramApiClient(deps.getBotToken, { @@ -1255,6 +1258,7 @@ export function createDefaultTelegramBridgeApiRuntime(deps: { maxFileSizeBytes: TELEGRAM_INBOUND_FILE_MAX_BYTES, tempFileMaxAgeMs: TELEGRAM_TEMP_FILE_MAX_AGE_MS, recordRuntimeEvent: deps.recordRuntimeEvent, + onCallSucceeded: deps.onCallSucceeded, }); } @@ -1266,8 +1270,9 @@ export function createTelegramBridgeApiRuntime( body: Record, options?: TelegramApiCallOptions, ): Promise => { + let response: TResponse; try { - return await deps.client.call(method, body, options); + response = await deps.client.call(method, body, options); } catch (error) { deps.recordRuntimeEvent( "api", @@ -1276,6 +1281,8 @@ export function createTelegramBridgeApiRuntime( ); throw error; } + deps.onCallSucceeded?.(method); + return response; }; return { call: callRecorded, @@ -1285,16 +1292,17 @@ export function createTelegramBridgeApiRuntime( * photos, documents, animations, etc.). * Errors are recorded under the "multipart" category for diagnostics. */ - callMultipart: async ( - method, - fields, - fileField, - filePath, - fileName, - options, - ) => { + callMultipart: async ( + method: string, + fields: Record, + fileField: string, + filePath: string, + fileName: string, + options?: TelegramApiCallOptions, + ): Promise => { + let response: TResponse; try { - return await deps.client.callMultipart( + response = await deps.client.callMultipart( method, fields, fileField, @@ -1310,6 +1318,8 @@ export function createTelegramBridgeApiRuntime( ); throw error; } + deps.onCallSucceeded?.(method); + return response; }, /** diff --git a/lib/traffic.ts b/lib/traffic.ts new file mode 100644 index 0000000..4f933d9 --- /dev/null +++ b/lib/traffic.ts @@ -0,0 +1,115 @@ +/** + * Telegram bridge traffic counters + * Zones: telegram transport, status + * Owns in-memory received/sent message counts and last-activity time for the status bar + * + * This domain is pure: it counts, it does not read config, call Telegram, or import other domains. + */ + +export interface TelegramTrafficSnapshot { + /** Authorized messages received from the paired owner since the bridge loaded. */ + received: number; + /** Messages the bridge delivered to Telegram since the bridge loaded. */ + sent: number; + /** Time of the latest received or sent message, when there was one. */ + lastAtMs?: number; +} + +export interface TelegramTrafficCounters { + recordReceived: () => void; + recordSent: () => void; + snapshot: () => TelegramTrafficSnapshot; +} + +/** + * Bot API methods that deliver a message the owner can read. Typing actions, + * drafts, edits, reactions and callback answers are not messages. + */ +const TELEGRAM_OUTBOUND_MESSAGE_METHODS: Readonly> = { + sendMessage: true, + sendRichMessage: true, + sendPhoto: true, + sendDocument: true, + sendAudio: true, + sendVideo: true, + sendVoice: true, + sendAnimation: true, + sendVideoNote: true, + sendMediaGroup: true, + sendSticker: true, +}; + +export function isTelegramOutboundMessageMethod(method: string): boolean { + return Object.hasOwn(TELEGRAM_OUTBOUND_MESSAGE_METHODS, method); +} + +export function createTelegramTrafficCounters( + now: () => number = Date.now, +): TelegramTrafficCounters { + let received = 0; + let sent = 0; + let lastAtMs: number | undefined; + return { + recordReceived() { + received += 1; + lastAtMs = now(); + }, + recordSent() { + sent += 1; + lastAtMs = now(); + }, + snapshot: () => ({ + received, + sent, + ...(lastAtMs === undefined ? {} : { lastAtMs }), + }), + }; +} + +export interface TelegramTrafficStatusBridgeDeps { + counters: TelegramTrafficCounters; + /** Current session context, when the bridge has one. */ + getContext: () => TContext | undefined; + updateStatus: (ctx: TContext) => void; + recordRuntimeEvent: ( + category: string, + error: unknown, + details?: Record, + ) => void; +} + +export interface TelegramTrafficStatusBridge { + /** Observer for successful Bot API calls; counts delivered messages. */ + onCallSucceeded: (method: string) => void; + /** Observer for messages accepted from the paired owner. */ + onAuthorizedMessage: (ctx: TContext) => void; +} + +/** + * Counts traffic and refreshes the status bar so the counters stay current. A + * status refresh failure (for example a stale session context) is recorded and + * never propagates into the send or receive path that triggered it. + */ +export function createTelegramTrafficStatusBridge( + deps: TelegramTrafficStatusBridgeDeps, +): TelegramTrafficStatusBridge { + const refreshStatus = (ctx: TContext): void => { + try { + deps.updateStatus(ctx); + } catch (error) { + deps.recordRuntimeEvent("status", error, { phase: "traffic-refresh" }); + } + }; + return { + onCallSucceeded(method) { + if (!isTelegramOutboundMessageMethod(method)) return; + deps.counters.recordSent(); + const ctx = deps.getContext(); + if (ctx !== undefined) refreshStatus(ctx); + }, + onAuthorizedMessage(ctx) { + deps.counters.recordReceived(); + refreshStatus(ctx); + }, + }; +} diff --git a/tests/status.test.ts b/tests/status.test.ts index 301302d..80bf5a9 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -111,248 +111,165 @@ test("Status runtime diagnostics scheduler coalesces snapshot persists", async ( assert.deepEqual(errors, []); }); -test("Status bar text renders bridge connection and queue states", () => { - const theme = { - fg: (token: string, text: string) => `<${token}>${text}`, +const statusBarTheme = { + fg: (token: string, text: string) => `<${token}>${text}`, +}; + +function plainStatusText(text: string): string { + return text.replace(/<\/?[a-z]+>/g, ""); +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +test("Status bar text keeps the label, state color and queue for every connection state", () => { + const base = { + hasBotToken: true, + pollingActive: true, + paired: true, + compactionInProgress: false, + processing: false, + queuedStatus: "", }; - assert.equal( - buildTelegramStatusBarText(theme, { - hasBotToken: false, - pollingActive: false, - paired: false, - compactionInProgress: false, - processing: false, - queuedStatus: "", - }), - "telegram not configured", - ); - assert.equal( - buildTelegramStatusBarText(theme, { - hasBotToken: true, - pollingActive: true, - paired: true, - compactionInProgress: false, - processing: true, - queuedStatus: " +1", - }), - "telegram active +1", - ); - assert.equal( - buildTelegramStatusBarText(theme, { - hasBotToken: true, - pollingActive: true, - paired: true, - compactionInProgress: false, - processing: true, - processingStatus: "dispatching", - queuedStatus: " +1", - }), - "telegram active +1", - ); - assert.equal( - buildTelegramStatusBarText(theme, { - hasBotToken: true, - pollingActive: true, - paired: true, - compactionInProgress: false, - processing: true, - processingStatus: "active", - queuedStatus: "", - }), - "telegram active", - ); - assert.equal( - buildTelegramStatusBarText(theme, { - hasBotToken: true, - pollingActive: true, - paired: true, - compactionInProgress: true, - processing: false, - queuedStatus: "", - }), - "telegram connected", - ); - assert.equal( - buildTelegramStatusBarText(theme, { - hasBotToken: true, - pollingActive: true, - paired: true, - compactionInProgress: true, - processing: true, - processingStatus: "active", - queuedStatus: "", - }), - "telegram active", - ); - assert.equal( - buildTelegramStatusBarText(theme, { - hasBotToken: true, - pollingActive: false, - paired: true, - instanceThreadName: "Aurora", - compactionInProgress: false, - processing: true, - processingStatus: "queued", - queuedStatus: " +2", - }), - "telegram disconnected +2", - ); - assert.equal( - buildTelegramStatusBarText(theme, { - hasBotToken: true, - pollingActive: false, - paired: true, - busRole: "follower", - compactionInProgress: false, - processing: false, - queuedStatus: "", - }), - "telegram follower", - ); - assert.equal( - buildTelegramStatusBarText(theme, { - hasBotToken: true, - pollingActive: false, - paired: true, - busRole: "follower", - instanceThreadName: "Amber", - compactionInProgress: false, - processing: true, - processingStatus: "active", - queuedStatus: "", - }), - "Amber active", - ); - assert.equal( - buildTelegramStatusBarText(theme, { - hasBotToken: true, - pollingActive: false, - paired: true, - busRole: "follower", - busLifecyclePhase: "electing", - compactionInProgress: false, - processing: false, - queuedStatus: "", - }), - "telegram electing", - ); - assert.equal( - buildTelegramStatusBarText(theme, { - hasBotToken: true, - pollingActive: false, - paired: true, - busLifecyclePhase: "electing", - compactionInProgress: false, - processing: false, - queuedStatus: "", - }), - "telegram electing", - ); - assert.equal( - buildTelegramStatusBarText(theme, { - hasBotToken: true, - pollingActive: false, - paired: true, - busRole: "follower", - instanceThreadName: "Follower", - compactionInProgress: false, - processing: false, - queuedStatus: "", - }), - "telegram follower", - ); - assert.equal( - buildTelegramStatusBarText(theme, { - hasBotToken: true, - pollingActive: false, - paired: true, - busRole: "follower", - instanceThreadName: "Lname", - compactionInProgress: false, - processing: false, - queuedStatus: "", - }), - "Lname follower", - ); - assert.equal( - buildTelegramStatusBarText(theme, { - hasBotToken: true, - pollingActive: false, - paired: true, - busRole: "follower", - instanceSlot: "O", - instanceThreadName: "extensions Follower", - compactionInProgress: false, - processing: false, - queuedStatus: "", - }), - "extensions Follower follower", - ); - assert.equal( - buildTelegramStatusBarText(theme, { - hasBotToken: true, - pollingActive: false, - paired: true, - busRole: "follower", - instanceSlot: "O", - instanceThreadName: "Oname", - compactionInProgress: false, - processing: false, - queuedStatus: "", - }), - "Oname follower", - ); - assert.equal( - buildTelegramStatusBarText(theme, { - hasBotToken: true, - pollingActive: true, - paired: true, - busRole: "leader", - compactionInProgress: false, - processing: false, - queuedStatus: "", - }), - "telegram leader", - ); - assert.equal( - buildTelegramStatusBarText(theme, { - hasBotToken: true, - pollingActive: true, - paired: true, - busRole: "leader", - instanceThreadName: "🌙 A-identity", - compactionInProgress: false, - processing: false, - queuedStatus: "", - }), - "🌙 A-identity leader", - ); - assert.equal( - buildTelegramStatusBarText(theme, { - hasBotToken: true, - pollingActive: true, - paired: false, - compactionInProgress: false, - processing: true, - processingStatus: "queued", - queuedStatus: " +1", - }), - "telegram awaiting pairing +1", - ); - assert.equal( - buildTelegramStatusBarText(theme, { - hasBotToken: true, - pollingActive: true, - paired: true, - compactionInProgress: false, - processing: false, - queuedStatus: "", + const cases: Array<{ + state: Partial[1]>; + label: string; + token: string; + word: string; + queued?: string; + }> = [ + { state: { hasBotToken: false, pollingActive: false, paired: false }, label: "telegram", token: "muted", word: "not configured" }, + { state: { processing: true, queuedStatus: " +1" }, label: "telegram", token: "warning", word: "active", queued: " +1" }, + { state: { processing: true, processingStatus: "dispatching", queuedStatus: " +1" }, label: "telegram", token: "warning", word: "active", queued: " +1" }, + { state: { processing: true, processingStatus: "active" }, label: "telegram", token: "warning", word: "active" }, + { state: { compactionInProgress: true }, label: "telegram", token: "success", word: "connected" }, + { state: { compactionInProgress: true, processing: true, processingStatus: "active" }, label: "telegram", token: "warning", word: "active" }, + { state: { pollingActive: false, instanceThreadName: "Aurora", processing: true, processingStatus: "queued", queuedStatus: " +2" }, label: "telegram", token: "muted", word: "disconnected", queued: " +2" }, + { state: { pollingActive: false, busRole: "follower" }, label: "telegram", token: "success", word: "follower" }, + { state: { pollingActive: false, busRole: "follower", instanceThreadName: "Amber", processing: true, processingStatus: "active" }, label: "Amber", token: "warning", word: "active" }, + { state: { pollingActive: false, busRole: "follower", busLifecyclePhase: "electing" }, label: "telegram", token: "warning", word: "electing" }, + { state: { pollingActive: false, busLifecyclePhase: "electing" }, label: "telegram", token: "warning", word: "electing" }, + { state: { pollingActive: false, busRole: "follower", instanceThreadName: "Follower" }, label: "telegram", token: "success", word: "follower" }, + { state: { pollingActive: false, busRole: "follower", instanceThreadName: "Lname" }, label: "Lname", token: "success", word: "follower" }, + { state: { pollingActive: false, busRole: "follower", instanceSlot: "O", instanceThreadName: "extensions Follower" }, label: "extensions Follower", token: "success", word: "follower" }, + { state: { pollingActive: false, busRole: "follower", instanceSlot: "O", instanceThreadName: "Oname" }, label: "Oname", token: "success", word: "follower" }, + { state: { busRole: "leader" }, label: "telegram", token: "success", word: "leader" }, + { state: { busRole: "leader", instanceThreadName: "🌙 A-identity" }, label: "🌙 A-identity", token: "success", word: "leader" }, + { state: { paired: false, processing: true, processingStatus: "queued", queuedStatus: " +1" }, label: "telegram", token: "warning", word: "awaiting pairing", queued: " +1" }, + ]; + for (const { state, label, token, word, queued } of cases) { + const text = buildTelegramStatusBarText(statusBarTheme, { ...base, ...state }); + const expected = new RegExp( + `^${escapeRegExp(label)} <${token}>\\S+ ${word}${queued ? `${escapeRegExp(queued)}` : ""}`, + ); + assert.match(text, expected, JSON.stringify(state)); + } + assert.match( + buildTelegramStatusBarText(statusBarTheme, { + ...base, error: "typing failed", }), - "telegram error typing failed", + /^telegram<\/accent> \S+ error<\/error> typing failed<\/muted>$/, ); }); +test("Status bar shows bot, message counters and last activity only while the bridge is up", () => { + const lastAtMs = new Date(2026, 9, 1, 14, 32).getTime(); + const connected = { + hasBotToken: true, + pollingActive: true, + paired: true, + compactionInProgress: false, + processing: false, + queuedStatus: "", + botUsername: "demo_bot", + traffic: { received: 12, sent: 11, lastAtMs }, + }; + const up = plainStatusText(buildTelegramStatusBarText(statusBarTheme, connected)); + assert.match(up, /connected/); + assert.match(up, /@demo_bot/); + assert.match(up, /12 \S*11/); + assert.match(up, /14:32/); + + const withoutActivity = plainStatusText( + buildTelegramStatusBarText(statusBarTheme, { + ...connected, + traffic: { received: 0, sent: 0 }, + }), + ); + assert.match(withoutActivity, /0 \S*0/); + assert.doesNotMatch(withoutActivity, /\d\d:\d\d/); + + const working = plainStatusText( + buildTelegramStatusBarText(statusBarTheme, { + ...connected, + processing: true, + processingStatus: "active", + }), + ); + assert.match(working, /active/); + assert.match(working, /@demo_bot/); + assert.match(working, /12 \S*11/); + + const offline = plainStatusText( + buildTelegramStatusBarText(statusBarTheme, { ...connected, pollingActive: false }), + ); + assert.doesNotMatch(offline, /@demo_bot|14:32/); + assert.match(offline, /disconnected/); +}); + +test("Status bar tells the operator how to leave a non-working state", () => { + const base = { + hasBotToken: true, + pollingActive: false, + paired: true, + compactionInProgress: false, + processing: false, + queuedStatus: "", + }; + assert.match( + plainStatusText(buildTelegramStatusBarText(statusBarTheme, { ...base, hasBotToken: false, paired: false })), + /\/telegram-setup/, + ); + const pairing = plainStatusText( + buildTelegramStatusBarText(statusBarTheme, { ...base, paired: false, botUsername: "demo_bot" }), + ); + assert.match(pairing, /\/start/); + assert.match(pairing, /@demo_bot/); + assert.match( + plainStatusText(buildTelegramStatusBarText(statusBarTheme, base)), + /\/telegram-connect/, + ); +}); + +test("Status bar follows the host symbol preset and falls back without one", () => { + const state = { + hasBotToken: true, + pollingActive: true, + paired: true, + compactionInProgress: false, + processing: false, + queuedStatus: "", + botUsername: "demo_bot", + traffic: { received: 3, sent: 2, lastAtMs: new Date(2026, 9, 1, 9, 5).getTime() }, + }; + const ascii = plainStatusText( + buildTelegramStatusBarText( + { + ...statusBarTheme, + status: { enabled: "[x]" }, + sep: { dot: " - " }, + icon: { input: "in:", output: "out:", time: "t:" }, + }, + state, + ), + ); + assert.equal(ascii, "telegram [x] connected - @demo_bot - in:3 out:2 - t: 09:05"); + const fallback = plainStatusText(buildTelegramStatusBarText(statusBarTheme, state)); + assert.match(fallback, /^telegram \S+ connected · @demo_bot · \S*3 \S*2 · \S+ 09:05$/u); +}); + test("Status runtime updates the status bar and exposes bridge lines", () => { const events: string[] = []; const ctx = { @@ -390,9 +307,9 @@ test("Status runtime updates the status bar and exposes bridge lines", () => { }), }); runtime.updateStatus(ctx, "demo error"); - assert.equal( + assert.match( events[0], - "telegram:telegram error demo error", + /^telegram:telegram<\/accent> \S+ error<\/error> demo error<\/muted>$/, ); assert.deepEqual(runtime.getStatusLines().slice(0, 3), [ "connection:", @@ -550,9 +467,9 @@ test("Bridge status runtime stays active while tools run after queue changes", ( }, }, }); - assert.equal( + assert.match( events[0], - "telegram:telegram active", + /^telegram:telegram<\/accent> \S+ active<\/warning>/, ); }); @@ -589,9 +506,9 @@ test("Bridge status runtime builds status state from live ports", () => { }, }, }); - assert.equal( + assert.match( events[0], - "telegram:telegram active +1", + /^telegram:telegram<\/accent> \S+ active<\/warning> \+1<\/success>/, ); assert.deepEqual(runtime.getStatusLines(), [ "connection:", diff --git a/tests/traffic.test.ts b/tests/traffic.test.ts new file mode 100644 index 0000000..7847105 --- /dev/null +++ b/tests/traffic.test.ts @@ -0,0 +1,121 @@ +/** + * Regression tests for the Telegram traffic counters + * Covers what counts as a message, last-activity tracking, and the /telegram-status line. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildTelegramBridgeStatusLines } from "../lib/status.ts"; +import { + createTelegramTrafficCounters, + createTelegramTrafficStatusBridge, + isTelegramOutboundMessageMethod, +} from "../lib/traffic.ts"; + +test("Traffic counters count both directions and remember the latest activity", () => { + let nowMs = 1_000; + const counters = createTelegramTrafficCounters(() => nowMs); + assert.deepEqual(counters.snapshot(), { received: 0, sent: 0 }); + counters.recordReceived(); + nowMs = 2_000; + counters.recordSent(); + counters.recordSent(); + assert.deepEqual(counters.snapshot(), { + received: 1, + sent: 2, + lastAtMs: 2_000, + }); +}); + +test("Only calls that deliver a readable message count as sent", () => { + for (const method of ["sendMessage", "sendRichMessage", "sendPhoto", "sendDocument", "sendVoice"]) { + assert.equal(isTelegramOutboundMessageMethod(method), true, method); + } + for (const method of [ + "sendChatAction", + "sendMessageDraft", + "editMessageText", + "setMessageReaction", + "answerCallbackQuery", + "getUpdates", + "toString", + ]) { + assert.equal(isTelegramOutboundMessageMethod(method), false, method); + } +}); + +test("Status lines report the message counters and the time of the last one", () => { + const baseState = { + botUsername: "demo_bot", + allowedUserId: 7, + pollingActive: true, + pendingDispatch: false, + compactionInProgress: false, + activeToolExecutions: 0, + pendingModelSwitch: false, + queuedItems: [], + recentRuntimeEvents: [], + }; + const withTraffic = buildTelegramBridgeStatusLines({ + ...baseState, + traffic: { received: 12, sent: 11, lastAtMs: new Date(2026, 9, 1, 14, 32).getTime() }, + }); + assert.ok( + withTraffic.includes("- messages: 12 received, 11 sent, last 14:32"), + withTraffic.join("\n"), + ); + const withoutTraffic = buildTelegramBridgeStatusLines(baseState); + assert.equal(withoutTraffic.some((line) => line.startsWith("- messages:")), false); +}); + +test("Traffic bridge counts delivered messages and refreshes the status with the current context", () => { + const counters = createTelegramTrafficCounters(); + const refreshed: string[] = []; + let context: string | undefined = "session-a"; + const bridge = createTelegramTrafficStatusBridge({ + counters, + getContext: () => context, + updateStatus: (ctx: string) => { + refreshed.push(ctx); + }, + recordRuntimeEvent: () => {}, + }); + bridge.onCallSucceeded("sendChatAction"); + bridge.onCallSucceeded("editMessageText"); + assert.deepEqual(counters.snapshot(), { received: 0, sent: 0 }); + assert.deepEqual(refreshed, []); + bridge.onCallSucceeded("sendRichMessage"); + assert.equal(counters.snapshot().sent, 1); + assert.deepEqual(refreshed, ["session-a"]); + context = undefined; + bridge.onCallSucceeded("sendMessage"); + assert.equal(counters.snapshot().sent, 2); + assert.deepEqual(refreshed, ["session-a"]); + bridge.onAuthorizedMessage("session-b"); + assert.equal(counters.snapshot().received, 1); + assert.deepEqual(refreshed, ["session-a", "session-b"]); +}); + +test("Traffic bridge records a failed status refresh instead of breaking the message path", () => { + const counters = createTelegramTrafficCounters(); + const recorded: string[] = []; + const bridge = createTelegramTrafficStatusBridge({ + counters, + getContext: () => "session", + updateStatus: () => { + throw new Error("stale ctx"); + }, + recordRuntimeEvent: (category, error, details) => { + recorded.push(`${category}:${(error as Error).message}:${details?.phase}`); + }, + }); + bridge.onCallSucceeded("sendMessage"); + bridge.onAuthorizedMessage("session"); + assert.deepEqual(counters.snapshot().received, 1); + assert.deepEqual(counters.snapshot().sent, 1); + assert.deepEqual(recorded, [ + "status:stale ctx:traffic-refresh", + "status:stale ctx:traffic-refresh", + ]); +});