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
This commit is contained in:
Peter Steinberger
2026-07-27 23:04:28 -04:00
committed by GitHub
parent ea707382fd
commit e0a119dabf
29 changed files with 954 additions and 184 deletions

View File

@@ -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

View File

@@ -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();
});

View File

@@ -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<boolean> {
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

View File

@@ -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",
);
});
});

View File

@@ -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<ResolvedWhatsAppAccount> = {
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 }) =>

View File

@@ -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<ResolvedWhatsAppAccount> =
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<ResolvedWhatsAppAccount> =
});
},
},
status: createAsyncComputedAccountStatusAdapter<ResolvedWhatsAppAccount>({
status: createComputedAccountStatusAdapter<ResolvedWhatsAppAccount>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, {
connected: false,
reconnectAttempts: 0,
@@ -262,21 +266,13 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
? "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<ResolvedWhatsAppAccount> =
: {}),
};
},
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<ResolvedWhatsAppAccount> =
},
};
},
resolveAccountState: ({ configured }) => (configured ? "linked" : "not linked"),
logSelfId: ({ account, runtime, includeChannelPrefix }) => {
void loadWhatsAppChannelRuntime().then((runtimeExports) =>
runtimeExports.logWebSelfId(account.authDir, runtime, includeChannelPrefix),

View File

@@ -109,6 +109,7 @@ export function createWhatsAppPluginBase(params: {
setup: NonNullable<ChannelPlugin<ResolvedWhatsAppAccount>["setup"]>;
setupContract?: NonNullable<ChannelPlugin<ResolvedWhatsAppAccount>["setupContract"]>;
isConfigured: NonNullable<ChannelPlugin<ResolvedWhatsAppAccount>["config"]>["isConfigured"];
isLinked: NonNullable<ChannelPlugin<ResolvedWhatsAppAccount>["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,
},

View File

@@ -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<ResolvedZalouserAccount, ZalouserProb
resolver: zalouserResolverAdapter,
auth: zalouserAuthAdapter,
message: zalouserMessageAdapter,
status: createAsyncComputedAccountStatusAdapter<ResolvedZalouserAccount, ZalouserProbeResult>(
{
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<ResolvedZalouserAccount, ZalouserProbeResult>({
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();

View File

@@ -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,

View File

@@ -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",
},
]);

View File

@@ -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",

View File

@@ -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,

View File

@@ -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<typeof account>;
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<typeof account>;
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<typeof account>;
await expect(
buildChannelAccountSnapshotFromAccount({
plugin,
cfg: {} as OpenClawConfig,
accountId: "default",
account,
}),
).resolves.toMatchObject({
configured: false,
stateReason: "not configured",
lastError: null,
});
expect(isLinked).not.toHaveBeenCalled();
});
});

View File

@@ -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<ResolvedAccount>(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 } : {}),
});
}

View File

@@ -73,6 +73,8 @@ export type ChannelCapabilitiesDiagnostics = {
type ChannelAdapterCallback<T extends (...args: never[]) => unknown> = T;
export type ChannelAccountLinkState = "linked" | "not-linked" | "unknown";
export type ChannelConfigAdapter<ResolvedAccount> = {
listAccountIds: (cfg: OpenClawConfig) => string[];
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount;
@@ -91,9 +93,18 @@ export type ChannelConfigAdapter<ResolvedAccount> = {
isConfigured?: ChannelAdapterCallback<
(account: ResolvedAccount, cfg: OpenClawConfig) => boolean | Promise<boolean>
>;
isLinked?: ChannelAdapterCallback<
(
account: ResolvedAccount,
cfg: OpenClawConfig,
) => ChannelAccountLinkState | Promise<ChannelAccountLinkState>
>;
unconfiguredReason?: ChannelAdapterCallback<
(account: ResolvedAccount, cfg: OpenClawConfig) => string
>;
unlinkedReason?: ChannelAdapterCallback<
(account: ResolvedAccount, cfg: OpenClawConfig) => string
>;
describeAccount?: ChannelAdapterCallback<
(account: ResolvedAccount, cfg: OpenClawConfig) => ChannelAccountSnapshot
>;

View File

@@ -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;

View File

@@ -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<typeof applyChannelAccountState>[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");
});
});

