From e0a119dabf48426cc937d33db4e817ff12688605 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 23:04:28 -0400 Subject: [PATCH] fix(channels): own account status in one closed state machine (#114775) * refactor(channels): own account status in one closed state machine * fix(gateway): stop storing derived reasons and configured in channel runtime * fix(channels): separate linkage from configuration in whatsapp and zalo personal * fix(cli): render channel state reasons apart from runtime failures * refactor(channels): keep account-state projection internal to its owner --- .../.generated/plugin-sdk-api-baseline.sha256 | 6 +- .../src/channel-runtime-loader.test.ts | 9 +- .../whatsapp/src/channel-runtime-loader.ts | 7 +- extensions/whatsapp/src/channel.setup.test.ts | 17 +- extensions/whatsapp/src/channel.setup.ts | 5 +- extensions/whatsapp/src/channel.ts | 35 +-- extensions/whatsapp/src/shared.ts | 6 +- extensions/zalouser/src/channel.ts | 47 ++-- extensions/zalouser/src/shared.ts | 7 +- extensions/zalouser/src/status-issues.test.ts | 5 +- extensions/zalouser/src/status-issues.ts | 5 +- src/channels/plugins/read-only.ts | 6 + src/channels/plugins/status.test.ts | 81 +++++- src/channels/plugins/status.ts | 47 ++-- src/channels/plugins/types.adapters.ts | 11 + src/channels/plugins/types.core.ts | 1 + src/channels/status/account-state.test.ts | 154 +++++++++++ src/channels/status/account-state.ts | 168 ++++++++++++ src/commands/agents.providers.test.ts | 42 +++ src/commands/agents.providers.ts | 36 ++- ...channels.config-only-status-output.test.ts | 61 ++--- src/commands/channels/shared.ts | 14 +- src/commands/channels/status.ts | 3 - src/gateway/channel-health-policy.test.ts | 10 + src/gateway/channel-health-policy.ts | 3 +- src/gateway/server-channels.test.ts | 239 +++++++++++++++++- src/gateway/server-channels.ts | 89 ++++--- src/plugin-sdk/status-helpers.test.ts | 7 +- src/plugin-sdk/status-helpers.ts | 17 +- 29 files changed, 954 insertions(+), 184 deletions(-) create mode 100644 src/channels/status/account-state.test.ts create mode 100644 src/channels/status/account-state.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 7d304f31abf7..d36b8dd92ad5 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -44,7 +44,7 @@ bba5540be7cf9613a163663decdb2affe2af9bbd3ad7914989ab186f9c2abec1 module/channel 0ceb4378709eb2d92a62a275f87fa04e18f77df8f942a9a0acef81019ebc1e24 module/channel-secret-runtime 7c90157a95bc0523fc66b1f78ce140f7ec7dbf809dfa3a480244efc01f972754 module/channel-send-result fb123c1b557ed2527e335f13c3d6de41ab0c3305151001cf2b1075d1da8034c5 module/channel-setup -47719805c7a1d2623dcecafad50abb6cc674262b250e1f0d7020a79ad41cfd42 module/channel-status +8de651a14a46014dc86fb8ccaff819e0854cf6bb2e3b59e642f1e55b37ef43c2 module/channel-status 824858dccff862ba6b83f6a478bea73397972a4584f4a427a794224ac680670a module/channel-streaming 14f0103adb14627b662fbe9fdd5ed08596ed702a250853e8beb8bb8dd1361588 module/cli-argv c89ec1b194b76f67a6f4dd108dccf460da6065646cba31374c8aa748f23a39e4 module/collection-runtime @@ -62,7 +62,7 @@ c1ea9510dfda047609a99d5d2cd1f1560f5d469a36e6b695766213d695c25b0f module/convers f70c93d28053ca2e8353e45e6515ce7acef188097c6117d1545965d0699c8004 module/device-bootstrap 6215d3af5923bf5a616d73062534968b69f448e3e30adc64ae9caebdd1a46d71 module/diagnostic-runtime ea81ef06956c1bc0853fa00afbbc2b5a4019116aaf8a436e1b27d06f7a2c9e88 module/directory-runtime -54cf9326eebaad5dd28a9a2fa8b7e62742aa71419622b89f4170a14fde195623 module/discord +ddea82ff9e81e77fe1a80c3ee6273ea93e0f17710d7cd5ef988314c18a0dfc3b module/discord f65408d85477bb362ebe6ed9148c1bb6b9eb7839733e5e3119f4ff8f1cfd0567 module/error-runtime b013053a61e7d9be3d0c683c02baf57fa7e4393ec54e0df6a46ab0f2fe2348fd module/extension-shared ceacad83db01c66e7be6aa21a291597020f13f737b697690eae7d47098e6499a module/gateway-method-runtime @@ -128,7 +128,7 @@ ae469f32799380e6b045abaefefee6eb3f00d714ffbf36b6eeef5025dc529472 module/speech- 9e521fe9073dfd1a6a6855f909fa6befe8613e18403f0a65faaba973a8b630c1 module/ssrf-policy f85d5be0f635de6a77bbd9224a37c373998e098a8dcf2048ddc2c9ac735b47ef module/ssrf-runtime ff35f9f74d35d37a2eb6126b57f3dc5a4d580b6222fe3a34c9368779ad32eab7 module/state-paths -c5ff317bb7957d0870cb806e7989c1d48877e3bed583c173ce2f8e9cfe57ee36 module/status-helpers +9d44964935615a6c2cd83f2dd2307690f15d714b13b85422f711808e0cf4afcc module/status-helpers 537047854c21ad20ea0572f8019503cbda3bbafece8194a28c30b0639bde2fde module/string-coerce-runtime 1b5b9a4532db991fce807ba736b550de27aa5f9de2e1f508a8f4f09cae76ea29 module/telegram-account 110944726884fca94f38c9c329b5950629438b9a719f4782c4beeade8bd67746 module/temp-path diff --git a/extensions/whatsapp/src/channel-runtime-loader.test.ts b/extensions/whatsapp/src/channel-runtime-loader.test.ts index e09680650468..0948392ececc 100644 --- a/extensions/whatsapp/src/channel-runtime-loader.test.ts +++ b/extensions/whatsapp/src/channel-runtime-loader.test.ts @@ -4,7 +4,10 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import ts from "typescript"; import { describe, expect, it, vi } from "vitest"; -import { isWhatsAppAuthConfigured, loadWhatsAppChannelRuntime } from "./channel-runtime-loader.js"; +import { + loadWhatsAppChannelRuntime, + readWhatsAppAccountLinkState, +} from "./channel-runtime-loader.js"; const runtimeLoads = vi.hoisted(() => ({ order: [] as string[], @@ -60,8 +63,8 @@ describe("WhatsApp channel runtime loader", () => { expect(loadWhatsAppChannelRuntime()).toBe(firstRuntimeLoad); await expect( - Promise.all([isWhatsAppAuthConfigured("/tmp/default"), firstRuntimeLoad]), - ).resolves.toEqual([true, expect.any(Object)]); + Promise.all([readWhatsAppAccountLinkState("/tmp/default"), firstRuntimeLoad]), + ).resolves.toEqual(["linked", expect.any(Object)]); expect(runtimeLoads.order).toEqual(["auth-store", "channel-runtime"]); expect(runtimeLoads.readWebAuthState).toHaveBeenCalledOnce(); }); diff --git a/extensions/whatsapp/src/channel-runtime-loader.ts b/extensions/whatsapp/src/channel-runtime-loader.ts index 1d670e4dddd3..7d32f48f47a1 100644 --- a/extensions/whatsapp/src/channel-runtime-loader.ts +++ b/extensions/whatsapp/src/channel-runtime-loader.ts @@ -3,9 +3,12 @@ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; const loadWhatsAppAuthStore = createLazyRuntimeModule(() => import("./auth-store.js")); -export async function isWhatsAppAuthConfigured(authDir: string): Promise { +export async function readWhatsAppAccountLinkState( + authDir: string, +): Promise<"linked" | "not-linked" | "unknown"> { const authStore = await loadWhatsAppAuthStore(); - return (await authStore.readWebAuthState(authDir)) === "linked"; + const state = await authStore.readWebAuthState(authDir); + return state === "unstable" ? "unknown" : state; } // Source-loaded entry points must share this promise. Preloading auth-store keeps diff --git a/extensions/whatsapp/src/channel.setup.test.ts b/extensions/whatsapp/src/channel.setup.test.ts index 9ea1fbd8a1bc..23d76a7b775b 100644 --- a/extensions/whatsapp/src/channel.setup.test.ts +++ b/extensions/whatsapp/src/channel.setup.test.ts @@ -521,16 +521,15 @@ describe("whatsapp setup wizard", () => { expect(result).toEqual({ ok: false, reason: WHATSAPP_AUTH_UNSTABLE_CODE }); }); - it("does not treat unstable auth as configured in generic plugin config checks", async () => { + it("keeps config distinct from indeterminate linkage", async () => { hoisted.readWebAuthState.mockResolvedValueOnce("unstable"); + const account = { + authDir: "/tmp/work", + } as never; - await expect( - whatsappSetupPlugin.config.isConfigured?.( - { - authDir: "/tmp/work", - } as never, - {} as never, - ), - ).resolves.toBe(false); + expect(whatsappSetupPlugin.config.isConfigured?.(account, {} as never)).toBe(true); + await expect(whatsappSetupPlugin.config.isLinked?.(account, {} as never)).resolves.toBe( + "unknown", + ); }); }); diff --git a/extensions/whatsapp/src/channel.setup.ts b/extensions/whatsapp/src/channel.setup.ts index 47343556a281..abdcc0d147c2 100644 --- a/extensions/whatsapp/src/channel.setup.ts +++ b/extensions/whatsapp/src/channel.setup.ts @@ -1,7 +1,7 @@ // Whatsapp plugin module implements channel.setup behavior. import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; import type { ResolvedWhatsAppAccount } from "./accounts.js"; -import { isWhatsAppAuthConfigured } from "./channel-runtime-loader.js"; +import { readWhatsAppAccountLinkState } from "./channel-runtime-loader.js"; import { resolveWhatsAppGroupRequireMention, resolveWhatsAppGroupToolPolicy, @@ -19,7 +19,8 @@ export const whatsappSetupPlugin: ChannelPlugin = { setupWizard: whatsappSetupWizardProxy, setup: whatsappSetupAdapter, setupContract: whatsappSetupContract, - isConfigured: async (account) => await isWhatsAppAuthConfigured(account.authDir), + isConfigured: (account) => Boolean(account.authDir), + isLinked: async (account) => await readWhatsAppAccountLinkState(account.authDir), }), lifecycle: { detectLegacyStateMigrations: ({ oauthDir }) => diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index ecaaeeb5d573..e34555d82f77 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -4,7 +4,7 @@ import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowli import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { - createAsyncComputedAccountStatusAdapter, + createComputedAccountStatusAdapter, createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers"; import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount } from "./accounts.js"; @@ -16,7 +16,10 @@ import { resolveWhatsAppAgentReactionGuidance, } from "./channel-actions.js"; import { whatsappChannelOutbound, whatsappMessageAdapter } from "./channel-outbound.js"; -import { isWhatsAppAuthConfigured, loadWhatsAppChannelRuntime } from "./channel-runtime-loader.js"; +import { + loadWhatsAppChannelRuntime, + readWhatsAppAccountLinkState, +} from "./channel-runtime-loader.js"; import { whatsappCommandPolicy } from "./command-policy.js"; import { formatWhatsAppConfigAllowFromEntries } from "./config-accessors.js"; import { resolveWhatsAppMentionStripRegexes } from "./group-intro.js"; @@ -88,7 +91,8 @@ export const whatsappPlugin: ChannelPlugin = setupWizard: whatsappSetupWizardProxy, setup: whatsappSetupAdapter, setupContract: whatsappSetupContract, - isConfigured: async (account) => await isWhatsAppAuthConfigured(account.authDir), + isConfigured: (account) => Boolean(account.authDir), + isLinked: async (account) => await readWhatsAppAccountLinkState(account.authDir), }), agentTools: () => [createWhatsAppLoginTool()], allowlist: buildDmGroupAccountAllowlistAdapter({ @@ -223,7 +227,7 @@ export const whatsappPlugin: ChannelPlugin = }); }, }, - status: createAsyncComputedAccountStatusAdapter({ + status: createComputedAccountStatusAdapter({ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { connected: false, reconnectAttempts: 0, @@ -262,21 +266,13 @@ export const whatsappPlugin: ChannelPlugin = ? "not-linked" : undefined; const statusState = summaryAuthState === undefined ? undefined : summaryAuthState; - const configured = - auth.state === "unstable" - ? typeof snapshot.configured === "boolean" - ? snapshot.configured - : true - : typeof linked === "boolean" - ? linked - : auth.state === "linked"; const authAgeMs = typeof linked === "boolean" && linked ? auth.authAgeMs : null; const self = typeof linked === "boolean" && linked ? auth.selfId : { e164: null, jid: null, lid: null }; return { - configured, + configured: Boolean(account.authDir), ...(statusState ? { statusState } : {}), ...(typeof linked === "boolean" ? { linked } : {}), authAgeMs, @@ -298,21 +294,13 @@ export const whatsappPlugin: ChannelPlugin = : {}), }; }, - resolveAccountSnapshot: async ({ account, runtime }) => { - const channelRuntime = await loadWhatsAppChannelRuntime(); - const authState = await channelRuntime.readWebAuthState(account.authDir); + resolveAccountSnapshot: ({ account, runtime }) => { return { accountId: account.accountId, name: account.name, enabled: account.enabled, - configured: true, + configured: Boolean(account.authDir), extra: { - statusState: authState, - ...(authState === "linked" - ? { linked: true } - : authState === "not-linked" - ? { linked: false } - : {}), connected: runtime?.connected ?? false, reconnectAttempts: runtime?.reconnectAttempts, lastConnectedAt: runtime?.lastConnectedAt ?? null, @@ -331,7 +319,6 @@ export const whatsappPlugin: ChannelPlugin = }, }; }, - resolveAccountState: ({ configured }) => (configured ? "linked" : "not linked"), logSelfId: ({ account, runtime, includeChannelPrefix }) => { void loadWhatsAppChannelRuntime().then((runtimeExports) => runtimeExports.logWebSelfId(account.authDir, runtime, includeChannelPrefix), diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index 72a62dc3ca9c..bef4980f8871 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -109,6 +109,7 @@ export function createWhatsAppPluginBase(params: { setup: NonNullable["setup"]>; setupContract?: NonNullable["setupContract"]>; isConfigured: NonNullable["config"]>["isConfigured"]; + isLinked: NonNullable["config"]>["isLinked"]; }) { const collectWhatsAppSecurityWarnings = createAllowlistProviderGroupPolicyWarningCollector<{ account: ResolvedWhatsAppAccount; @@ -192,14 +193,15 @@ export function createWhatsAppPluginBase(params: { isEnabled: (account) => account.enabled, disabledReason: () => "disabled", isConfigured: params.isConfigured, + isLinked: params.isLinked, hasPersistedAuthState: ({ cfg }) => hasAnyWhatsAppAuth(cfg), - unconfiguredReason: () => "not linked", + unconfiguredReason: () => "not configured", + unlinkedReason: () => "not linked", describeAccount: (account) => describeAccountSnapshot({ account, configured: Boolean(account.authDir), extra: { - linked: Boolean(account.authDir), dmPolicy: account.dmPolicy, allowFrom: account.allowFrom, }, diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 794577464730..c77df0793c96 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -4,14 +4,10 @@ import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-outbound"; import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { - createAsyncComputedAccountStatusAdapter, + createComputedAccountStatusAdapter, createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers"; -import { - checkZcaAuthenticated, - resolveZalouserAccountSync, - type ResolvedZalouserAccount, -} from "./accounts.js"; +import { resolveZalouserAccountSync, type ResolvedZalouserAccount } from "./accounts.js"; import type { ChannelDirectoryEntry, ChannelPlugin } from "./channel-api.js"; import { DEFAULT_ACCOUNT_ID } from "./channel-api.js"; import { @@ -139,31 +135,22 @@ export const zalouserPlugin: ChannelPlugin( - { - defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), - collectStatusIssues: collectZalouserStatusIssues, - buildChannelSummary: ({ snapshot }) => buildPassiveProbedChannelStatusSummary(snapshot), - probeAccount: async ({ account, timeoutMs }) => - (await loadZalouserChannelRuntime()).probeZalouser(account.profile, timeoutMs), - resolveAccountSnapshot: async ({ account, runtime }) => { - const configured = await checkZcaAuthenticated(account.profile); - const configError = "not authenticated"; - return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured, - extra: { - dmPolicy: account.config.dmPolicy ?? "pairing", - lastError: configured - ? (runtime?.lastError ?? null) - : (runtime?.lastError ?? configError), - }, - }; + status: createComputedAccountStatusAdapter({ + defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), + collectStatusIssues: collectZalouserStatusIssues, + buildChannelSummary: ({ snapshot }) => buildPassiveProbedChannelStatusSummary(snapshot), + probeAccount: async ({ account, timeoutMs }) => + (await loadZalouserChannelRuntime()).probeZalouser(account.profile, timeoutMs), + resolveAccountSnapshot: ({ account }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.profile), + extra: { + dmPolicy: account.config.dmPolicy ?? "pairing", }, - }, - ), + }), + }), gateway: { startAccount: async (ctx) => { const { getZaloUserInfo } = await loadZalouserChannelRuntime(); diff --git a/extensions/zalouser/src/shared.ts b/extensions/zalouser/src/shared.ts index 4da9ab54ef78..dc3633b44b6c 100644 --- a/extensions/zalouser/src/shared.ts +++ b/extensions/zalouser/src/shared.ts @@ -84,10 +84,15 @@ export function createZalouserPluginBase(params: { configSchema: buildChannelConfigSchema(ZalouserConfigSchema), config: { ...zalouserConfigAdapter, - isConfigured: async (account) => await checkZcaAuthenticated(account.profile), + isConfigured: (account) => Boolean(account.profile), + isLinked: async (account) => + (await checkZcaAuthenticated(account.profile)) ? "linked" : "not-linked", + unconfiguredReason: () => "not configured", + unlinkedReason: () => "not authenticated", describeAccount: (account) => describeAccountSnapshot({ account, + configured: Boolean(account.profile), }), }, setup: params.setup, diff --git a/extensions/zalouser/src/status-issues.test.ts b/extensions/zalouser/src/status-issues.test.ts index 858326e06b72..cc68b70b971e 100644 --- a/extensions/zalouser/src/status-issues.test.ts +++ b/extensions/zalouser/src/status-issues.test.ts @@ -4,12 +4,13 @@ import { describe, expect, it } from "vitest"; import { collectZalouserStatusIssues } from "./status-issues.js"; describe("collectZalouserStatusIssues", () => { - it("flags missing auth when configured is false", () => { + it("flags missing auth when a configured profile is not linked", () => { const issues = collectZalouserStatusIssues([ { accountId: "default", enabled: true, - configured: false, + configured: true, + linked: false, lastError: "not authenticated", }, ]); diff --git a/extensions/zalouser/src/status-issues.ts b/extensions/zalouser/src/status-issues.ts index 44f1afa4e4ab..8157d8397c57 100644 --- a/extensions/zalouser/src/status-issues.ts +++ b/extensions/zalouser/src/status-issues.ts @@ -16,6 +16,7 @@ const ZALOUSER_STATUS_FIELDS = [ "accountId", "enabled", "configured", + "linked", "dmPolicy", "lastError", ] as const; @@ -35,9 +36,7 @@ export function collectZalouserStatusIssues( continue; } - const configured = account.configured === true; - - if (!configured) { + if (account.configured !== true || account.linked === false) { issues.push( standardNotConfiguredIssue({ channel: "zalouser", diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index 4685dee3ac42..7ac0bb457dda 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -643,9 +643,15 @@ function rebindChannelPluginConfig( isConfigured: config.isConfigured ? (account, cfg) => config.isConfigured?.(account, rebind(cfg)) ?? false : undefined, + isLinked: config.isLinked + ? (account, cfg) => config.isLinked?.(account, rebind(cfg)) ?? "unknown" + : undefined, unconfiguredReason: config.unconfiguredReason ? (account, cfg) => config.unconfiguredReason?.(account, rebind(cfg)) ?? "" : undefined, + unlinkedReason: config.unlinkedReason + ? (account, cfg) => config.unlinkedReason?.(account, rebind(cfg)) ?? "" + : undefined, describeAccount: config.describeAccount ? (account, cfg) => config.describeAccount!(account, rebind(cfg)) : undefined, diff --git a/src/channels/plugins/status.test.ts b/src/channels/plugins/status.test.ts index e43e3dbc5911..fbf8650552fa 100644 --- a/src/channels/plugins/status.test.ts +++ b/src/channels/plugins/status.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { buildChannelAccountSnapshotFromAccount } from "./status.js"; import type { ChannelPlugin } from "./types.plugin.js"; @@ -42,4 +42,83 @@ describe("buildChannelAccountSnapshotFromAccount", () => { expect(snapshot.baseUrl).toBe("https://chat.example.test/?token=***"); expect(account.baseUrl).toBe(rawBaseUrl); }); + + it("preserves lifecycle fields computed by a custom status adapter", async () => { + const account = { enabled: true, configured: true }; + const plugin = { + config: {}, + status: { + buildAccountSnapshot: () => ({ + accountId: "default", + linked: true, + running: false, + connected: false, + lastError: "probe failed", + }), + }, + } as unknown as ChannelPlugin; + + await expect( + buildChannelAccountSnapshotFromAccount({ + plugin, + cfg: {} as OpenClawConfig, + accountId: "default", + account, + }), + ).resolves.toMatchObject({ + configured: true, + linked: true, + running: false, + connected: false, + lastError: "probe failed", + }); + }); + + it("uses descriptor linkage when no live link resolver exists", async () => { + const account = { enabled: true, configured: true }; + const plugin = { + config: { + describeAccount: () => ({ accountId: "default", configured: true, linked: false }), + }, + } as unknown as ChannelPlugin; + + await expect( + buildChannelAccountSnapshotFromAccount({ + plugin, + cfg: {} as OpenClawConfig, + accountId: "default", + account, + }), + ).resolves.toMatchObject({ + configured: true, + linked: false, + running: false, + stateReason: "not linked", + lastError: null, + }); + }); + + it("does not inspect linkage when the account is unconfigured", async () => { + const isLinked = vi.fn(() => { + throw new Error("linkage unavailable"); + }); + const account = { enabled: true, configured: false }; + const plugin = { + config: { isConfigured: () => false, isLinked }, + } as unknown as ChannelPlugin; + + await expect( + buildChannelAccountSnapshotFromAccount({ + plugin, + cfg: {} as OpenClawConfig, + accountId: "default", + account, + }), + ).resolves.toMatchObject({ + configured: false, + stateReason: "not configured", + lastError: null, + }); + expect(isLinked).not.toHaveBeenCalled(); + }); }); diff --git a/src/channels/plugins/status.ts b/src/channels/plugins/status.ts index 8704932d926d..29cca4e46a8b 100644 --- a/src/channels/plugins/status.ts +++ b/src/channels/plugins/status.ts @@ -10,6 +10,11 @@ import { projectSafeChannelAccountSnapshotFields, redactChannelAccountSnapshotBaseUrl, } from "../account-snapshot-fields.js"; +import { + applyChannelAccountState, + resolveChannelAccountLinked, + resolveChannelAccountState, +} from "../status/account-state.js"; import type { ChannelPlugin } from "./types.plugin.js"; import type { ChannelAccountSnapshot } from "./types.public.js"; @@ -34,31 +39,41 @@ export async function buildChannelAccountSnapshotFromAccount(pa audit: params.audit, }); } 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; - 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, - enabled, - configured, ...projectSafeChannelAccountSnapshotFields(params.account), ...projectSafeChannelAccountSnapshotFields(params.runtime), }; } + const described = params.plugin.config.describeAccount?.(params.account, params.cfg); + const enabled = params.plugin.config.isEnabled + ? params.plugin.config.isEnabled(params.account, params.cfg) + : (described?.enabled ?? snapshot.enabled ?? params.enabledFallback ?? true); + const configured = + described?.configured ?? + (params.plugin.config.isConfigured + ? await params.plugin.config.isConfigured(params.account, params.cfg) + : (snapshot.configured ?? params.configuredFallback ?? true)); + const linkState = + configured && params.plugin.config.isLinked + ? await params.plugin.config.isLinked(params.account, params.cfg) + : undefined; + const state = resolveChannelAccountState({ + enabled, + configured, + linked: resolveChannelAccountLinked(linkState, described?.linked ?? snapshot.linked), + runtime: snapshot, + disabledReason: params.plugin.config.disabledReason?.(params.account, params.cfg), + unconfiguredReason: params.plugin.config.unconfiguredReason?.(params.account, params.cfg), + unlinkedReason: params.plugin.config.unlinkedReason?.(params.account, params.cfg), + }); + const projectedSnapshot = { ...snapshot }; + applyChannelAccountState(projectedSnapshot, state); return redactChannelAccountSnapshotBaseUrl({ - ...snapshot, + ...projectedSnapshot, + enabled, 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 } : {}), }); } diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 14adb6a90579..1f6465401b02 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -73,6 +73,8 @@ export type ChannelCapabilitiesDiagnostics = { type ChannelAdapterCallback unknown> = T; +export type ChannelAccountLinkState = "linked" | "not-linked" | "unknown"; + export type ChannelConfigAdapter = { listAccountIds: (cfg: OpenClawConfig) => string[]; resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount; @@ -91,9 +93,18 @@ export type ChannelConfigAdapter = { isConfigured?: ChannelAdapterCallback< (account: ResolvedAccount, cfg: OpenClawConfig) => boolean | Promise >; + isLinked?: ChannelAdapterCallback< + ( + account: ResolvedAccount, + cfg: OpenClawConfig, + ) => ChannelAccountLinkState | Promise + >; unconfiguredReason?: ChannelAdapterCallback< (account: ResolvedAccount, cfg: OpenClawConfig) => string >; + unlinkedReason?: ChannelAdapterCallback< + (account: ResolvedAccount, cfg: OpenClawConfig) => string + >; describeAccount?: ChannelAdapterCallback< (account: ResolvedAccount, cfg: OpenClawConfig) => ChannelAccountSnapshot >; diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 5b9e1cef3091..a479609b3ad6 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -165,6 +165,7 @@ export type ChannelAccountSnapshot = { lastMessageAt?: number | null; lastEventAt?: number | null; lastTransportActivityAt?: number | null; + stateReason?: string; lastError?: string | null; healthState?: string; terminalDisconnect?: boolean; diff --git a/src/channels/status/account-state.test.ts b/src/channels/status/account-state.test.ts new file mode 100644 index 000000000000..662ecc8faf21 --- /dev/null +++ b/src/channels/status/account-state.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import type { ChannelAccountSnapshot } from "../plugins/types.core.js"; +import { + applyChannelAccountState, + projectChannelAccountDisplayState, + resolveChannelAccountLinked, + resolveChannelAccountState, +} from "./account-state.js"; + +// The projection is module-internal; exercise it through the public merge helper +// so the test does not force a wider export surface than production needs. +function projectChannelAccountState(state: Parameters[1]) { + const snapshot: ChannelAccountSnapshot = { accountId: "default" }; + applyChannelAccountState(snapshot, state); + const { accountId: _accountId, ...projected } = snapshot; + return projected; +} + +const baseInput = { + enabled: true, + configured: true, + linked: true, + runtime: {}, + disabledReason: "disabled reason", + unconfiguredReason: "unconfigured reason", + unlinkedReason: "unlinked reason", +} as const; + +describe("resolveChannelAccountState", () => { + it.each([ + [ + "disabled wins over later states", + { enabled: false, configured: false, linked: false, runtime: { running: true } }, + { + kind: "disabled", + configured: false, + linked: false, + reason: "disabled reason", + failure: null, + }, + ], + [ + "unconfigured wins over linkage and runtime", + { configured: false, linked: false, runtime: { running: true } }, + { kind: "unconfigured", reason: "unconfigured reason", failure: null }, + ], + [ + "explicitly unlinked wins over runtime", + { linked: false, runtime: { running: true } }, + { kind: "unlinked", reason: "unlinked reason", failure: null }, + ], + [ + "running owns linkage, connectivity, and failure", + { runtime: { running: true, connected: true, lastError: "not linked" } }, + { kind: "running", linked: true, connected: true, failure: "not linked" }, + ], + [ + "stopped owns linkage, connectivity, and failure", + {}, + { kind: "stopped", linked: true, connected: undefined, failure: null }, + ], + ] as const)("%s", (_name, input, expected) => { + expect(resolveChannelAccountState({ ...baseInput, ...input })).toEqual(expected); + }); + + it.each([false, true])("keeps unknown linkage configured (running=%s)", (running) => { + const state = resolveChannelAccountState({ + ...baseInput, + linked: undefined, + runtime: { running }, + }); + expect(state.kind).toBe(running ? "running" : "stopped"); + expect(projectChannelAccountState(state)).toMatchObject({ configured: true }); + expect(projectChannelAccountState(state)).not.toHaveProperty("linked"); + }); + + it.each([ + ["linked", true], + ["not-linked", false], + ["unknown", undefined], + [undefined, true], + ] as const)("maps link state %s", (state, expected) => { + expect(resolveChannelAccountLinked(state, true)).toBe(expected); + }); +}); + +describe("projectChannelAccountState", () => { + it.each([ + [ + { + kind: "disabled", + configured: false, + linked: false, + reason: "disabled", + failure: null, + }, + { + configured: false, + linked: false, + running: false, + stateReason: "disabled", + lastError: null, + }, + ], + [ + { kind: "unconfigured", reason: "not configured", failure: null }, + { configured: false, running: false, stateReason: "not configured", lastError: null }, + ], + [ + { kind: "unlinked", reason: "not linked", failure: null }, + { + configured: true, + linked: false, + running: false, + stateReason: "not linked", + lastError: null, + }, + ], + [ + { kind: "running", linked: true, connected: false, failure: null }, + { configured: true, linked: true, running: true, connected: false, lastError: null }, + ], + [ + { kind: "stopped", connected: true, failure: "transport failed" }, + { configured: true, running: false, connected: true, lastError: "transport failed" }, + ], + ] as const)("projects %s without stale fields", (state, expected) => { + expect(projectChannelAccountState(state)).toEqual(expected); + }); + + it("replaces owner fields while preserving unrelated snapshot data", () => { + const state = resolveChannelAccountState({ ...baseInput, linked: undefined }); + const snapshot = { + accountId: "default", + configured: false, + linked: false, + running: true, + connected: true, + stateReason: "not linked", + lastError: "not linked", + mode: "polling", + }; + applyChannelAccountState(snapshot, state); + expect(snapshot).toEqual({ + accountId: "default", + configured: true, + running: false, + lastError: null, + mode: "polling", + }); + expect(projectChannelAccountDisplayState(state)).toBe("configured"); + expect(projectChannelAccountDisplayState(state, "enabled")).toBe("enabled"); + }); +}); diff --git a/src/channels/status/account-state.ts b/src/channels/status/account-state.ts new file mode 100644 index 000000000000..cdee6bad2b29 --- /dev/null +++ b/src/channels/status/account-state.ts @@ -0,0 +1,168 @@ +import type { ChannelAccountLinkState } from "../plugins/types.adapters.js"; +import type { + ChannelAccountSnapshot, + ChannelAccountState as ChannelAccountDisplayState, +} from "../plugins/types.core.js"; + +// Internal to this module: callers consume the projections, never the variant +// shape. Keeping it unexported also avoids colliding with the display-state +// `ChannelAccountState` in plugins/types.core.ts. +type ChannelAccountState = + | { + kind: "disabled"; + configured: boolean; + linked: boolean | undefined; + reason: string; + failure: string | null; + } + | { kind: "unconfigured"; reason: string; failure: string | null } + | { kind: "unlinked"; reason: string; failure: string | null } + | { kind: "running"; linked?: true; connected: boolean; failure: string | null } + | { kind: "stopped"; linked?: true; connected?: boolean; failure: string | null }; + +type ChannelAccountStateInput = { + enabled: boolean; + configured: boolean; + linked: boolean | undefined; + runtime?: Pick; + disabledReason?: string; + unconfiguredReason?: string; + unlinkedReason?: string; +}; + +function assertNeverState(state: never): never { + throw new Error(`Unhandled channel account state: ${String(state)}`); +} + +export function resolveChannelAccountState(input: ChannelAccountStateInput): ChannelAccountState { + const failure = input.runtime?.lastError ?? null; + if (!input.enabled) { + return { + kind: "disabled", + configured: input.configured, + linked: input.linked, + reason: input.disabledReason ?? "disabled", + failure, + }; + } + if (!input.configured) { + return { + kind: "unconfigured", + reason: input.unconfiguredReason ?? "not configured", + failure, + }; + } + if (input.linked === false) { + return { kind: "unlinked", reason: input.unlinkedReason ?? "not linked", failure }; + } + if (input.runtime?.running === true) { + return { + kind: "running", + linked: input.linked, + connected: input.runtime.connected ?? false, + failure, + }; + } + return { + kind: "stopped", + linked: input.linked, + connected: input.runtime?.connected, + failure, + }; +} + +export function resolveChannelAccountLinked( + state: ChannelAccountLinkState | undefined, + fallback?: boolean, +): boolean | undefined { + return state ? (state === "unknown" ? undefined : state === "linked") : fallback; +} + +function projectChannelAccountState(state: ChannelAccountState): { + configured: boolean; + linked?: boolean; + running: boolean; + connected?: boolean; + stateReason?: string; + lastError: string | null; +} { + switch (state.kind) { + case "disabled": + return { + configured: state.configured, + ...(typeof state.linked === "boolean" ? { linked: state.linked } : {}), + running: false, + stateReason: state.reason, + lastError: state.failure, + }; + case "unconfigured": + return { + configured: false, + running: false, + stateReason: state.reason, + lastError: state.failure, + }; + case "unlinked": + return { + configured: true, + linked: false, + running: false, + stateReason: state.reason, + lastError: state.failure, + }; + case "running": + return { + configured: true, + ...(state.linked ? { linked: true } : {}), + running: true, + connected: state.connected, + lastError: state.failure, + }; + case "stopped": + return { + configured: true, + ...(state.linked ? { linked: true } : {}), + running: false, + ...(typeof state.connected === "boolean" ? { connected: state.connected } : {}), + lastError: state.failure, + }; + } + return assertNeverState(state); +} + +const CHANNEL_ACCOUNT_STATE_FIELDS = [ + "configured", + "linked", + "running", + "connected", + "stateReason", + "lastError", +] as const; + +export function applyChannelAccountState( + snapshot: ChannelAccountSnapshot, + state: ChannelAccountState, +): void { + for (const field of CHANNEL_ACCOUNT_STATE_FIELDS) { + delete snapshot[field]; + } + Object.assign(snapshot, projectChannelAccountState(state)); +} + +export function projectChannelAccountDisplayState( + state: ChannelAccountState, + fallback?: ChannelAccountDisplayState, +): ChannelAccountDisplayState { + switch (state.kind) { + case "disabled": + return "disabled"; + case "unconfigured": + return "not configured"; + case "unlinked": + return "not linked"; + case "running": + case "stopped": + return state.linked ? "linked" : (fallback ?? "configured"); + } + return assertNeverState(state); +} diff --git a/src/commands/agents.providers.test.ts b/src/commands/agents.providers.test.ts index 716439d5312b..3130969c735a 100644 --- a/src/commands/agents.providers.test.ts +++ b/src/commands/agents.providers.test.ts @@ -132,6 +132,48 @@ describe("buildProviderStatusIndex", () => { ); }); + it("does not inspect linkage for an unconfigured account", async () => { + const isLinked = vi.fn(() => { + throw new Error("linkage unavailable"); + }); + const plugin = { + id: "quietchat", + meta: { label: "QuietChat" }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ enabled: true }), + isConfigured: () => false, + isLinked, + }, + } as never; + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([plugin]); + + const status = (await buildProviderStatusIndex({} as OpenClawConfig)).get("quietchat:default"); + + expect(status?.state).toBe("not configured"); + expect(isLinked).not.toHaveBeenCalled(); + }); + + it("uses the shipped custom state resolver when canonical linkage is unknown", async () => { + const resolveAccountState = vi.fn(() => "enabled" as const); + const plugin = { + id: "legacychat", + meta: { label: "LegacyChat" }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ enabled: true, configured: true }), + isConfigured: () => true, + }, + status: { resolveAccountState }, + } as never; + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([plugin]); + + const status = (await buildProviderStatusIndex({} as OpenClawConfig)).get("legacychat:default"); + + expect(status?.state).toBe("enabled"); + expect(resolveAccountState).toHaveBeenCalledOnce(); + }); + it("rethrows unexpected read-only account resolution errors", async () => { const plugin = { id: "quietchat", diff --git a/src/commands/agents.providers.ts b/src/commands/agents.providers.ts index 44f292d443eb..8df35b093b74 100644 --- a/src/commands/agents.providers.ts +++ b/src/commands/agents.providers.ts @@ -6,6 +6,11 @@ import { normalizeChannelId } from "../channels/plugins/index.js"; import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import type { ChannelId } from "../channels/plugins/types.public.js"; +import { + projectChannelAccountDisplayState, + resolveChannelAccountLinked, + resolveChannelAccountState, +} from "../channels/status/account-state.js"; import type { AgentBinding } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { listExplicitConfiguredChannelIdsForConfig } from "../plugins/channel-plugin-ids.js"; @@ -170,20 +175,25 @@ export async function buildProviderStatusIndex( : snapshot?.configured; const resolvedEnabled = typeof enabled === "boolean" ? enabled : true; const resolvedConfigured = typeof configured === "boolean" ? configured : true; - const state = - plugin.status?.resolveAccountState?.({ - account, - cfg, - configured: resolvedConfigured, + const linkState = + resolvedConfigured && plugin.config.isLinked + ? await plugin.config.isLinked(account, cfg) + : undefined; + const linked = resolveChannelAccountLinked(linkState, snapshot?.linked); + const fallbackState = plugin.status?.resolveAccountState?.({ + account, + cfg, + configured: resolvedConfigured, + enabled: resolvedEnabled, + }); + const state = projectChannelAccountDisplayState( + resolveChannelAccountState({ enabled: resolvedEnabled, - }) ?? - (typeof snapshot?.linked === "boolean" - ? snapshot.linked - ? "linked" - : "not linked" - : resolvedConfigured - ? "configured" - : "not configured"); + configured: resolvedConfigured, + linked, + }), + fallbackState, + ); const name = snapshot?.name ?? (account as { name?: string }).name; map.set(providerAccountKey(plugin.id, accountId), { provider: plugin.id, diff --git a/src/commands/channels.config-only-status-output.test.ts b/src/commands/channels.config-only-status-output.test.ts index fe9c037e0a94..850956a80ab5 100644 --- a/src/commands/channels.config-only-status-output.test.ts +++ b/src/commands/channels.config-only-status-output.test.ts @@ -17,35 +17,6 @@ vi.mock("../channels/plugins/read-only.js", () => ({ listReadOnlyChannelPluginsForConfig, })); -vi.mock("../channels/plugins/status.js", () => ({ - buildReadOnlySourceChannelAccountSnapshot: async ({ - accountId, - cfg, - plugin, - }: { - accountId: string; - cfg: unknown; - plugin: ChannelPlugin; - }) => { - const account = await plugin.config.inspectAccount?.(cfg as never, accountId); - return account ? { accountId, ...(account as Record) } : null; - }, - buildChannelAccountSnapshot: async ({ - accountId, - cfg, - plugin, - }: { - accountId: string; - cfg: unknown; - plugin: ChannelPlugin; - }) => { - const account = - (await plugin.config.inspectAccount?.(cfg as never, accountId)) ?? - plugin.config.resolveAccount(cfg as never, accountId); - return { accountId, ...(account as Record) }; - }, -})); - function registerSingleTestPlugin(_pluginId: string, plugin: ChannelPlugin) { activeChannelPlugins.splice(0, activeChannelPlugins.length, plugin); } @@ -179,6 +150,27 @@ function makeUnavailableHttpSlackPlugin(): ChannelPlugin { }); } +function makeIndeterminateLinkPlugin(): ChannelPlugin { + return makeDirectPlugin({ + id: "whatsapp", + label: "WhatsApp", + docsPath: "/channels/whatsapp", + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ accountId: "default", enabled: true, authDir: "/auth" }), + isEnabled: () => true, + isConfigured: () => true, + isLinked: () => "unknown", + unlinkedReason: () => "not linked", + describeAccount: () => ({ + accountId: "default", + enabled: true, + configured: true, + }), + }, + }); +} + function expectResolvedTokenStatusSummary( summary: string, options?: { includeUnavailableTokenLine?: boolean }, @@ -259,4 +251,15 @@ describe("config-only channels status output", () => { expect(joined).toContain("bot:config"); expect(joined).toContain("signing:config (unavailable)"); }); + + it("never reports not configured when linkage is indeterminate", async () => { + registerSingleTestPlugin("whatsapp", makeIndeterminateLinkPlugin()); + + const joined = await formatLocalStatusSummary({ channels: { whatsapp: {} } }); + + expect(joined).toContain("WhatsApp default: enabled, configured"); + expect(joined).not.toContain("not configured"); + expect(joined).not.toContain("not linked"); + expect(joined).not.toContain("error:"); + }); }); diff --git a/src/commands/channels/shared.ts b/src/commands/channels/shared.ts index 501b20484a59..9b676618c67d 100644 --- a/src/commands/channels/shared.ts +++ b/src/commands/channels/shared.ts @@ -66,7 +66,7 @@ export function formatChannelAccountLabel(params: { return `${styledChannel} ${styledAccount}`; } -/** Append common enabled/configured/linked status fragments for account output. */ +/** Append canonical state fragments and genuine runtime failures for account output. */ export function appendEnabledConfiguredLinkedBits( bits: string[], account: Record, @@ -87,6 +87,18 @@ export function appendEnabledConfiguredLinkedBits( if (typeof account.linked === "boolean") { bits.push(account.linked ? "linked" : "not linked"); } + const reason = typeof account.stateReason === "string" ? account.stateReason : ""; + const duplicatesState = + (account.enabled === false && reason === "disabled") || + (account.configured === false && reason === "not configured") || + (account.linked === false && reason === "not linked"); + if (reason && !duplicatesState) { + bits.push(`reason:${reason}`); + } + const error = typeof account.lastError === "string" ? account.lastError : ""; + if (error) { + bits.push(`error:${error}`); + } } /** Append account mode metadata when present. */ diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index 0dd898abb4ff..09b813a46a5c 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -176,9 +176,6 @@ export function formatGatewayChannelsStatusLines(payload: Record { expect(evaluation).toEqual({ healthy: true, reason: "unmanaged" }); }); + it("treats explicitly unlinked accounts as healthy unmanaged", () => { + const evaluation = evaluateHealth({ + running: false, + enabled: true, + configured: true, + linked: false, + }); + expect(evaluation).toEqual({ healthy: true, reason: "unmanaged" }); + }); + it("uses channel connect grace before flagging disconnected", () => { const evaluation = evaluateHealth( runningAccount({ diff --git a/src/gateway/channel-health-policy.ts b/src/gateway/channel-health-policy.ts index 53434ac6db73..2f23cee26c9d 100644 --- a/src/gateway/channel-health-policy.ts +++ b/src/gateway/channel-health-policy.ts @@ -7,6 +7,7 @@ type ChannelHealthSnapshot = { connected?: boolean; enabled?: boolean; configured?: boolean; + linked?: boolean; restartPending?: boolean; busy?: boolean; activeRuns?: number; @@ -47,7 +48,7 @@ export type ChannelHealthPolicy = { type ChannelRestartReason = "gave-up" | "stopped" | "stale-socket" | "stuck" | "disconnected"; function isManagedAccount(snapshot: ChannelHealthSnapshot): boolean { - return snapshot.enabled !== false && snapshot.configured !== false; + return snapshot.enabled !== false && snapshot.configured !== false && snapshot.linked !== false; } const BUSY_ACTIVITY_STALE_THRESHOLD_MS = 25 * 60_000; diff --git a/src/gateway/server-channels.test.ts b/src/gateway/server-channels.test.ts index a42c9c793a15..c24d4929d71f 100644 --- a/src/gateway/server-channels.test.ts +++ b/src/gateway/server-channels.test.ts @@ -2,8 +2,12 @@ * Server channel lifecycle tests. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ChannelGatewayContext } from "../channels/plugins/types.adapters.js"; +import type { + ChannelAccountLinkState, + ChannelGatewayContext, +} from "../channels/plugins/types.adapters.js"; import type { ChannelId, ChannelPlugin } from "../channels/plugins/types.public.js"; +import { formatGatewayChannelsStatusLines } from "../commands/channels/status.js"; import { createSubsystemLogger, type SubsystemLogger, @@ -90,6 +94,10 @@ function createTestPlugin(params?: { describeAccount?: ChannelPlugin["config"]["describeAccount"]; resolveAccount?: ChannelPlugin["config"]["resolveAccount"]; isConfigured?: ChannelPlugin["config"]["isConfigured"]; + isLinked?: ChannelPlugin["config"]["isLinked"]; + disabledReason?: ChannelPlugin["config"]["disabledReason"]; + unconfiguredReason?: ChannelPlugin["config"]["unconfiguredReason"]; + unlinkedReason?: ChannelPlugin["config"]["unlinkedReason"]; }): ChannelPlugin { const id = params?.id ?? "discord"; const account = params?.account ?? { enabled: true, configured: true }; @@ -99,6 +107,10 @@ function createTestPlugin(params?: { resolveAccount: params?.resolveAccount ?? (() => account), isEnabled: (resolved) => resolved.enabled !== false, ...(params?.isConfigured ? { isConfigured: params.isConfigured } : {}), + ...(params?.isLinked ? { isLinked: params.isLinked } : {}), + ...(params?.disabledReason ? { disabledReason: params.disabledReason } : {}), + ...(params?.unconfiguredReason ? { unconfiguredReason: params.unconfiguredReason } : {}), + ...(params?.unlinkedReason ? { unlinkedReason: params.unlinkedReason } : {}), }; if (includeDescribeAccount) { config.describeAccount = @@ -1219,6 +1231,69 @@ describe("server-channels auto restart", () => { expect(account?.configured).toBe(true); }); + it("retains an async configuration result when descriptors omit it", async () => { + const startAccount = vi.fn(async () => {}); + installTestRegistry( + createTestPlugin({ + includeDescribeAccount: false, + isConfigured: async () => false, + startAccount, + }), + ); + const manager = createManager(); + + await manager.startChannel("discord"); + + expect(startAccount).not.toHaveBeenCalled(); + expect(manager.getRuntimeSnapshot().channelAccounts.discord?.default).toMatchObject({ + configured: false, + running: false, + stateReason: "not configured", + lastError: null, + }); + }); + + it("preserves runtime linkage when the plugin has no link resolver", async () => { + const account = { enabled: true, configured: true }; + const startAccount = vi.fn( + async ({ abortSignal }: ChannelGatewayContext) => + await new Promise((resolve) => { + abortSignal.addEventListener("abort", () => resolve(), { once: true }); + }), + ); + const plugin = createTestPlugin({ account, startAccount }); + plugin.status = { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + linked: true, + running: false, + lastError: null, + }, + }; + installTestRegistry(plugin); + const manager = createManager(); + + await manager.startChannel("discord"); + + expect(manager.getRuntimeSnapshot().channelAccounts.discord?.default?.linked).toBe(true); + manager.markChannelLoggedOut("discord", true); + expect(manager.getRuntimeSnapshot().channelAccounts.discord?.default).toMatchObject({ + linked: false, + running: false, + lastError: "logged out", + }); + await manager.stopChannel("discord", DEFAULT_ACCOUNT_ID); + + account.enabled = false; + await manager.startChannel("discord"); + expect(manager.getRuntimeSnapshot().channelAccounts.discord?.default?.linked).toBe(false); + + account.enabled = true; + await manager.startChannel("discord"); + expect(startAccount).toHaveBeenCalledOnce(); + expect(manager.getRuntimeSnapshot().channelAccounts.discord?.default?.linked).toBe(false); + }); + it("applies described config fields into runtime snapshots", () => { installTestRegistry( createTestPlugin({ @@ -1237,6 +1312,168 @@ describe("server-channels auto restart", () => { expect(account?.mode).toBe("webhook"); }); + it("applies described linkage before startup and into runtime snapshots", async () => { + const startAccount = vi.fn(async () => {}); + installTestRegistry( + createTestPlugin({ + startAccount, + describeAccount: () => ({ + accountId: DEFAULT_ACCOUNT_ID, + configured: true, + linked: false, + }), + }), + ); + const manager = createManager(); + + await manager.startChannel("discord"); + const account = manager.getRuntimeSnapshot().channelAccounts.discord?.default; + + expect(startAccount).not.toHaveBeenCalled(); + expect(account).toMatchObject({ + configured: true, + linked: false, + stateReason: "not linked", + }); + }); + + it("cannot retain an unlinked explanation after a successful linked start", async () => { + let linkState: ChannelAccountLinkState = "not-linked"; + const startAccount = vi.fn( + async ({ abortSignal }: ChannelGatewayContext) => + await new Promise((resolve) => { + abortSignal.addEventListener("abort", () => resolve(), { once: true }); + }), + ); + installTestRegistry( + createTestPlugin({ + id: "whatsapp", + startAccount, + isConfigured: () => true, + isLinked: () => linkState, + unlinkedReason: () => "not authenticated", + }), + ); + const manager = createManager({ channelIds: ["whatsapp"] }); + + await manager.startChannel("whatsapp"); + const unlinkedAccount = manager.getRuntimeSnapshot().channelAccounts.whatsapp?.default; + expect(unlinkedAccount).toMatchObject({ + configured: true, + linked: false, + running: false, + stateReason: "not authenticated", + lastError: null, + }); + expect( + formatGatewayChannelsStatusLines({ + channelAccounts: { whatsapp: unlinkedAccount ? [unlinkedAccount] : [] }, + }).join("\n"), + ).toContain("reason:not authenticated"); + + linkState = "linked"; + await manager.startChannel("whatsapp"); + const snapshot = manager.getRuntimeSnapshot(); + const account = snapshot.channelAccounts.whatsapp?.default; + const output = formatGatewayChannelsStatusLines({ + channelAccounts: { whatsapp: account ? [account] : [] }, + }).join("\n"); + expect(startAccount).toHaveBeenCalledOnce(); + expect(account).toMatchObject({ + configured: true, + linked: true, + running: true, + lastError: null, + }); + expect(account).not.toHaveProperty("stateReason"); + expect(output).toContain("configured, linked, running"); + expect(output).not.toContain("error:not linked"); + }); + + it("keeps configured true when the linkage read is indeterminate", async () => { + const startAccount = vi.fn(async () => {}); + installTestRegistry( + createTestPlugin({ + id: "whatsapp", + startAccount, + isConfigured: () => true, + isLinked: () => "unknown", + describeAccount: () => ({ + accountId: DEFAULT_ACCOUNT_ID, + configured: true, + linked: false, + }), + }), + ); + const manager = createManager({ channelIds: ["whatsapp"] }); + + await manager.startChannel("whatsapp"); + + expect(startAccount).not.toHaveBeenCalled(); + expect(manager.getRuntimeSnapshot().channelAccounts.whatsapp?.default).toMatchObject({ + configured: true, + running: false, + lastError: null, + }); + expect(manager.getRuntimeSnapshot().channelAccounts.whatsapp?.default).not.toHaveProperty( + "linked", + ); + }); + + it.each([ + "telegram", + "slack", + "discord", + "imessage", + "signal", + "msteams", + "mattermost", + "feishu", + "irc", + "tlon", + "zalo", + "zalouser", + "nextcloud-talk", + "sms", + ] as const)("does not retain a stale derived reason for %s", async (channelId) => { + const account = { enabled: true, configured: false }; + const startAccount = vi.fn( + async ({ abortSignal }: ChannelGatewayContext) => + await new Promise((resolve) => { + abortSignal.addEventListener("abort", () => resolve(), { once: true }); + }), + ); + installTestRegistry( + createTestPlugin({ + id: channelId, + account, + startAccount, + isConfigured: (resolved) => resolved.configured === true, + unconfiguredReason: () => `${channelId} not configured`, + }), + ); + const manager = createManager({ channelIds: [channelId] }); + + await manager.startChannel(channelId); + expect(manager.getRuntimeSnapshot().channelAccounts[channelId]?.default).toMatchObject({ + stateReason: `${channelId} not configured`, + lastError: null, + }); + + account.configured = true; + await manager.startChannel(channelId); + + expect(startAccount).toHaveBeenCalledOnce(); + expect(manager.getRuntimeSnapshot().channelAccounts[channelId]?.default).toMatchObject({ + configured: true, + running: true, + lastError: null, + }); + expect(manager.getRuntimeSnapshot().channelAccounts[channelId]?.default).not.toHaveProperty( + "stateReason", + ); + }); + it("passes channelRuntime through channel gateway context when provided", async () => { const channelRuntime = { ...createRuntimeChannel(), diff --git a/src/gateway/server-channels.ts b/src/gateway/server-channels.ts index d400ca707448..2a0b1043e16f 100644 --- a/src/gateway/server-channels.ts +++ b/src/gateway/server-channels.ts @@ -5,6 +5,10 @@ import { getCredentialUnavailableDiagnostics } from "../channels/account-snapsho import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { type ChannelId, getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; import type { ChannelAccountSnapshot } from "../channels/plugins/types.public.js"; +import { + applyChannelAccountState, + resolveChannelAccountState, +} from "../channels/status/account-state.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { withGatewayNativeApprovalRuntime } from "../infra/approval-gateway-runtime-context.js"; import type { GatewayNativeApprovalRuntime } from "../infra/approval-gateway-runtime.types.js"; @@ -32,7 +36,6 @@ import type { RuntimeEnv } from "../runtime.js"; import { assertSecretOwnerAvailable, clearActiveCredentialDegradedOwner, - SecretSurfaceUnavailableError, setActiveCredentialDegradedOwner, } from "../secrets/runtime-degraded-state.js"; import { isAccountEnabled } from "../shared/account-enabled.js"; @@ -163,25 +166,6 @@ async function waitForChannelStopGracefully(task: Promise | undefined, }); } -function applyDescribedAccountFields( - next: ChannelAccountSnapshot, - described: ChannelAccountSnapshot | undefined, -) { - if (!described) { - next.configured ??= true; - return next; - } - if (typeof described.configured === "boolean") { - next.configured = described.configured; - } else { - next.configured ??= true; - } - if (described.mode !== undefined) { - next.mode = described.mode; - } - return next; -} - type ChannelManagerOptions = { getRuntimeConfig: () => OpenClawConfig; channelLogs: Partial>; @@ -495,7 +479,6 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage if (ambientAutostartSuppressedChannelIds.has(channelId) && optsValue.manual !== true) { for (const id of accountIds) { setStoppedRuntime(channelId, id, { - configured: false, restartPending: false, lastError: "ambient channel credentials suppressed for dev gateway", }); @@ -594,6 +577,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage clearActiveCredentialDegradedOwner("account", secretOwnerId); assertSecretOwnerAvailable("account", secretOwnerId); const account = plugin.config.resolveAccount(cfg, id); + const described = plugin.config.describeAccount?.(account, cfg); const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : isAccountEnabled(account); @@ -601,10 +585,8 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage setRuntime(channelId, id, { accountId: id, enabled: false, - configured: true, running: false, restartPending: false, - lastError: plugin.config.disabledReason?.(account, cfg) ?? "disabled", }); return; } @@ -633,9 +615,36 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage accountId: id, enabled: true, configured: false, + linked: undefined, + running: false, + restartPending: false, + }); + return; + } + setRuntime(channelId, id, { + accountId: id, + enabled: true, + configured: true, + ...(plugin.config.isLinked ? { linked: undefined } : {}), + }); + + const fallbackLinked = described?.linked ?? getRuntime(channelId, id).linked; + const linkState = plugin.config.isLinked + ? await measureStartup(`channels.${channelId}.is-linked`, () => + plugin.config.isLinked!(account, cfg), + ) + : fallbackLinked === true + ? "linked" + : fallbackLinked === false + ? "not-linked" + : undefined; + if (linkState === "not-linked" || linkState === "unknown") { + setRuntime(channelId, id, { + accountId: id, + enabled: true, + linked: linkState === "not-linked" ? false : undefined, running: false, restartPending: false, - lastError: plugin.config.unconfiguredReason?.(account, cfg) ?? "not configured", }); return; } @@ -682,7 +691,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage setRuntime(channelId, id, { accountId: id, enabled: true, - configured: true, + ...(linkState === "linked" ? { linked: true } : {}), running: true, restartPending: false, lastStartAt: Date.now(), @@ -905,7 +914,6 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage } catch (error) { if (!handedOffTask) { setStoppedRuntime(channelId, id, { - ...(error instanceof SecretSurfaceUnavailableError ? { configured: true } : {}), restartPending: false, lastError: formatErrorMessage(error), }); @@ -1144,6 +1152,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage const current = getRuntime(channelId, resolvedId); const next: ChannelAccountSnapshot = { accountId: resolvedId, + ...(cleared ? { linked: false } : {}), running: false, restartPending: false, lastError: cleared ? "logged out" : current.lastError, @@ -1174,16 +1183,24 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage : isAccountEnabled(account); const described = plugin.config.describeAccount?.(account, cfg); const current = store.runtimes.get(id) ?? cloneDefaultRuntime(plugin.id, id); - const next = { ...current, accountId: id }; - next.enabled = enabled; - applyDescribedAccountFields(next, described); - const configured = described?.configured; - if (!next.running) { - if (!enabled) { - next.lastError ??= plugin.config.disabledReason?.(account, cfg) ?? "disabled"; - } else if (configured === false) { - next.lastError ??= plugin.config.unconfiguredReason?.(account, cfg) ?? "not configured"; - } + const configured = described?.configured ?? current.configured ?? true; + const state = resolveChannelAccountState({ + enabled, + configured, + linked: plugin.config.isLinked + ? current.linked + : typeof current.linked === "boolean" + ? current.linked + : described?.linked, + runtime: current, + disabledReason: plugin.config.disabledReason?.(account, cfg), + unconfiguredReason: plugin.config.unconfiguredReason?.(account, cfg), + unlinkedReason: plugin.config.unlinkedReason?.(account, cfg), + }); + const next = { ...current, accountId: id, enabled }; + applyChannelAccountState(next, state); + if (described?.mode !== undefined) { + next.mode = described.mode; } accounts[id] = next; } diff --git a/src/plugin-sdk/status-helpers.test.ts b/src/plugin-sdk/status-helpers.test.ts index a29f55f20409..6e1c02fc6010 100644 --- a/src/plugin-sdk/status-helpers.test.ts +++ b/src/plugin-sdk/status-helpers.test.ts @@ -302,7 +302,12 @@ describe("buildComputedAccountStatusSnapshot", () => { enabled: true, configured: false, }), - ).toEqual(expectedAccountSnapshot({ enabled: true })); + ).toEqual( + expectedAccountSnapshot({ + enabled: true, + stateReason: "not configured", + }), + ); }); it("merges computed extras after the shared fields", () => { diff --git a/src/plugin-sdk/status-helpers.ts b/src/plugin-sdk/status-helpers.ts index 68b42013f5f1..fb31e212e3c9 100644 --- a/src/plugin-sdk/status-helpers.ts +++ b/src/plugin-sdk/status-helpers.ts @@ -3,6 +3,10 @@ import { normalizeOptionalString } from "../../packages/normalization-core/src/s import type { ChannelStatusAdapter } from "../channels/plugins/types.adapters.js"; import type { ChannelAccountSnapshot } from "../channels/plugins/types.core.js"; import type { ChannelStatusIssue } from "../channels/plugins/types.public.js"; +import { + applyChannelAccountState, + resolveChannelAccountState, +} from "../channels/status/account-state.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; export type { ChannelAccountSnapshot } from "../channels/plugins/types.core.js"; export type { ChannelStatusIssue } from "../channels/plugins/types.public.js"; @@ -21,6 +25,7 @@ export { } from "../utils/reaction-level.js"; type RuntimeLifecycleSnapshot = { + linked?: boolean | null; running?: boolean | null; connected?: boolean | null; restartPending?: boolean | null; @@ -181,6 +186,7 @@ export function buildBaseChannelStatusSummary