diff --git a/CHANGELOG.md b/CHANGELOG.md index b93cc49e14e..dfd70a1ef03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,7 @@ Docs: https://docs.openclaw.ai - Gateway/hooks: keep successful `deliver:false` agent hooks silent, log a hook audit record for suppressed success announcements, and suppress fallback summaries after attempted hook delivery while still surfacing failed hook runs. Repairs #55761; builds on #36332 and #49234. Thanks @EffortlessSteven, @cioclawcode, and @BrennerSpear. - Plugin SDK/Discord: restore a deprecated `openclaw/plugin-sdk/discord` compatibility facade and the legacy compat group-policy warning export for the published `@openclaw/discord@2026.3.13` package, covering its config, account, directory, status, and thread-binding imports while keeping new plugins on generic SDK subpaths. Fixes #73685; supersedes #73703. Thanks @rderickson9 and @SymbolStar. - Channels/Discord: suppress duplicate gateway monitors when multiple enabled accounts resolve to the same bot token, preferring config tokens over default env fallback and reporting skipped duplicates as disabled. Supersedes #73608. Thanks @kagura-agent. +- CLI/health: build channel health summaries from inspected credential metadata plus runtime state, so `openclaw health --json` reports Discord `running`, `connected`, and `tokenSource` consistently with channel status. Fixes #44354. Thanks @ferenc-acs. - Control UI/Talk: decode Google Live binary WebSocket JSON frames and stop queued browser audio on interruption or shutdown, so browser Talk leaves `Connecting Talk...` and barge-in no longer plays stale audio. Fixes #73601 and #73460; supersedes #73466. Thanks @Spolen23 and @WadydX. - Channels/Discord: ignore stale route-shaped conversation bindings after a Discord channel is reconfigured to another agent, while preserving explicit focus and subagent bindings. Fixes #73626. Thanks @ramitrkar-hash. - Agents/bootstrap: pass pending BOOTSTRAP.md contents through the first-run user prompt while keeping them out of privileged system context, and show limited bootstrap guidance when workspace file access is unavailable. Fixes #73622. Thanks @mark1010. @@ -104,6 +105,7 @@ Docs: https://docs.openclaw.ai - Plugins/runtime-deps: cache bundled runtime-deps JSON/package files and root chunk import scans by file signature, reducing repeated staged-runtime scanning during bundled channel startup. Refs #73647 and #73705. Thanks @mattmcintyre and @bmilne1981. - CLI/TUI: keep `chat.history` off model-catalog discovery so initial Gateway-backed TUI history loads cannot block behind slow provider/plugin model scans on low-core hosts. Refs #73524. Thanks @harshcatsystems-collab. - Channels/WhatsApp: flag recently reconnected linked accounts in channel status even when the socket is currently healthy, so flapping WhatsApp Web sessions no longer look clean after a brief reconnect. Refs #73602. Thanks @Vksh07. +- Channels/WhatsApp: log shared dispatcher delivery failures with reply kind, message id, chat id, and connection id, so typing-without-send reports can identify whether the WhatsApp send path rejected a generated reply. Refs #74269. Thanks @tomcosta-git. - Feishu: suppress distinct late `final` text deliveries after a streaming card has already closed, while keeping media attachments deliverable, so late-finals no longer reopen duplicate Feishu cards. Fixes #71977. (#72294) Thanks @MonkeyLeeT. - Gateway: expose `gateway.handshakeTimeoutMs` in config, schema, and docs while preserving `OPENCLAW_HANDSHAKE_TIMEOUT_MS` precedence, so loaded or low-powered hosts can tune local WebSocket pre-auth handshakes without patching dist files. Supersedes #51282; refs #73592 and #73652. Thanks @henry-the-frog. - Gateway/TUI/status: align configured and env-based WebSocket handshake budgets across local clients, probes, and fallback RPCs while preserving explicit status timeouts and paired-device auth fallback, so slow local gateways are not marked unreachable by a shorter client watchdog. Refs #73524, #73535, #73592, and #73602. Thanks @harshcatsystems-collab, @DJBlackhawk, and @Vksh07. diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts index 5095ca0ce24..1f1b09785bf 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts @@ -118,6 +118,16 @@ function getCapturedDeliver() { )?.dispatcherOptions?.deliver; } +function getCapturedOnError() { + return ( + capturedDispatchParams as { + dispatcherOptions?: { + onError?: (err: unknown, info: { kind: "tool" | "block" | "final" }) => void; + }; + } + )?.dispatcherOptions?.onError; +} + type BufferedReplyParams = Parameters[0]; function makeReplyLogger(): BufferedReplyParams["replyLogger"] { @@ -704,6 +714,44 @@ describe("whatsapp inbound dispatch", () => { ).toBe(sendComposing); }); + it("logs delivery failures from the shared dispatcher with WhatsApp context", async () => { + const replyLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as BufferedReplyParams["replyLogger"]; + const error = new Error("send failed"); + + await dispatchBufferedReply({ + connectionId: "conn-1", + conversationId: "+15550001000", + msg: makeMsg({ + id: "msg-1", + from: "+15550001000", + to: "+15550002000", + chatId: "15550001000@s.whatsapp.net", + }), + replyLogger, + }); + + getCapturedOnError()?.(error, { kind: "final" }); + + expect(replyLogger.error).toHaveBeenCalledWith( + { + err: error, + replyKind: "final", + correlationId: "msg-1", + connectionId: "conn-1", + conversationId: "+15550001000", + chatId: "15550001000@s.whatsapp.net", + to: "+15550001000", + from: "+15550002000", + }, + "auto-reply delivery failed", + ); + }); + it("updates main last route for DM when session key matches main session key", () => { const updateLastRoute = vi.fn(); diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts index 794b8244f52..d8ecd820c2e 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts @@ -56,6 +56,29 @@ type SenderContext = { e164?: string; }; +function logWhatsAppReplyDeliveryError(params: { + err: unknown; + info: { kind: ReplyLifecycleKind }; + connectionId: string; + conversationId: string; + msg: WebInboundMsg; + replyLogger: ReturnType; +}) { + params.replyLogger.error( + { + err: params.err, + replyKind: params.info.kind, + correlationId: params.msg.id ?? null, + connectionId: params.connectionId, + conversationId: params.conversationId, + chatId: params.msg.chatId ?? null, + to: params.msg.from ?? null, + from: params.msg.to ?? null, + }, + "auto-reply delivery failed", + ); +} + function resolveWhatsAppDisableBlockStreaming(cfg: ReturnType): boolean | undefined { if (typeof cfg.channels?.whatsapp?.blockStreaming !== "boolean") { return undefined; @@ -355,6 +378,16 @@ export async function dispatchWhatsAppBufferedReply(params: { } }, onReplyStart: params.msg.sendComposing, + onError: (err, info) => { + logWhatsAppReplyDeliveryError({ + err, + info, + connectionId: params.connectionId, + conversationId: params.conversationId, + msg: params.msg, + replyLogger: params.replyLogger, + }); + }, }, replyOptions: { disableBlockStreaming, diff --git a/src/channels/plugins/status.ts b/src/channels/plugins/status.ts index c031f14c98b..ad7727cc419 100644 --- a/src/channels/plugins/status.ts +++ b/src/channels/plugins/status.ts @@ -1,12 +1,12 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { inspectChannelAccount } from "../account-inspection.js"; import { projectSafeChannelAccountSnapshotFields } from "../account-snapshot-fields.js"; -import { inspectReadOnlyChannelAccount } from "../read-only-account-inspect.js"; import type { ChannelPlugin } from "./types.plugin.js"; import type { ChannelAccountSnapshot } from "./types.public.js"; // Channel docking: status snapshots flow through plugin.status hooks here. -async function buildSnapshotFromAccount(params: { +export async function buildChannelAccountSnapshotFromAccount(params: { plugin: ChannelPlugin; cfg: OpenClawConfig; accountId: string; @@ -14,52 +14,46 @@ async function buildSnapshotFromAccount(params: { runtime?: ChannelAccountSnapshot; probe?: unknown; audit?: unknown; + enabledFallback?: boolean; + configuredFallback?: boolean; }): Promise { + let snapshot: ChannelAccountSnapshot; if (params.plugin.status?.buildAccountSnapshot) { - const snapshot = await params.plugin.status.buildAccountSnapshot({ + snapshot = await params.plugin.status.buildAccountSnapshot({ account: params.account, cfg: params.cfg, runtime: params.runtime, probe: params.probe, audit: params.audit, }); - return normalizeOptionalString(snapshot.accountId) - ? snapshot - : { - ...snapshot, - accountId: params.accountId, - }; - } - const enabled = params.plugin.config.isEnabled - ? params.plugin.config.isEnabled(params.account, params.cfg) - : params.account && typeof params.account === "object" - ? (params.account as { enabled?: boolean }).enabled - : undefined; - const configured = - params.account && typeof params.account === "object" && "configured" in params.account - ? (params.account as { configured?: boolean }).configured - : params.plugin.config.isConfigured - ? await params.plugin.config.isConfigured(params.account, params.cfg) + } else { + const enabled = params.plugin.config.isEnabled + ? params.plugin.config.isEnabled(params.account, params.cfg) + : params.account && typeof params.account === "object" + ? (params.account as { enabled?: boolean }).enabled : undefined; - return { - accountId: params.accountId, - enabled, - configured, - ...projectSafeChannelAccountSnapshotFields(params.account), - }; -} - -async function inspectChannelAccount(params: { - plugin: ChannelPlugin; - cfg: OpenClawConfig; - accountId: string; -}): Promise { - return (params.plugin.config.inspectAccount?.(params.cfg, params.accountId) ?? - (await inspectReadOnlyChannelAccount({ - channelId: params.plugin.id, - cfg: params.cfg, + const configured = + params.account && typeof params.account === "object" && "configured" in params.account + ? (params.account as { configured?: boolean }).configured + : params.plugin.config.isConfigured + ? await params.plugin.config.isConfigured(params.account, params.cfg) + : undefined; + snapshot = { accountId: params.accountId, - }))) as ResolvedAccount | null; + enabled, + configured, + ...projectSafeChannelAccountSnapshotFields(params.account), + ...projectSafeChannelAccountSnapshotFields(params.runtime), + }; + } + + return { + ...snapshot, + accountId: normalizeOptionalString(snapshot.accountId) ? snapshot.accountId : params.accountId, + enabled: snapshot.enabled ?? params.enabledFallback, + configured: snapshot.configured ?? params.configuredFallback, + ...(params.probe !== undefined && snapshot.probe === undefined ? { probe: params.probe } : {}), + }; } export async function buildReadOnlySourceChannelAccountSnapshot(params: { @@ -74,7 +68,7 @@ export async function buildReadOnlySourceChannelAccountSnapshot if (!inspectedAccount) { return null; } - return await buildSnapshotFromAccount({ + return await buildChannelAccountSnapshotFromAccount({ ...params, account: inspectedAccount as ResolvedAccount, }); @@ -91,7 +85,7 @@ export async function buildChannelAccountSnapshot(params: { const inspectedAccount = await inspectChannelAccount(params); const account = inspectedAccount ?? params.plugin.config.resolveAccount(params.cfg, params.accountId); - return await buildSnapshotFromAccount({ + return await buildChannelAccountSnapshotFromAccount({ ...params, account, }); diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index cbb28559e52..c0ed1739341 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -2,12 +2,14 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChannelAccountSnapshot } from "../channels/plugins/types.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import { createPluginRecord } from "../plugins/status.test-helpers.js"; import type { HealthSummary } from "./health.js"; let testConfig: Record = {}; let testStore: Record = {}; +let healthPluginsForTest: HealthTestPlugin[] = []; let setActivePluginRegistry: typeof import("../plugins/runtime.js").setActivePluginRegistry; let createChannelTestPluginBase: typeof import("../test-utils/channel-plugins.js").createChannelTestPluginBase; @@ -18,6 +20,8 @@ let probeTelegramAccountForTestOverride: | ((account: TelegramHealthAccount, timeoutMs: number) => Promise>) | undefined; +type HealthTestPlugin = Pick; + type TelegramHealthAccount = { accountId: string; token: string; @@ -29,6 +33,15 @@ type TelegramHealthAccount = { }; }; +type DiscordHealthAccount = { + accountId: string; + token: string; + tokenSource: string; + tokenStatus?: "available" | "configured_unavailable" | "missing"; + enabled: boolean; + configured: boolean; +}; + async function loadFreshHealthModulesForTest() { vi.doMock("../config/config.js", () => ({ getRuntimeConfig: () => testConfig, @@ -57,7 +70,7 @@ async function loadFreshHealthModulesForTest() { logoutWeb: vi.fn(), })); vi.doMock("../channels/plugins/read-only.js", () => ({ - listReadOnlyChannelPluginsForConfig: () => [createTelegramHealthPlugin()], + listReadOnlyChannelPluginsForConfig: () => healthPluginsForTest, })); const [pluginsRuntime, channelTestUtils, health] = await Promise.all([ @@ -280,10 +293,7 @@ async function runSuccessfulTelegramProbe( return { calls, telegram }; } -function createTelegramHealthPlugin(): Pick< - ChannelPlugin, - "id" | "meta" | "capabilities" | "config" | "status" -> { +function createTelegramHealthPlugin(): HealthTestPlugin { return { ...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }), config: { @@ -303,6 +313,91 @@ function createTelegramHealthPlugin(): Pick< }; } +function resolveDiscordHealthAccountForTest(params: { + cfg: Record; + accountId?: string | null; +}): DiscordHealthAccount { + const channels = params.cfg.channels as Record | undefined; + const discord = (channels?.discord as Record | undefined) ?? {}; + const accountId = params.accountId?.trim() || "default"; + const token = typeof discord.token === "string" ? discord.token.trim() : ""; + return { + accountId, + token, + tokenSource: token ? "config" : "none", + ...(token ? { tokenStatus: "available" as const } : {}), + enabled: discord.enabled !== false, + configured: Boolean(token), + }; +} + +function inspectDiscordHealthAccountForTest(params: { + cfg: Record; + accountId?: string | null; +}): DiscordHealthAccount { + const channels = params.cfg.channels as Record | undefined; + const discord = (channels?.discord as Record | undefined) ?? {}; + const accountId = params.accountId?.trim() || "default"; + const token = typeof discord.token === "string" ? discord.token.trim() : ""; + const tokenStatus = + token.length > 0 + ? "available" + : discord.token && typeof discord.token === "object" + ? "configured_unavailable" + : "missing"; + return { + accountId, + token, + tokenSource: tokenStatus === "missing" ? "none" : "config", + tokenStatus, + enabled: discord.enabled !== false, + configured: tokenStatus !== "missing", + }; +} + +function createDiscordHealthPlugin(): HealthTestPlugin { + return { + ...createChannelTestPluginBase({ id: "discord", label: "Discord" }), + config: { + listAccountIds: () => ["default"], + resolveAccount: (cfg, accountId) => + resolveDiscordHealthAccountForTest({ + cfg: cfg as Record, + accountId, + }), + inspectAccount: (cfg, accountId) => + inspectDiscordHealthAccountForTest({ + cfg: cfg as Record, + accountId, + }), + isEnabled: (account) => (account as DiscordHealthAccount).enabled, + isConfigured: (account) => (account as DiscordHealthAccount).configured, + }, + status: { + buildAccountSnapshot: ({ account, runtime }) => { + const resolved = account as DiscordHealthAccount; + return { + accountId: resolved.accountId, + enabled: resolved.enabled, + configured: resolved.configured, + tokenSource: resolved.tokenSource, + tokenStatus: resolved.tokenStatus, + running: runtime?.running ?? false, + connected: runtime?.connected ?? false, + lastConnectedAt: runtime?.lastConnectedAt ?? null, + } satisfies ChannelAccountSnapshot; + }, + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + tokenSource: snapshot.tokenSource ?? "none", + tokenStatus: snapshot.tokenStatus, + running: snapshot.running ?? false, + connected: snapshot.connected ?? false, + }), + }, + }; +} + describe("getHealthSnapshot", () => { beforeAll(async () => { ({ @@ -316,6 +411,7 @@ describe("getHealthSnapshot", () => { beforeEach(() => { buildTelegramHealthSummaryForTest = buildTelegramHealthSummary; probeTelegramAccountForTestOverride = undefined; + healthPluginsForTest = [createTelegramHealthPlugin()]; setActivePluginRegistry( createTestRegistry([ { pluginId: "telegram", plugin: createTelegramHealthPlugin(), source: "test" }, @@ -477,6 +573,111 @@ describe("getHealthSnapshot", () => { expect(telegram.accounts?.default?.probe?.ok).toBe(true); }); + it("merges inspected account metadata with runtime state before building health summaries", async () => { + testConfig = { channels: { discord: { token: "discord-token" } } }; + testStore = {}; + healthPluginsForTest = [createDiscordHealthPlugin()]; + + const snap = await getHealthSnapshot({ + probe: false, + includeSensitive: false, + runtimeSnapshot: { + channels: { + discord: { + accountId: "default", + running: true, + connected: true, + lastConnectedAt: 123, + }, + }, + channelAccounts: {}, + }, + }); + const discord = snap.channels.discord as { + configured?: boolean; + running?: boolean; + connected?: boolean; + tokenSource?: string; + tokenStatus?: string; + accounts?: Record< + string, + { + configured?: boolean; + running?: boolean; + connected?: boolean; + tokenSource?: string; + tokenStatus?: string; + } + >; + }; + + expect(discord.configured).toBe(true); + expect(discord.running).toBe(true); + expect(discord.connected).toBe(true); + expect(discord.tokenSource).toBe("config"); + expect(discord.tokenStatus).toBe("available"); + expect(discord.accounts?.default).toMatchObject({ + configured: true, + running: true, + connected: true, + tokenSource: "config", + tokenStatus: "available", + }); + }); + + it("preserves plugin-derived configured state for unavailable SecretRef credentials", async () => { + testConfig = { + channels: { + discord: { + token: { + source: "env", + provider: "default", + id: "MISSING_DISCORD_BOT_TOKEN", + }, + }, + }, + }; + testStore = {}; + healthPluginsForTest = [createDiscordHealthPlugin()]; + + const snap = await getHealthSnapshot({ + probe: false, + includeSensitive: false, + runtimeSnapshot: { + channels: { + discord: { + accountId: "default", + running: true, + connected: true, + }, + }, + channelAccounts: {}, + }, + }); + const discord = snap.channels.discord as { + configured?: boolean; + tokenSource?: string; + tokenStatus?: string; + accounts?: Record< + string, + { + configured?: boolean; + tokenSource?: string; + tokenStatus?: string; + } + >; + }; + + expect(discord.configured).toBe(true); + expect(discord.tokenSource).toBe("config"); + expect(discord.tokenStatus).toBe("configured_unavailable"); + expect(discord.accounts?.default).toMatchObject({ + configured: true, + tokenSource: "config", + tokenStatus: "configured_unavailable", + }); + }); + it("omits secret runtime fields and raw probe payloads from non-sensitive health snapshots", async () => { testConfig = { channels: { telegram: { botToken: "t-1" } } }; testStore = {}; diff --git a/src/commands/health.ts b/src/commands/health.ts index 9c23ec33d54..1b9c7b1e54f 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -1,10 +1,14 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { projectSafeChannelAccountSnapshotFields } from "../channels/account-snapshot-fields.js"; +import { inspectChannelAccount } from "../channels/account-inspection.js"; +import { + resolveChannelAccountConfigured, + resolveChannelAccountEnabled, +} from "../channels/account-summary.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js"; +import { buildChannelAccountSnapshotFromAccount } from "../channels/plugins/status.js"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import type { ChannelAccountSnapshot } from "../channels/plugins/types.public.js"; -import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; import { withProgress } from "../cli/progress.js"; import { getRuntimeConfig } from "../config/config.js"; import { resolveStorePath } from "../config/sessions/paths.js"; @@ -176,17 +180,6 @@ function buildPluginHealthSummary(): PluginHealthSummary | undefined { return { loaded, errors }; } -async function inspectHealthAccount(plugin: ChannelPlugin, cfg: OpenClawConfig, accountId: string) { - return ( - plugin.config.inspectAccount?.(cfg, accountId) ?? - (await inspectReadOnlyChannelAccount({ - channelId: plugin.id, - cfg, - accountId, - })) - ); -} - function readBooleanField(value: unknown, key: string): boolean | undefined { const record = asNullableRecord(value); if (!record) { @@ -195,12 +188,60 @@ function readBooleanField(value: unknown, key: string): boolean | undefined { return typeof record[key] === "boolean" ? record[key] : undefined; } +const hasAccountValue = (account: unknown): boolean => account !== null && account !== undefined; + +function resolveProbeAccountEnabled(params: { + plugin: ChannelPlugin; + cfg: OpenClawConfig; + accountId: string; + account: unknown; + diagnostics: string[]; +}): boolean { + const fallback = readBooleanField(params.account, "enabled") ?? true; + try { + return resolveChannelAccountEnabled({ + plugin: params.plugin, + account: params.account, + cfg: params.cfg, + }); + } catch (error) { + params.diagnostics.push( + `${params.plugin.id}:${params.accountId}: failed to evaluate enabled state (${formatErrorMessage(error)}).`, + ); + return fallback; + } +} + +async function resolveProbeAccountConfigured(params: { + plugin: ChannelPlugin; + cfg: OpenClawConfig; + accountId: string; + account: unknown; + diagnostics: string[]; +}): Promise { + const fallback = readBooleanField(params.account, "configured") ?? true; + try { + return await resolveChannelAccountConfigured({ + plugin: params.plugin, + account: params.account, + cfg: params.cfg, + readAccountConfiguredField: true, + }); + } catch (error) { + params.diagnostics.push( + `${params.plugin.id}:${params.accountId}: failed to evaluate configured state (${formatErrorMessage(error)}).`, + ); + return fallback; + } +} + async function resolveHealthAccountContext(params: { plugin: ChannelPlugin; cfg: OpenClawConfig; accountId: string; }): Promise<{ - account: unknown; + probeAccount: unknown; + snapshotAccount: unknown; enabled: boolean; configured: boolean; diagnostics: string[]; @@ -213,45 +254,50 @@ async function resolveHealthAccountContext(params: { diagnostics.push( `${params.plugin.id}:${params.accountId}: failed to resolve account (${formatErrorMessage(error)}).`, ); - account = await inspectHealthAccount(params.plugin, params.cfg, params.accountId); + } + let inspectedAccount: unknown; + try { + inspectedAccount = await inspectChannelAccount(params); + } catch (error) { + diagnostics.push( + `${params.plugin.id}:${params.accountId}: failed to inspect account (${formatErrorMessage(error)}).`, + ); } - if (!account) { + const probeAccount = hasAccountValue(account) ? account : inspectedAccount; + if (!hasAccountValue(probeAccount)) { return { - account: {}, + probeAccount: {}, + snapshotAccount: {}, enabled: false, configured: false, diagnostics, }; } + const snapshotAccount = hasAccountValue(inspectedAccount) ? inspectedAccount : probeAccount; - const enabledFallback = readBooleanField(account, "enabled") ?? true; - let enabled = enabledFallback; - if (params.plugin.config.isEnabled) { - try { - enabled = params.plugin.config.isEnabled(account, params.cfg); - } catch (error) { - enabled = enabledFallback; - diagnostics.push( - `${params.plugin.id}:${params.accountId}: failed to evaluate enabled state (${formatErrorMessage(error)}).`, - ); - } - } + const enabled = resolveProbeAccountEnabled({ + plugin: params.plugin, + cfg: params.cfg, + accountId: params.accountId, + account: probeAccount, + diagnostics, + }); + const configured = await resolveProbeAccountConfigured({ + plugin: params.plugin, + cfg: params.cfg, + accountId: params.accountId, + account: probeAccount, + diagnostics, + }); - const configuredFallback = readBooleanField(account, "configured") ?? true; - let configured = configuredFallback; - if (params.plugin.config.isConfigured) { - try { - configured = await params.plugin.config.isConfigured(account, params.cfg); - } catch (error) { - configured = configuredFallback; - diagnostics.push( - `${params.plugin.id}:${params.accountId}: failed to evaluate configured state (${formatErrorMessage(error)}).`, - ); - } - } - - return { account, enabled, configured, diagnostics }; + return { + probeAccount, + snapshotAccount, + enabled, + configured, + diagnostics, + }; } export async function getHealthSnapshot(params?: { @@ -332,11 +378,12 @@ export async function getHealthSnapshot(params?: { const accountSummaries: Record = {}; for (const accountId of accountIdsToProbe) { - const { account, enabled, configured, diagnostics } = await resolveHealthAccountContext({ - plugin, - cfg, - accountId, - }); + const { probeAccount, snapshotAccount, enabled, configured, diagnostics } = + await resolveHealthAccountContext({ + plugin, + cfg, + accountId, + }); if (diagnostics.length > 0) { debugHealth("account.diagnostics", { channel: plugin.id, accountId, diagnostics }); } @@ -346,7 +393,7 @@ export async function getHealthSnapshot(params?: { if (enabled && configured && doProbe && plugin.status?.probeAccount) { try { probe = await plugin.status.probeAccount({ - account, + account: probeAccount, timeoutMs: cappedTimeout, cfg, }); @@ -370,22 +417,23 @@ export async function getHealthSnapshot(params?: { const runtimeSnapshot = params?.runtimeSnapshot?.channelAccounts[plugin.id]?.[accountId] ?? (accountId === defaultAccountId ? params?.runtimeSnapshot?.channels[plugin.id] : undefined); - const snapshot: ChannelAccountSnapshot = { - ...projectSafeChannelAccountSnapshotFields(runtimeSnapshot), + const snapshot: ChannelAccountSnapshot = await buildChannelAccountSnapshotFromAccount({ + plugin, + cfg, accountId, - enabled, - configured, - }; - if (includeSensitive && probe !== undefined) { - snapshot.probe = probe; - } + account: snapshotAccount, + runtime: runtimeSnapshot, + probe: includeSensitive ? probe : undefined, + enabledFallback: enabled, + configuredFallback: configured, + }); if (lastProbeAt) { snapshot.lastProbeAt = lastProbeAt; } const summary = plugin.status?.buildChannelSummary ? await plugin.status.buildChannelSummary({ - account, + account: probeAccount, cfg, defaultAccountId: accountId, snapshot, @@ -530,12 +578,12 @@ export async function healthCommand( ` ${plugin.id}: accounts=${accountIds.join(", ") || "(none)"} default=${defaultAccountId}`, ); for (const accountId of accountIds) { - const { account, configured, diagnostics } = await resolveHealthAccountContext({ + const { snapshotAccount, configured, diagnostics } = await resolveHealthAccountContext({ plugin, cfg, accountId, }); - const record = asNullableRecord(account); + const record = asNullableRecord(snapshotAccount); const tokenSource = record && typeof record.tokenSource === "string" ? record.tokenSource : undefined; runtime.log( @@ -650,7 +698,7 @@ export async function healthCommand( } try { plugin.status.logSelfId({ - account: accountContext.account, + account: accountContext.probeAccount, cfg, runtime, includeChannelPrefix: true,