View File

@@ -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<ChannelAccountSnapshot, "running" | "connected" | "lastError">;
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);
}

View File

@@ -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",

View File

@@ -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,

View File

@@ -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<string, unknown>) } : 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<string, unknown>) };
},
}));
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:");
});
});

View File

@@ -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<string, unknown>,
@@ -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. */

View File

@@ -176,9 +176,6 @@ export function formatGatewayChannelsStatusLines(payload: Record<string, unknown
if (audit && typeof audit.ok === "boolean") {
bits.push(audit.ok ? "audit ok" : "audit failed");
}
if (typeof account.lastError === "string" && account.lastError) {
bits.push(`error:${account.lastError}`);
}
const rawChannelLabel = channelLabels[provider];
return buildChannelAccountLine(provider, account, bits, {
channelLabel: typeof rawChannelLabel === "string" ? rawChannelLabel : provider,

View File

@@ -64,6 +64,16 @@ describe("evaluateChannelHealth", () => {
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({

View File

@@ -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;

View File

@@ -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<TestAccount>["config"]["describeAccount"];
resolveAccount?: ChannelPlugin<TestAccount>["config"]["resolveAccount"];
isConfigured?: ChannelPlugin<TestAccount>["config"]["isConfigured"];
isLinked?: ChannelPlugin<TestAccount>["config"]["isLinked"];
disabledReason?: ChannelPlugin<TestAccount>["config"]["disabledReason"];
unconfiguredReason?: ChannelPlugin<TestAccount>["config"]["unconfiguredReason"];
unlinkedReason?: ChannelPlugin<TestAccount>["config"]["unlinkedReason"];
}): ChannelPlugin<TestAccount> {
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<TestAccount>) =>
await new Promise<void>((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<TestAccount>) =>
await new Promise<void>((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<TestAccount>) =>
await new Promise<void>((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(),

View File

@@ -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<unknown> | 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<Record<ChannelId, SubsystemLogger>>;
@@ -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;
}

View File

@@ -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", () => {

View File

@@ -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<TExtra extends StatusSnapshotExtra
},
extra?: TExtra,
) {
// Channel summaries already consume projected account state; this helper only normalizes nulls.
return {
configured: snapshot.configured ?? false,
...(extra ?? ({} as TExtra)),
@@ -244,7 +250,7 @@ export function buildBaseAccountStatusSnapshot<TExtra extends StatusSnapshotExtr
extra?: TExtra,
) {
const { account, runtime, probe } = params;
return {
const snapshot = {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
@@ -254,6 +260,14 @@ export function buildBaseAccountStatusSnapshot<TExtra extends StatusSnapshotExtr
lastOutboundAt: runtime?.lastOutboundAt ?? null,
...(extra ?? ({} as TExtra)),
};
const state = resolveChannelAccountState({
enabled: account.enabled !== false,
configured: account.configured === true,
linked: typeof snapshot.linked === "boolean" ? snapshot.linked : undefined,
runtime: snapshot,
});
applyChannelAccountState(snapshot, state);
return snapshot;
}
/** Convenience wrapper when the caller already has flattened account fields instead of an account object. */
@@ -367,6 +381,7 @@ export function buildRuntimeAccountStatusSnapshot<TExtra extends StatusSnapshotE
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
...(typeof runtime?.linked === "boolean" ? { linked: runtime.linked } : {}),
...(typeof runtime?.connected === "boolean" ? { connected: runtime.connected } : {}),
...(typeof runtime?.restartPending === "boolean"
? { restartPending: runtime.restartPending }