refactor(channels): fix account resolution and setup isolation (#114395)

* refactor(channels): fix account resolution and setup isolation

* fix(channels): keep account resolvers safely destructurable
This commit is contained in:
Peter Steinberger
2026-07-27 03:49:37 -04:00
committed by GitHub
parent e9990b818a
commit 08b1ff73e2
40 changed files with 889 additions and 708 deletions

View File

@@ -1,7 +1,7 @@
7a8753532854ff6f6d2a0c8e6386653adce285f9f6a9b5e56e88c9ea49e79cc2 module/account-core
e7519fd1b7ff7003a6c8b9b640ffedd443a3754a10efc82877d48cd98cc6ba28 module/account-helpers
cada1ccdba113c813b6c012d4e119e2a67e23cf7015ebfd62403ed8e74d83cbd module/account-core
4c7a44c538a6856a0861300f05a5df16cb7805fbf1b22c5b772c0a6ad71c45ef module/account-helpers
cbf4e2c3088f8886a7c9ea91325a66e0f0846cea21f0b2891f36399b4811306c module/account-id
6b674e7aa4006240227c16eb5c74360f1d67639e8f5580ad72499d6ed28283c4 module/account-resolution
8e985f345f21a1c9a2b0e94304aaaad6a326bec1c1ce3b26027d2862804a366e module/account-resolution
e5e67ddf3cab38fcbf9220bc3160715897e2709d9a9ff6ff36f1ecc9453c2367 module/agent-config-primitives
74daa746deb548379d3f0d6eac3c4d082df1034c4360cc03bf51fee0f10a2e4d module/agent-harness
b80e26a4dcf2c246d01755b8da4bee272637f11ced1689fa561397c1979613e5 module/agent-harness-runtime
@@ -119,7 +119,7 @@ e576b537880f63b3a91f3608f7e84c873bce6c6a3d9a0ba98c247f46de788d25 module/secret-
c81b9702c192d574413fc1df9a57c73128652a25de205eec55d6b47549349283 module/security-runtime
31b785e74f1f8f56241b7756ef6a5d86199c5ce177cbb1c234a261866972f270 module/session-discussion
32fb6d253abf22440bc76c7a68d1f35fc0ef369b0ad738aedba9c3054a76e48e module/session-store-runtime
23cc02cbfb0a0bfa41adc8f02f5f22738781495250c8cee06c18f18bfd283afe module/setup
36affbe151431a6141664b6838e20f2d121ff210d57a3c1b4b41a8818b5c81d8 module/setup
21071e8c2ef020685aba09b5661e37e7415938ff6602053fa831ac9d58673248 module/setup-runtime
cd431f6ba8327b81438b7a63b1963120f200f5abd145fb6aa7c5c561339cb0b1 module/setup-tools
18e384ec43d9eaee52c8e286e127bda2048370e2337964a754d94b236724ca9e module/skill-commands-runtime

View File

@@ -7,7 +7,6 @@ import {
hasConfiguredAccountValue,
} from "openclaw/plugin-sdk/account-helpers";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
import { resolveNormalizedAccountEntry } from "openclaw/plugin-sdk/account-resolution-runtime";
import { resolveIntegerOption } from "openclaw/plugin-sdk/number-runtime";
import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth";
@@ -28,8 +27,11 @@ const DEFAULT_DISCUSSIONS_SECTION = "Sessions";
const {
listAccountIds: listClickClackAccountIds,
resolveDefaultAccountId: resolveDefaultClickClackAccountId,
} = createAccountListHelpers("clickclack", {
resolveAccountConfig: resolveMergedClickClackAccountConfig,
} = createAccountListHelpers<ClickClackAccountConfig>("clickclack", {
normalizeAccountId,
omitKeys: ["defaultAccount"],
nestedObjectKeys: ["discussions"],
hasImplicitDefaultAccount: (cfg) => {
const channel = cfg.channels?.clickclack;
return Boolean(
@@ -49,14 +51,7 @@ export function resolveClickClackAccountConfig(
accountId: string,
): ClickClackAccountConfig {
const channel = cfg.channels?.clickclack;
const merged = resolveMergedAccountConfig<ClickClackAccountConfig>({
channelConfig: cfg.channels?.clickclack as ClickClackAccountConfig | undefined,
accounts: channel?.accounts,
accountId,
omitKeys: ["defaultAccount"],
nestedObjectKeys: ["discussions"],
normalizeAccountId,
});
const merged = resolveMergedClickClackAccountConfig(cfg, accountId);
const account = resolveNormalizedAccountEntry(channel?.accounts, accountId, normalizeAccountId);
const accountTokenFile = account?.tokenFile?.trim();
if (accountTokenFile) {

View File

@@ -9,9 +9,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
applyAccountNameToChannelSection,
applySetupAccountConfigPatch,
migrateBaseNameToDefaultAccount,
moveSingleAccountChannelSectionToDefaultAccount,
patchScopedAccountConfig,
} from "openclaw/plugin-sdk/setup";
import { createSetupInputPresenceValidator } from "openclaw/plugin-sdk/setup-runtime";
import { resolveClickClackAccountConfig } from "./accounts.js";
@@ -160,6 +160,7 @@ export function applyClickClackSetupConfigPatch(params: {
accountId: string;
name?: string;
patch: Record<string, unknown>;
clearFields?: readonly string[];
}): OpenClawConfig {
const accountId = normalizeAccountId(params.accountId);
const scopedConfig =
@@ -183,62 +184,15 @@ export function applyClickClackSetupConfigPatch(params: {
channelKey: channel,
})
: namedConfig;
return applySetupAccountConfigPatch({
return patchScopedAccountConfig({
cfg: next,
channelKey: channel,
accountId,
patch: params.patch,
...(params.clearFields ? { clearFields: params.clearFields } : {}),
});
}
function clearClickClackSetupConfigFields(params: {
cfg: OpenClawConfig;
accountId: string;
fields: string[];
}): OpenClawConfig {
const clickclack = (params.cfg.channels as Record<string, unknown> | undefined)?.clickclack as
| (Record<string, unknown> & { accounts?: Record<string, Record<string, unknown>> })
| undefined;
if (!clickclack) {
return params.cfg;
}
const accountId = normalizeAccountId(params.accountId);
if (accountId === DEFAULT_ACCOUNT_ID) {
const nextClickClack = { ...clickclack };
for (const field of params.fields) {
delete nextClickClack[field];
}
return {
...params.cfg,
channels: {
...params.cfg.channels,
clickclack: nextClickClack,
},
} as OpenClawConfig;
}
const currentAccount = clickclack.accounts?.[accountId];
if (!currentAccount) {
return params.cfg;
}
const nextAccount = { ...currentAccount };
for (const field of params.fields) {
delete nextAccount[field];
}
return {
...params.cfg,
channels: {
...params.cfg.channels,
clickclack: {
...clickclack,
accounts: {
...clickclack.accounts,
[accountId]: nextAccount,
},
},
},
} as OpenClawConfig;
}
export function applyClickClackCredentialConfig(params: {
cfg: OpenClawConfig;
accountId: string;
@@ -253,9 +207,10 @@ export function applyClickClackCredentialConfig(params: {
: params.token !== undefined
? ["tokenFile"]
: [];
const next = applyClickClackSetupConfigPatch({
return applyClickClackSetupConfigPatch({
cfg: params.cfg,
accountId: params.accountId,
clearFields: fieldsToClear,
patch: params.useEnv
? {}
: params.tokenFile
@@ -264,11 +219,6 @@ export function applyClickClackCredentialConfig(params: {
? { token: params.token }
: {},
});
return clearClickClackSetupConfigFields({
cfg: next,
accountId: params.accountId,
fields: fieldsToClear,
});
}
export const clickClackSetupAdapter: ChannelSetupAdapter = {

View File

@@ -2,7 +2,6 @@
import {
createAccountActionGate,
createAccountListHelpers,
resolveMergedAccountConfig,
} from "openclaw/plugin-sdk/account-helpers";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
@@ -26,11 +25,16 @@ export type ResolvedDiscordAccount = {
config: DiscordAccountConfig;
};
const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("discord", {
const {
listAccountIds,
resolveDefaultAccountId,
resolveAccountConfig: resolveMergedDiscordAccountConfig,
} = createAccountListHelpers<DiscordAccountConfig>("discord", {
implicitDefaultAccount: {
channelKeys: ["token"],
envVars: ["DISCORD_BOT_TOKEN"],
},
nestedObjectKeys: ["activities", "agentComponents", "botLoopProtection"],
});
export const listDiscordAccountIds = listAccountIds;
export const resolveDefaultDiscordAccountId = resolveDefaultAccountId;
@@ -46,15 +50,7 @@ export function mergeDiscordAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): DiscordAccountConfig {
const merged = resolveMergedAccountConfig<DiscordAccountConfig>({
channelConfig: cfg.channels?.discord as DiscordAccountConfig | undefined,
accounts: cfg.channels?.discord?.accounts as
| Record<string, Partial<DiscordAccountConfig>>
| undefined,
accountId,
nestedObjectKeys: ["activities", "agentComponents", "botLoopProtection"],
});
return merged;
return resolveMergedDiscordAccountConfig(cfg, accountId);
}
export function resolveDiscordAccountAllowFrom(params: {

View File

@@ -1,11 +1,10 @@
// Googlechat plugin module implements accounts behavior.
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
import {
createAccountListHelpers,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
type OpenClawConfig,
resolveAccountEntry,
resolveMergedAccountConfig,
} from "openclaw/plugin-sdk/account-resolution";
import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared";
import { mergePairLoopGuardConfig } from "openclaw/plugin-sdk/pair-loop-guard-runtime";
@@ -47,11 +46,14 @@ const JsonRecordSchema = z.record(z.string(), z.unknown());
const {
listAccountIds: listGoogleChatAccountIds,
resolveDefaultAccountId: resolveDefaultGoogleChatAccountId,
} = createAccountListHelpers("googlechat", {
resolveAccountConfig: resolveMergedGoogleChatAccountConfig,
} = createAccountListHelpers<GoogleChatAccountConfig>("googlechat", {
implicitDefaultAccount: {
channelKeys: ["serviceAccount", "serviceAccountFile"],
envVars: [ENV_SERVICE_ACCOUNT, ENV_SERVICE_ACCOUNT_FILE],
},
omitKeys: ["defaultAccount"],
nestedObjectKeys: ["botLoopProtection"],
});
export { listGoogleChatAccountIds, resolveDefaultGoogleChatAccountId };
@@ -60,13 +62,7 @@ function mergeGoogleChatAccountConfig(
accountId: string,
): GoogleChatAccountConfig {
const raw = cfg.channels?.["googlechat"] ?? {};
const base = resolveMergedAccountConfig<GoogleChatAccountConfig>({
channelConfig: raw as GoogleChatAccountConfig,
accounts: raw.accounts as Record<string, Partial<GoogleChatAccountConfig>> | undefined,
accountId,
omitKeys: ["defaultAccount"],
nestedObjectKeys: ["botLoopProtection"],
});
const base = resolveMergedGoogleChatAccountConfig(cfg, accountId);
const defaultAccountConfig = resolveAccountEntry(raw.accounts, DEFAULT_ACCOUNT_ID) ?? {};
if (accountId === DEFAULT_ACCOUNT_ID) {
return base;

View File

@@ -1,11 +1,7 @@
import { statSync } from "node:fs";
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import {
createAccountListHelpers,
normalizeAccountId,
resolveMergedAccountConfig,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-resolution";
import { normalizeAccountId, type OpenClawConfig } from "openclaw/plugin-sdk/account-resolution";
// Imessage plugin module implements accounts behavior.
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
@@ -21,7 +17,11 @@ export type ResolvedIMessageAccount = {
configured: boolean;
};
const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("imessage", {
const {
listAccountIds,
resolveDefaultAccountId,
resolveAccountConfig: resolveMergedIMessageAccountConfig,
} = createAccountListHelpers<IMessageAccountConfig>("imessage", {
implicitDefaultAccount: {
channelKeys: ["cliPath", "dbPath"],
},
@@ -77,13 +77,7 @@ function mergeIMessageStreamingConfig(
function mergeIMessageAccountConfig(cfg: OpenClawConfig, accountId: string): IMessageAccountConfig {
const accountConfig = resolveIMessageAccountConfig(cfg, accountId);
const merged = resolveMergedAccountConfig<IMessageAccountConfig>({
channelConfig: cfg.channels?.imessage as IMessageAccountConfig | undefined,
accounts: cfg.channels?.imessage?.accounts as
| Record<string, Partial<IMessageAccountConfig>>
| undefined,
accountId,
});
const merged = resolveMergedIMessageAccountConfig(cfg, accountId);
const streaming = mergeIMessageStreamingConfig(
(cfg.channels?.imessage as Record<string, unknown> | undefined)?.streaming,
(accountConfig as Record<string, unknown> | undefined)?.streaming,

View File

@@ -1,7 +1,7 @@
// Irc plugin module implements accounts behavior.
import { resolveAccountWithDefaultFallback } from "openclaw/plugin-sdk/account-core";
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
import { parseOptionalDelimitedEntries } from "openclaw/plugin-sdk/channel-core";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime";
@@ -55,28 +55,22 @@ function parseIntEnv(value?: string): number | undefined {
return parsed;
}
const { listAccountIds: listIrcAccountIds, resolveDefaultAccountId: resolveDefaultIrcAccountId } =
createAccountListHelpers("irc", {
normalizeAccountId,
hasImplicitDefaultAccount: (cfg) =>
Boolean(
(cfg.channels?.irc?.host?.trim() || process.env.IRC_HOST?.trim()) &&
(cfg.channels?.irc?.nick?.trim() || process.env.IRC_NICK?.trim()),
),
});
const {
listAccountIds: listIrcAccountIds,
resolveDefaultAccountId: resolveDefaultIrcAccountId,
resolveAccountConfig: mergeIrcAccountConfig,
} = createAccountListHelpers<IrcAccountConfig>("irc", {
normalizeAccountId,
omitKeys: ["defaultAccount"],
nestedObjectKeys: ["nickserv"],
hasImplicitDefaultAccount: (cfg) =>
Boolean(
(cfg.channels?.irc?.host?.trim() || process.env.IRC_HOST?.trim()) &&
(cfg.channels?.irc?.nick?.trim() || process.env.IRC_NICK?.trim()),
),
});
export { listIrcAccountIds, resolveDefaultIrcAccountId };
function mergeIrcAccountConfig(cfg: CoreConfig, accountId: string): IrcAccountConfig {
return resolveMergedAccountConfig<IrcAccountConfig>({
channelConfig: cfg.channels?.irc as IrcAccountConfig | undefined,
accounts: cfg.channels?.irc?.accounts as Record<string, Partial<IrcAccountConfig>> | undefined,
accountId,
omitKeys: ["defaultAccount"],
normalizeAccountId,
nestedObjectKeys: ["nickserv"],
});
}
function resolvePassword(accountId: string, merged: IrcAccountConfig) {
if (accountId === DEFAULT_ACCOUNT_ID) {
const envPassword = process.env.IRC_PASSWORD?.trim();
@@ -162,7 +156,6 @@ export function resolveIrcAccount(params: {
cfg: CoreConfig;
accountId?: string | null;
}): ResolvedIrcAccount {
const hasExplicitAccountId = Boolean(params.accountId?.trim());
const baseEnabled = params.cfg.channels?.irc?.enabled !== false;
const resolve = (accountId: string) => {
@@ -249,24 +242,13 @@ export function resolveIrcAccount(params: {
} satisfies ResolvedIrcAccount;
};
const normalized = normalizeAccountId(params.accountId);
const primary = resolve(normalized);
if (hasExplicitAccountId) {
return primary;
}
if (primary.configured) {
return primary;
}
const fallbackId = resolveDefaultIrcAccountId(params.cfg);
if (fallbackId === primary.accountId) {
return primary;
}
const fallback = resolve(fallbackId);
if (!fallback.configured) {
return primary;
}
return fallback;
return resolveAccountWithDefaultFallback({
accountId: params.accountId,
normalizeAccountId,
resolvePrimary: resolve,
hasCredential: (account) => account.configured,
resolveDefaultAccountId: () => resolveDefaultIrcAccountId(params.cfg),
});
}
export function listEnabledIrcAccounts(cfg: CoreConfig): ResolvedIrcAccount[] {

View File

@@ -5,7 +5,12 @@ import path from "node:path";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveLineAccount, resolveDefaultLineAccountId, normalizeAccountId } from "./accounts.js";
import {
listLineAccountIds,
normalizeAccountId,
resolveDefaultLineAccountId,
resolveLineAccount,
} from "./accounts.js";
describe("LINE accounts", () => {
const tempDirs: string[] = [];
@@ -391,6 +396,24 @@ describe("LINE accounts", () => {
});
});
describe("listLineAccountIds", () => {
it("keeps unconfigured channels empty", () => {
expect(listLineAccountIds({})).toEqual([]);
});
it("preserves configured named-account insertion order", () => {
expect(
listLineAccountIds({
channels: {
line: {
accounts: { work: {}, alerts: {} },
},
},
}),
).toEqual(["work", "alerts"]);
});
});
describe("resolveDefaultLineAccountId", () => {
it.each([
{

View File

@@ -1,11 +1,15 @@
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
// Line plugin module implements accounts behavior.
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId as normalizeSharedAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/account-resolution";
import { resolveAccountEntry } from "openclaw/plugin-sdk/account-resolution";
import {
resolveAccountEntry,
resolveListedDefaultAccountId,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-resolution";
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime";
import type {
LineAccountConfig,
@@ -16,6 +20,12 @@ import type {
ResolvedLineAccount,
} from "./types.js";
const { resolveAccountConfig: resolveMergedLineAccountConfig } = createAccountListHelpers<
Record<string, unknown> & LineConfig
>("line", {
omitKeys: ["defaultAccount"],
});
function readCredentialFile(filePath: string, configPath: string) {
return tryReadSecretFileSync(
filePath,
@@ -162,18 +172,10 @@ export function resolveLineAccount(params: {
accountConfig,
});
const {
accounts: _ignoredAccounts,
defaultAccount: _ignoredDefaultAccount,
...lineBase
} = (lineConfig ?? {}) as LineConfig & {
accounts?: unknown;
defaultAccount?: unknown;
};
const mergedConfig: LineConfig & LineAccountConfig = {
...lineBase,
...accountConfig,
};
const mergedConfig: LineConfig & LineAccountConfig = resolveMergedLineAccountConfig(
cfg,
accountId,
);
const baseEnabled = lineConfig?.enabled !== false;
const accountEnabled = accountConfig?.enabled !== false;
@@ -226,20 +228,13 @@ export function listLineAccountIds(cfg: OpenClawConfig): string[] {
}
export function resolveDefaultLineAccountId(cfg: OpenClawConfig): string {
const preferred = normalizeOptionalAccountId(
(cfg.channels?.line as LineConfig | undefined)?.defaultAccount,
);
if (
preferred &&
listLineAccountIds(cfg).some((accountId) => normalizeSharedAccountId(accountId) === preferred)
) {
return preferred;
}
const ids = listLineAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID;
return resolveListedDefaultAccountId({
accountIds: listLineAccountIds(cfg),
configuredDefaultAccountId: normalizeOptionalAccountId(
(cfg.channels?.line as LineConfig | undefined)?.defaultAccount,
),
normalizeListedAccountId: normalizeSharedAccountId,
});
}
export function normalizeAccountId(accountId: string | undefined): string {

View File

@@ -2,7 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/setup";
// Guards the shipped `--token` alias: released CLIs configured LINE through the
// shared token envelope switch, which must keep writing channelAccessToken.
import { describe, expect, it } from "vitest";
import { lineSetupAdapter } from "./setup-core.js";
import { lineSetupAdapter, patchLineAccountConfig } from "./setup-core.js";
type LineChannelConfig = { channelAccessToken?: string };
@@ -28,3 +28,60 @@ describe("line setup token alias", () => {
expect(applied.channelAccessToken).toBe("explicit-token");
});
});
describe("LINE scoped setup config", () => {
it("explicitly re-enables an existing disabled named account", () => {
const cfg = patchLineAccountConfig({
cfg: {
channels: {
line: {
enabled: false,
accounts: {
work: {
enabled: false,
channelAccessToken: "old-token",
},
},
},
},
},
accountId: "work",
enabled: true,
patch: { channelAccessToken: "new-token" },
});
expect(cfg.channels?.line?.enabled).toBe(true);
expect(cfg.channels?.line?.accounts?.work).toMatchObject({
enabled: true,
channelAccessToken: "new-token",
});
});
it("clears only the selected named-account credential before applying its replacement", () => {
const cfg = patchLineAccountConfig({
cfg: {
channels: {
line: {
channelAccessToken: "default-token",
accounts: {
work: {
channelAccessToken: "old-token",
tokenFile: "/run/secrets/line-work",
},
},
},
},
},
accountId: "work",
enabled: true,
clearFields: ["channelAccessToken", "tokenFile"],
patch: { channelAccessToken: "new-token" },
});
expect(cfg.channels?.line?.channelAccessToken).toBe("default-token");
expect(cfg.channels?.line?.accounts?.work).toEqual({
enabled: true,
channelAccessToken: "new-token",
});
});
});

View File

@@ -5,14 +5,16 @@ import type {
ChannelSetupInput,
OpenClawConfig,
} from "openclaw/plugin-sdk/setup";
import { createSetupInputPresenceValidator } from "openclaw/plugin-sdk/setup";
import {
createSetupInputPresenceValidator,
patchScopedAccountConfig,
} from "openclaw/plugin-sdk/setup";
import { hasLineCredentials, parseLineAllowFromId } from "./account-helpers.js";
import {
DEFAULT_ACCOUNT_ID,
listLineAccountIds,
normalizeAccountId,
resolveLineAccount,
type LineConfig,
} from "./setup-runtime-api.js";
type LineSetupInput = ChannelSetupInput & {
@@ -28,53 +30,19 @@ export function patchLineAccountConfig(params: {
clearFields?: string[];
enabled?: boolean;
}): OpenClawConfig {
const accountId = normalizeAccountId(params.accountId);
const lineConfig = (params.cfg.channels?.line ?? {}) as LineConfig;
const clearFields = params.clearFields ?? [];
if (accountId === DEFAULT_ACCOUNT_ID) {
const nextLine = { ...lineConfig } as Record<string, unknown>;
for (const field of clearFields) {
delete nextLine[field];
}
return {
...params.cfg,
channels: {
...params.cfg.channels,
line: {
...nextLine,
...(params.enabled ? { enabled: true } : {}),
...params.patch,
},
},
};
}
const nextAccount = {
...lineConfig.accounts?.[accountId],
} as Record<string, unknown>;
for (const field of clearFields) {
delete nextAccount[field];
}
return {
...params.cfg,
channels: {
...params.cfg.channels,
line: {
...lineConfig,
...(params.enabled ? { enabled: true } : {}),
accounts: {
...lineConfig.accounts,
[accountId]: {
...nextAccount,
...(params.enabled ? { enabled: true } : {}),
...params.patch,
},
},
},
return patchScopedAccountConfig({
cfg: params.cfg,
channelKey: "line",
accountId: params.accountId,
patch: params.patch,
accountPatch: {
...(params.enabled ? { enabled: true } : {}),
...params.patch,
},
};
...(params.clearFields ? { clearFields: params.clearFields } : {}),
ensureChannelEnabled: Boolean(params.enabled),
ensureAccountEnabled: false,
});
}
export function isLineConfigured(cfg: OpenClawConfig, accountId: string): boolean {

View File

@@ -7,4 +7,3 @@ export {
} from "openclaw/plugin-sdk/setup";
export type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
export { listLineAccountIds, normalizeAccountId, resolveLineAccount } from "./accounts.js";
export type { LineConfig } from "./types.js";

View File

@@ -4,7 +4,6 @@ import {
hasConfiguredAccountValue,
} from "openclaw/plugin-sdk/account-helpers";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
import {
resolveChannelStreamingBlockCoalesce,
resolveChannelStreamingBlockEnabled,
@@ -47,7 +46,13 @@ export type ResolvedMattermostAccount = {
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
};
const mattermostAccountHelpers = createAccountListHelpers("mattermost", {
const {
listAccountIds: listMattermostAccountIds,
resolveDefaultAccountId: resolveDefaultMattermostAccountId,
resolveAccountConfig: mergeMattermostAccountConfig,
} = createAccountListHelpers<MattermostAccountConfig>("mattermost", {
omitKeys: ["defaultAccount"],
nestedObjectKeys: ["commands"],
hasImplicitDefaultAccount: (cfg) => {
const mattermost = cfg.channels?.mattermost;
return Boolean(
@@ -56,29 +61,7 @@ const mattermostAccountHelpers = createAccountListHelpers("mattermost", {
);
},
});
export function listMattermostAccountIds(cfg: OpenClawConfig): string[] {
return mattermostAccountHelpers.listAccountIds(cfg);
}
export function resolveDefaultMattermostAccountId(cfg: OpenClawConfig): string {
return mattermostAccountHelpers.resolveDefaultAccountId(cfg);
}
function mergeMattermostAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): MattermostAccountConfig {
return resolveMergedAccountConfig<MattermostAccountConfig>({
channelConfig: cfg.channels?.mattermost as MattermostAccountConfig | undefined,
accounts: cfg.channels?.mattermost?.accounts as
| Record<string, Partial<MattermostAccountConfig>>
| undefined,
accountId,
omitKeys: ["defaultAccount"],
nestedObjectKeys: ["commands"],
});
}
export { listMattermostAccountIds, resolveDefaultMattermostAccountId };
function resolveMattermostRequireMention(config: MattermostAccountConfig): boolean | undefined {
if (config.chatmode === "oncall") {

View File

@@ -1,12 +1,11 @@
// Nextcloud Talk plugin module implements accounts behavior.
import {
createAccountListHelpers,
DEFAULT_ACCOUNT_ID,
hasConfiguredAccountValue,
normalizeAccountId,
resolveAccountWithDefaultFallback,
resolveMergedAccountConfig,
} from "openclaw/plugin-sdk/account-core";
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime";
import {
normalizeLowercaseStringOrEmpty,
@@ -46,8 +45,10 @@ export type ResolvedNextcloudTalkAccount = {
const {
listAccountIds: listNextcloudTalkAccountIdsInternal,
resolveDefaultAccountId: resolveDefaultNextcloudTalkAccountId,
} = createAccountListHelpers("nextcloud-talk", {
resolveAccountConfig: mergeNextcloudTalkAccountConfig,
} = createAccountListHelpers<NextcloudTalkAccountConfig>("nextcloud-talk", {
normalizeAccountId,
omitKeys: ["defaultAccount"],
hasImplicitDefaultAccount: (cfg) => {
const channel = cfg.channels?.["nextcloud-talk"];
return Boolean(
@@ -66,21 +67,6 @@ export function listNextcloudTalkAccountIds(cfg: CoreConfig): string[] {
return ids;
}
function mergeNextcloudTalkAccountConfig(
cfg: CoreConfig,
accountId: string,
): NextcloudTalkAccountConfig {
return resolveMergedAccountConfig<NextcloudTalkAccountConfig>({
channelConfig: cfg.channels?.["nextcloud-talk"] as NextcloudTalkAccountConfig | undefined,
accounts: cfg.channels?.["nextcloud-talk"]?.accounts as
| Record<string, Partial<NextcloudTalkAccountConfig>>
| undefined,
accountId,
omitKeys: ["defaultAccount"],
normalizeAccountId,
});
}
function resolveNextcloudTalkSecret(
cfg: CoreConfig,
opts: { accountId?: string },

View File

@@ -6,7 +6,7 @@ import {
type ChannelSetupInput,
} from "openclaw/plugin-sdk/channel-setup";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import {
applyAccountNameToChannelSection,
patchScopedAccountConfig,
@@ -35,7 +35,6 @@ type NextcloudSetupInput = ChannelSetupInput & {
url?: string;
password?: string;
};
type NextcloudTalkSection = NonNullable<CoreConfig["channels"]>["nextcloud-talk"];
function readOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
@@ -59,63 +58,17 @@ export function setNextcloudTalkAccountConfig(
cfg: CoreConfig,
accountId: string,
updates: Record<string, unknown>,
clearFields?: readonly string[],
): CoreConfig {
return patchScopedAccountConfig({
cfg,
channelKey: channel,
accountId,
patch: updates,
...(clearFields ? { clearFields } : {}),
}) as CoreConfig;
}
export function clearNextcloudTalkAccountFields(
cfg: CoreConfig,
accountId: string,
fields: string[],
): CoreConfig {
const section = cfg.channels?.["nextcloud-talk"];
if (!section) {
return cfg;
}
if (accountId === DEFAULT_ACCOUNT_ID) {
const nextSection = { ...section } as Record<string, unknown>;
for (const field of fields) {
delete nextSection[field];
}
return {
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk": nextSection as NextcloudTalkSection,
},
} as CoreConfig;
}
const currentAccount = section.accounts?.[accountId];
if (!currentAccount) {
return cfg;
}
const nextAccount = { ...currentAccount } as Record<string, unknown>;
for (const field of fields) {
delete nextAccount[field];
}
return {
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk": {
...section,
accounts: {
...section.accounts,
[accountId]: nextAccount as NonNullable<typeof section.accounts>[string],
},
},
},
} as CoreConfig;
}
async function promptNextcloudTalkAllowFrom(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
@@ -231,12 +184,6 @@ export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = {
accountId,
name: setupInput.name,
});
const next = setupInput.useEnv
? clearNextcloudTalkAccountFields(namedConfig as CoreConfig, accountId, [
"botSecret",
"botSecretFile",
])
: namedConfig;
const patch = {
baseUrl: normalizeNextcloudTalkBaseUrl(setupInput.baseUrl),
...(setupInput.useEnv
@@ -247,7 +194,12 @@ export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = {
? { botSecret: setupInput.secret }
: {}),
};
return setNextcloudTalkAccountConfig(next as CoreConfig, accountId, patch);
return setNextcloudTalkAccountConfig(
namedConfig as CoreConfig,
accountId,
patch,
setupInput.useEnv ? ["botSecret", "botSecretFile"] : undefined,
);
},
};

View File

@@ -13,7 +13,6 @@ import {
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveNextcloudTalkAccount } from "./accounts.js";
import {
clearNextcloudTalkAccountFields,
nextcloudTalkDmPolicy,
normalizeNextcloudTalkBaseUrl,
setNextcloudTalkAccountConfig,
@@ -102,10 +101,8 @@ export const nextcloudTalkSetupWizard: ChannelSetupWizard = {
accountId === DEFAULT_ACCOUNT_ID
? normalizeOptionalString(process.env.NEXTCLOUD_TALK_BOT_SECRET)
: undefined,
patchAccount: ({ cfg, accountId, patch, clearFields }) => {
const cleared = clearNextcloudTalkAccountFields(cfg as CoreConfig, accountId, clearFields);
return setNextcloudTalkAccountConfig(cleared, accountId, patch);
},
patchAccount: ({ cfg, accountId, patch, clearFields }) =>
setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, patch, clearFields),
useEnv: {
clearFields: ["botSecret", "botSecretFile"],
patch: (account) => ({ baseUrl: account.baseUrl }),
@@ -135,11 +132,7 @@ export const nextcloudTalkSetupWizard: ChannelSetupWizard = {
),
shouldPrompt: ({ credentialValues }) => credentialValues[CONFIGURE_API_FLAG] === "1",
patchAccount: ({ cfg, accountId, patch, clearFields }) =>
setNextcloudTalkAccountConfig(
clearNextcloudTalkAccountFields(cfg as CoreConfig, accountId, clearFields),
accountId,
patch,
),
setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, patch, clearFields),
set: { clearFields: ["apiPassword", "apiPasswordFile"] },
}),
],

View File

@@ -7,7 +7,6 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
import { describe, expect, it } from "vitest";
import { resolveNextcloudTalkAccount } from "./accounts.js";
import {
clearNextcloudTalkAccountFields,
nextcloudTalkDmPolicy,
nextcloudTalkSetupAdapter,
normalizeNextcloudTalkBaseUrl,
@@ -76,9 +75,13 @@ describe("nextcloud talk setup", () => {
},
});
expect(clearNextcloudTalkAccountFields(cfg, DEFAULT_ACCOUNT_ID, ["botSecret"])).toEqual({
const clearedDefault = setNextcloudTalkAccountConfig(cfg, DEFAULT_ACCOUNT_ID, {}, [
"botSecret",
]);
expect(clearedDefault).toEqual({
channels: {
"nextcloud-talk": {
enabled: true,
baseUrl: "https://cloud.example.com",
accounts: {
work: {
@@ -90,17 +93,17 @@ describe("nextcloud talk setup", () => {
},
},
});
expect(
clearNextcloudTalkAccountFields(cfg, DEFAULT_ACCOUNT_ID, ["botSecret"]),
).not.toHaveProperty(["channels", "nextcloud-talk", "botSecret"]);
expect(clearedDefault).not.toHaveProperty(["channels", "nextcloud-talk", "botSecret"]);
expect(clearNextcloudTalkAccountFields(cfg, "work", ["botSecret", "botSecretFile"])).toEqual({
expect(setNextcloudTalkAccountConfig(cfg, "work", {}, ["botSecret", "botSecretFile"])).toEqual({
channels: {
"nextcloud-talk": {
enabled: true,
baseUrl: "https://cloud.example.com",
botSecret: "top-secret",
accounts: {
work: {
enabled: true,
apiPassword: "api-secret",
},
},
@@ -385,6 +388,44 @@ describe("nextcloud talk setup", () => {
expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecret");
expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecretFile");
});
it("replaces only the selected account's API password when the wizard sets a credential", async () => {
const credential = expectDefined(
nextcloudTalkSetupWizard.credentials[1],
"Nextcloud Talk API credential",
);
const next = await credential.applySet?.({
cfg: {
channels: {
"nextcloud-talk": {
botSecret: "root-secret",
accounts: {
work: {
baseUrl: "https://cloud.example.com",
botSecret: "work-secret",
apiUser: "bot",
apiPassword: "old-password",
apiPasswordFile: "/run/secrets/old-api-password",
},
},
},
},
},
accountId: "work",
credentialValues: {},
value: "new-password",
resolvedValue: "new-password",
});
expect(next?.channels?.["nextcloud-talk"]?.botSecret).toBe("root-secret");
expect(next?.channels?.["nextcloud-talk"]?.accounts?.work).toEqual({
enabled: true,
baseUrl: "https://cloud.example.com",
botSecret: "work-secret",
apiUser: "bot",
apiPassword: "new-password",
});
});
});
describe("resolveNextcloudTalkAccount", () => {

View File

@@ -1,13 +1,10 @@
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
// Nostr type declarations define plugin contracts.
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import {
listCombinedAccountIds,
resolveListedDefaultAccountId,
} from "openclaw/plugin-sdk/account-resolution";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { normalizeSecretInputString, type SecretInput } from "openclaw/plugin-sdk/secret-input";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -38,38 +35,20 @@ export interface ResolvedNostrAccount {
config: NostrAccountConfig;
}
function resolveConfiguredDefaultNostrAccountId(cfg: OpenClawConfig): string | undefined {
const nostrCfg = (cfg.channels as Record<string, unknown> | undefined)?.nostr as
| NostrAccountConfig
| undefined;
return normalizeOptionalAccountId(nostrCfg?.defaultAccount);
}
const {
listAccountIds: listNostrAccountIds,
resolveDefaultAccountId: resolveDefaultNostrAccountId,
} = createAccountListHelpers("nostr", {
fallbackAccountIdWhenEmpty: false,
resolveImplicitAccountId: (cfg) => {
const account = cfg.channels?.nostr as NostrAccountConfig | undefined;
return normalizeSecretInputString(account?.privateKey)
? (normalizeOptionalAccountId(account?.defaultAccount) ?? DEFAULT_ACCOUNT_ID)
: undefined;
},
});
/**
* List all configured Nostr account IDs
*/
export function listNostrAccountIds(cfg: OpenClawConfig): string[] {
const nostrCfg = (cfg.channels as Record<string, unknown> | undefined)?.nostr as
| NostrAccountConfig
| undefined;
const privateKey = normalizeSecretInputString(nostrCfg?.privateKey);
return listCombinedAccountIds({
configuredAccountIds: [],
implicitAccountId: privateKey
? (resolveConfiguredDefaultNostrAccountId(cfg) ?? DEFAULT_ACCOUNT_ID)
: undefined,
});
}
/**
* Get the default account ID
*/
export function resolveDefaultNostrAccountId(cfg: OpenClawConfig): string {
return resolveListedDefaultAccountId({
accountIds: listNostrAccountIds(cfg),
configuredDefaultAccountId: resolveConfiguredDefaultNostrAccountId(cfg),
});
}
export { listNostrAccountIds, resolveDefaultNostrAccountId };
/**
* Resolve a Nostr account from config

View File

@@ -1,7 +1,6 @@
// Qa Channel plugin module implements accounts behavior.
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { CoreConfig, QaChannelAccountConfig, ResolvedQaChannelAccount } from "./types.js";
@@ -10,8 +9,10 @@ const DEFAULT_POLL_TIMEOUT_MS = 1_000;
const {
listAccountIds: listQaChannelAccountIds,
resolveDefaultAccountId: resolveDefaultQaChannelAccountId,
} = createAccountListHelpers("qa-channel", {
resolveAccountConfig: resolveMergedQaAccountConfig,
} = createAccountListHelpers<QaChannelAccountConfig>("qa-channel", {
normalizeAccountId,
omitKeys: ["defaultAccount"],
implicitDefaultAccount: {
channelKeys: ["baseUrl"],
},
@@ -19,16 +20,6 @@ const {
export { listQaChannelAccountIds, resolveDefaultQaChannelAccountId };
function resolveMergedQaAccountConfig(cfg: CoreConfig, accountId: string): QaChannelAccountConfig {
return resolveMergedAccountConfig<QaChannelAccountConfig>({
channelConfig: cfg.channels?.["qa-channel"] as QaChannelAccountConfig | undefined,
accounts: cfg.channels?.["qa-channel"]?.accounts,
accountId,
omitKeys: ["defaultAccount"],
normalizeAccountId,
});
}
export function resolveQaChannelAccount(params: {
cfg: CoreConfig;
accountId?: string | null;

View File

@@ -1,11 +1,8 @@
import { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup";
// Qqbot helper module supports config shared behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
applyAccountNameToChannelSection,
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
} from "openclaw/plugin-sdk/core";
import { applyAccountNameToChannelSection } from "openclaw/plugin-sdk/core";
import type { ChannelSetupInput } from "openclaw/plugin-sdk/setup";
import {
describeAccount as engineDescribeAccount,
@@ -61,46 +58,19 @@ function describeQQBotAccount(account: ResolvedQQBotAccount | undefined) {
return engineDescribeAccount(account as never);
}
function formatQQBotAllowFrom(params: {
allowFrom: Array<string | number> | undefined | null;
}): string[] {
return engineFormatAllowFrom(params.allowFrom);
}
export const qqbotConfigAdapter = {
listAccountIds: (cfg: OpenClawConfig) => listQQBotAccountIds(cfg),
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }),
defaultAccountId: (cfg: OpenClawConfig) => resolveDefaultQQBotAccountId(cfg),
setAccountEnabled: ({
cfg,
accountId,
enabled,
}: {
cfg: OpenClawConfig;
accountId: string;
enabled: boolean;
}) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "qqbot",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "qqbot",
accountId,
clearBaseFields: ["appId", "clientSecret", "clientSecretFile", "name"],
}),
...createScopedChannelConfigAdapter<ResolvedQQBotAccount>({
sectionKey: "qqbot",
listAccountIds: listQQBotAccountIds,
resolveAccount: (cfg, accountId) =>
resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }),
defaultAccountId: resolveDefaultQQBotAccountId,
clearBaseFields: ["appId", "clientSecret", "clientSecretFile", "name"],
resolveAllowFrom: (account) => account.config.allowFrom,
formatAllowFrom: engineFormatAllowFrom,
}),
isConfigured: isQQBotConfigured,
describeAccount: describeQQBotAccount,
resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) =>
resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }).config?.allowFrom,
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> | undefined | null }) =>
formatQQBotAllowFrom({ allowFrom }),
};
export const qqbotSetupAdapterShared = {

View File

@@ -9,7 +9,7 @@ import {
} from "openclaw/plugin-sdk/json-schema-runtime";
import { DEFAULT_SECRET_FILE_MAX_BYTES } from "openclaw/plugin-sdk/secret-file-runtime";
import { describe, expect, it } from "vitest";
import { qqbotSetupAdapterShared } from "./bridge/config-shared.js";
import { qqbotConfigAdapter, qqbotSetupAdapterShared } from "./bridge/config-shared.js";
import {
DEFAULT_ACCOUNT_ID,
resolveDefaultQQBotAccountId,
@@ -122,6 +122,90 @@ describe("qqbot config", () => {
expect(resolveDefaultQQBotAccountId(cfg)).toBe("bot2");
});
it("keeps account mutations and allowlists scoped to the selected QQ Bot account", () => {
const cfg = {
channels: {
qqbot: {
appId: "default-app",
clientSecret: "default-secret",
accounts: {
work: {
appId: "work-app",
clientSecret: "work-secret",
allowFrom: ["qqbot:work-user"],
},
},
},
},
} as OpenClawConfig;
expect(qqbotConfigAdapter.resolveAccount(cfg, "work")).toMatchObject({
accountId: "work",
appId: "work-app",
clientSecret: "work-secret",
});
expect(qqbotConfigAdapter.resolveAllowFrom?.({ cfg, accountId: "work" })).toEqual([
"qqbot:work-user",
]);
expect(
qqbotConfigAdapter.formatAllowFrom?.({
cfg,
accountId: "work",
allowFrom: ["qqbot:work-user", 42],
}),
).toEqual(["WORK-USER", "42"]);
expect(
qqbotConfigAdapter.setAccountEnabled?.({
cfg,
accountId: "work",
enabled: false,
}),
).toMatchObject({
channels: {
qqbot: {
appId: "default-app",
accounts: { work: { appId: "work-app", enabled: false } },
},
},
});
});
it("clears default QQ Bot credentials without deleting named accounts", () => {
const cfg = {
channels: {
qqbot: {
appId: "default-app",
clientSecret: "default-secret",
clientSecretFile: "/tmp/default-qq-secret",
name: "Default bot",
accounts: {
work: {
appId: "work-app",
clientSecret: "work-secret",
},
},
},
},
} as OpenClawConfig;
expect(qqbotConfigAdapter.deleteAccount?.({ cfg, accountId: "default" })).toMatchObject({
channels: {
qqbot: {
appId: undefined,
clientSecret: undefined,
clientSecretFile: undefined,
name: undefined,
accounts: {
work: {
appId: "work-app",
clientSecret: "work-secret",
},
},
},
},
});
});
it("accepts SecretRef-backed credentials in the runtime schema", () => {
const parsed = requireRuntimeSchema().safeParse({
defaultAccount: "bot2",

View File

@@ -1,8 +1,5 @@
// Raft account resolution keeps CLI profiles scoped to their channel account.
import {
createAccountListHelpers,
resolveMergedAccountConfig,
} from "openclaw/plugin-sdk/account-helpers";
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -25,8 +22,13 @@ export type ResolvedRaftAccount = {
profile: string | null;
};
const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers(RAFT_CHANNEL_ID, {
const {
listAccountIds,
resolveDefaultAccountId,
resolveAccountConfig: resolveMergedRaftAccountConfig,
} = createAccountListHelpers<RaftAccountConfig>(RAFT_CHANNEL_ID, {
normalizeAccountId,
omitKeys: ["defaultAccount"],
implicitDefaultAccount: {
channelKeys: ["profile"],
envVars: ["RAFT_PROFILE"],
@@ -46,13 +48,7 @@ export function resolveRaftAccount(params: {
}): ResolvedRaftAccount {
const accountId = normalizeAccountId(params.accountId ?? resolveDefaultRaftAccountId(params.cfg));
const channel = resolveRaftConfig(params.cfg);
const merged = resolveMergedAccountConfig<RaftAccountConfig>({
channelConfig: channel,
accounts: channel?.accounts,
accountId,
omitKeys: ["defaultAccount"],
normalizeAccountId,
});
const merged = resolveMergedRaftAccountConfig(params.cfg, accountId);
const configuredProfile = normalizeOptionalString(merged.profile);
const envProfile =
accountId === DEFAULT_ACCOUNT_ID

View File

@@ -1,12 +1,10 @@
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
// Sms plugin module implements accounts behavior.
import { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id";
import {
DEFAULT_ACCOUNT_ID,
hasConfiguredAccountValue,
listCombinedAccountIds,
resolveAccountEntry,
resolveListedDefaultAccountId,
resolveMergedAccountConfig,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-resolution";
import { parseStrictInteger } from "openclaw/plugin-sdk/number-runtime";
@@ -68,21 +66,17 @@ function hasBaseAccount(channelCfg: SmsChannelConfig | undefined): boolean {
);
}
export function listSmsAccountIds(cfg: OpenClawConfig): string[] {
const channelCfg = getChannelConfig(cfg);
return listCombinedAccountIds({
configuredAccountIds: Object.keys(channelCfg?.accounts ?? {}),
implicitAccountId: hasBaseAccount(channelCfg) ? DEFAULT_ACCOUNT_ID : undefined,
});
}
const {
listAccountIds: listSmsAccountIds,
resolveDefaultAccountId: resolveDefaultSmsAccountId,
resolveAccountConfig: resolveMergedSmsAccountConfig,
} = createAccountListHelpers<Record<string, unknown> & SmsChannelConfig>(CHANNEL_ID, {
fallbackAccountIdWhenEmpty: false,
hasImplicitDefaultAccount: (cfg) => hasBaseAccount(getChannelConfig(cfg)),
omitKeys: ["defaultAccount"],
});
export function resolveDefaultSmsAccountId(cfg: OpenClawConfig): string {
const channelCfg = getChannelConfig(cfg);
return resolveListedDefaultAccountId({
accountIds: listSmsAccountIds(cfg),
configuredDefaultAccountId: normalizeOptionalAccountId(channelCfg?.defaultAccount),
});
}
export { listSmsAccountIds, resolveDefaultSmsAccountId };
export function resolveSmsAccount(
cfg: OpenClawConfig,
@@ -91,23 +85,7 @@ export function resolveSmsAccount(
const channelCfg = getChannelConfig(cfg) ?? {};
const id = normalizeOptionalAccountId(accountId) ?? resolveDefaultSmsAccountId(cfg);
const accountConfig = resolveAccountEntry(channelCfg.accounts, id);
const channelConfig: Record<string, unknown> & SmsChannelConfig = { ...channelCfg };
const accountEntries:
| Record<string, Partial<Record<string, unknown> & SmsChannelConfig>>
| undefined = channelCfg.accounts
? Object.fromEntries(
Object.entries(channelCfg.accounts).map(([accountKey, account]) => [
accountKey,
{ ...account },
]),
)
: undefined;
const merged = resolveMergedAccountConfig<Record<string, unknown> & SmsChannelConfig>({
channelConfig,
accounts: accountEntries,
accountId: id,
omitKeys: ["defaultAccount"],
});
const merged = resolveMergedSmsAccountConfig(cfg, id);
const useEnvFallbacks = id === DEFAULT_ACCOUNT_ID;
const envAccountSid = useEnvFallbacks ? process.env.TWILIO_ACCOUNT_SID : undefined;

View File

@@ -3,11 +3,10 @@
* merges per-account overrides, falls back to environment variables.
*/
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
import {
DEFAULT_ACCOUNT_ID,
hasConfiguredAccountValue,
listCombinedAccountIds,
resolveMergedAccountConfig,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-resolution";
import { resolveDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
@@ -27,12 +26,20 @@ function getChannelConfig(cfg: OpenClawConfig): SynologyChatChannelConfig | unde
return cfg?.channels?.["synology-chat"] as SynologyChatChannelConfig | undefined;
}
function resolveImplicitAccountId(channelCfg: SynologyChatChannelConfig): string | undefined {
return hasConfiguredAccountValue(channelCfg.token) ||
hasConfiguredAccountValue(process.env.SYNOLOGY_CHAT_TOKEN)
? DEFAULT_ACCOUNT_ID
: undefined;
}
const { listAccountIds, resolveAccountConfig: resolveMergedSynologyChatAccountConfig } =
createAccountListHelpers<Record<string, unknown> & SynologyChatChannelConfig>("synology-chat", {
fallbackAccountIdWhenEmpty: false,
hasImplicitDefaultAccount: (cfg) => {
const channel = getChannelConfig(cfg);
return Boolean(
channel &&
(hasConfiguredAccountValue(channel.token) ||
hasConfiguredAccountValue(process.env.SYNOLOGY_CHAT_TOKEN)),
);
},
});
export { listAccountIds };
function getRawAccountConfig(
channelCfg: SynologyChatChannelConfig,
@@ -92,22 +99,6 @@ function parseRateLimitPerMinute(raw: string | undefined): number {
return normalizeRateLimitPerMinuteValue(raw) ?? 30;
}
/**
* List all configured account IDs for this channel.
* Returns ["default"] if there's a base config, plus any named accounts.
*/
export function listAccountIds(cfg: OpenClawConfig): string[] {
const channelCfg = getChannelConfig(cfg);
if (!channelCfg) {
return [];
}
return listCombinedAccountIds({
configuredAccountIds: Object.keys(channelCfg.accounts ?? {}),
implicitAccountId: resolveImplicitAccountId(channelCfg),
});
}
/**
* Resolve a specific account by ID with full defaults applied.
* Falls back to env vars for the "default" account.
@@ -121,13 +112,7 @@ export function resolveAccount(
const accountOverrides =
id === DEFAULT_ACCOUNT_ID ? undefined : (channelCfg.accounts?.[id] ?? undefined);
const rawAccount = getRawAccountConfig(channelCfg, id);
const merged = resolveMergedAccountConfig<Record<string, unknown> & SynologyChatChannelConfig>({
channelConfig: channelCfg as Record<string, unknown> & SynologyChatChannelConfig,
accounts: channelCfg.accounts as
| Record<string, Partial<Record<string, unknown> & SynologyChatChannelConfig>>
| undefined,
accountId: id,
});
const merged = resolveMergedSynologyChatAccountConfig(cfg, id);
// Env var fallbacks (primarily for the "default" account)
const envToken = normalizeOptionalString(process.env.SYNOLOGY_CHAT_TOKEN) ?? "";

View File

@@ -170,6 +170,13 @@ describe("synology-chat account resolution", () => {
expect(listAccountIds({ channels: {} })).toStrictEqual([]);
});
it("does not discover an env account when the channel is not installed", () => {
process.env.SYNOLOGY_CHAT_TOKEN = "env-token";
expect(listAccountIds({})).toStrictEqual([]);
expect(listAccountIds({ channels: {} })).toStrictEqual([]);
});
it("lists the default account when base config has a token", () => {
const cfg = { channels: { "synology-chat": { token: "abc" } } };
expect(listAccountIds(cfg)).toEqual(["default"]);

View File

@@ -1,6 +1,7 @@
// Telegram plugin module implements account selection behavior.
import {
listCombinedAccountIds,
createAccountListHelpers,
hasConfiguredAccountValue,
resolveListedDefaultAccountId,
} from "openclaw/plugin-sdk/account-core";
import {
@@ -32,16 +33,6 @@ function resolveDefaultAgentId(cfg: OpenClawConfig): string {
return normalizeAgentId(chosen);
}
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
const ids = new Set<string>();
for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) {
if (key) {
ids.add(normalizeAccountId(key));
}
}
return [...ids];
}
function resolveBindingAccount(params: {
binding: unknown;
channelId: string;
@@ -88,33 +79,25 @@ function resolveDefaultAgentBoundAccountId(cfg: OpenClawConfig, channelId: strin
return null;
}
function hasConfiguredDefaultAccountValue(value: unknown): boolean {
if (typeof value === "string") {
return value.trim().length > 0;
}
return value !== undefined && value !== null;
}
function hasImplicitDefaultTelegramAccount(cfg: OpenClawConfig): boolean {
const telegram = cfg.channels?.telegram;
if (!telegram) {
return false;
}
return (
hasConfiguredDefaultAccountValue(telegram.botToken) ||
hasConfiguredDefaultAccountValue(telegram.tokenFile) ||
hasConfiguredDefaultAccountValue(process.env.TELEGRAM_BOT_TOKEN)
hasConfiguredAccountValue(telegram.botToken) ||
hasConfiguredAccountValue(telegram.tokenFile) ||
hasConfiguredAccountValue(process.env.TELEGRAM_BOT_TOKEN)
);
}
export function listTelegramAccountIds(cfg: OpenClawConfig): string[] {
return listCombinedAccountIds({
configuredAccountIds: listConfiguredAccountIds(cfg),
additionalAccountIds: listBoundAccountIds(cfg, "telegram"),
implicitAccountId: hasImplicitDefaultTelegramAccount(cfg) ? DEFAULT_ACCOUNT_ID : undefined,
fallbackAccountIdWhenEmpty: DEFAULT_ACCOUNT_ID,
});
}
const { listAccountIds: listTelegramAccountIds } = createAccountListHelpers("telegram", {
normalizeAccountId,
additionalAccountIds: (cfg) => listBoundAccountIds(cfg, "telegram"),
hasImplicitDefaultAccount: hasImplicitDefaultTelegramAccount,
});
export { listTelegramAccountIds };
export function resolveDefaultTelegramAccountSelection(cfg: OpenClawConfig): {
accountId: string;

View File

@@ -149,6 +149,28 @@ describe("resolveTelegramAccount", () => {
expect(accounts[0]?.token).toBe("tok-work");
});
it("preserves normalized agent-bound accounts and default-agent selection", () => {
const cfg = {
agents: { list: [{ id: "primary", default: true }] },
channels: {
telegram: {
botToken: "tok-default",
accounts: { Alerts: { botToken: "tok-alerts" } },
},
},
bindings: [
{ agentId: "primary", match: { channel: "telegram", accountId: " Ops Team " } },
{ agentId: "another", match: { channel: "telegram", accountId: "ops-team" } },
{ agentId: "ignored", match: { channel: "telegram", accountId: "*" } },
{ agentId: "ignored", match: { channel: "slack", accountId: "slack-only" } },
],
} as unknown as OpenClawConfig;
expect(listTelegramAccountIds(cfg)).toEqual(["alerts", "default", "ops-team"]);
expect(resolveDefaultTelegramAccountId(cfg)).toBe("ops-team");
expectNoMissingDefaultWarning();
});
it("keeps the implicit default account when named accounts are added to top-level credentials (#82780)", () => {
const cfg = {
channels: {

View File

@@ -228,6 +228,11 @@ describe("tlon core", () => {
expect(resolved.error.message).toMatch(/invalid tlon target/i);
});
it("does not invent an account when the Tlon channel is unconfigured", () => {
expect(listTlonAccountIds({} as OpenClawConfig)).toEqual([]);
expect(listTlonAccountIds({ channels: { tlon: {} } } as OpenClawConfig)).toEqual([]);
});
it("lists named accounts and the implicit default account", () => {
const cfg = {
channels: {

View File

@@ -1,10 +1,6 @@
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
// Tlon type declarations define plugin contracts.
import {
DEFAULT_ACCOUNT_ID,
listCombinedAccountIds,
normalizeAccountId,
resolveMergedAccountConfig,
} from "openclaw/plugin-sdk/account-resolution";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-resolution";
import type { ResolvedChannelImplicitMentions } from "openclaw/plugin-sdk/channel-ingress-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
@@ -60,6 +56,17 @@ function resolveTlonChannelConfig(cfg: OpenClawConfig): TlonAccountConfig | unde
return cfg.channels?.tlon as TlonAccountConfig | undefined;
}
const {
listAccountIds: listTlonAccountIds,
resolveAccountConfig: resolveMergedNamedTlonAccountConfig,
} = createAccountListHelpers<TlonAccountConfig>("tlon", {
normalizeAccountId,
fallbackAccountIdWhenEmpty: false,
hasImplicitDefaultAccount: (cfg) => Boolean(resolveTlonChannelConfig(cfg)?.ship),
});
export { listTlonAccountIds };
function resolveMergedTlonAccountConfig(
cfg: OpenClawConfig,
accountId: string,
@@ -68,14 +75,8 @@ function resolveMergedTlonAccountConfig(
if (accountId === DEFAULT_ACCOUNT_ID) {
return (channel ?? {}) as Record<string, unknown> & TlonAccountConfig;
}
return resolveMergedAccountConfig<Record<string, unknown> & TlonAccountConfig>({
channelConfig: (channel ?? {}) as Record<string, unknown> & TlonAccountConfig,
accounts: channel?.accounts as
| Record<string, Partial<Record<string, unknown> & TlonAccountConfig>>
| undefined,
accountId,
normalizeAccountId,
});
return resolveMergedNamedTlonAccountConfig(cfg, accountId) as Record<string, unknown> &
TlonAccountConfig;
}
export function resolveTlonAccount(
@@ -150,14 +151,3 @@ export function resolveTlonAccount(
ownerShip,
};
}
export function listTlonAccountIds(cfg: OpenClawConfig): string[] {
const base = resolveTlonChannelConfig(cfg);
if (!base) {
return [];
}
return listCombinedAccountIds({
configuredAccountIds: Object.keys(base.accounts ?? {}).map(normalizeAccountId),
implicitAccountId: base.ship ? DEFAULT_ACCOUNT_ID : undefined,
});
}

View File

@@ -2,11 +2,13 @@
import { describe, expect, it } from "vitest";
import {
getAccountConfig,
listAccountIds,
resolveDefaultTwitchAccountId,
resolveTwitchAccountContext,
twitchConfigAdapter,
} from "./config.js";
const { listAccountIds } = twitchConfigAdapter;
describe("getAccountConfig", () => {
const mockMultiAccountConfig = {
channels: {
@@ -117,6 +119,10 @@ describe("getAccountConfig", () => {
});
describe("listAccountIds", () => {
it("does not invent a default account when Twitch is unconfigured", () => {
expect(listAccountIds({})).toEqual([]);
});
it("includes the implicit default account from simplified config", () => {
expect(
listAccountIds({
@@ -160,6 +166,18 @@ describe("listAccountIds", () => {
} as Parameters<typeof listAccountIds>[0]),
).toEqual(["alerts-31m", "secondary"]);
});
it("preserves an explicitly present empty root credential as an implicit account", () => {
expect(
listAccountIds({
channels: {
twitch: {
username: "",
},
},
} as Parameters<typeof listAccountIds>[0]),
).toEqual(["default"]);
});
});
describe("resolveDefaultTwitchAccountId", () => {

View File

@@ -1,6 +1,7 @@
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
// Twitch helper module supports config behavior.
import {
listCombinedAccountIds,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
resolveNormalizedAccountEntry,
} from "openclaw/plugin-sdk/account-resolution";
@@ -9,10 +10,25 @@ import { resolveTwitchToken, type TwitchTokenResolution } from "./token.js";
import type { TwitchAccountConfig } from "./types.js";
import { isAccountConfigured } from "./utils/twitch.js";
/**
* Default account ID for Twitch
*/
export const DEFAULT_ACCOUNT_ID = "default";
export { DEFAULT_ACCOUNT_ID };
export type ResolvedTwitchAccount = TwitchAccountConfig & { accountId: string };
const { listAccountIds, resolveDefaultAccountId: resolveDefaultTwitchAccountId } =
createAccountListHelpers("twitch", {
normalizeAccountId,
fallbackAccountIdWhenEmpty: false,
hasImplicitDefaultAccount: (cfg) => {
const twitch = cfg.channels?.twitch as Record<string, unknown> | undefined;
return (
typeof twitch?.username === "string" ||
typeof twitch?.accessToken === "string" ||
typeof twitch?.channel === "string"
);
},
});
export { resolveDefaultTwitchAccountId };
type ResolvedTwitchAccountContext = {
accountId: string;
@@ -105,48 +121,6 @@ export function getAccountConfig(
return account;
}
/**
* List all configured account IDs
*
* Includes both explicit accounts and implicit "default" from base-level config
*/
export function listAccountIds(cfg: OpenClawConfig): string[] {
const twitch = cfg.channels?.twitch;
// Access accounts via unknown to handle union type (single-account vs multi-account)
const twitchRaw = twitch as Record<string, unknown> | undefined;
const accountMap = twitchRaw?.accounts as Record<string, unknown> | undefined;
// Add implicit "default" if base-level config exists and "default" not already present
const hasBaseLevelConfig =
twitchRaw &&
(typeof twitchRaw.username === "string" ||
typeof twitchRaw.accessToken === "string" ||
typeof twitchRaw.channel === "string");
return listCombinedAccountIds({
configuredAccountIds: Object.keys(accountMap ?? {}).map((accountId) =>
normalizeAccountId(accountId),
),
implicitAccountId: hasBaseLevelConfig ? DEFAULT_ACCOUNT_ID : undefined,
});
}
export function resolveDefaultTwitchAccountId(cfg: OpenClawConfig): string {
const preferredRaw =
typeof cfg.channels?.twitch?.defaultAccount === "string"
? cfg.channels.twitch.defaultAccount.trim()
: "";
const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : "";
const ids = listAccountIds(cfg);
if (preferred && ids.includes(preferred)) {
return preferred;
}
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
export function resolveTwitchAccountContext(
cfg: OpenClawConfig,
accountId?: string | null,
@@ -165,6 +139,35 @@ export function resolveTwitchAccountContext(
};
}
/** Keep runtime and setup on the same normalized, account-scoped credential path. */
function resolveTwitchAccount(
cfg: OpenClawConfig,
accountId?: string | null,
): ResolvedTwitchAccount {
const resolvedAccountId = normalizeAccountId(accountId ?? resolveDefaultTwitchAccountId(cfg));
const account = getAccountConfig(cfg, resolvedAccountId);
return account
? { accountId: resolvedAccountId, ...account }
: {
accountId: resolvedAccountId,
username: "",
accessToken: "",
clientId: "",
channel: "",
enabled: false,
};
}
/** Share account selection and configured-state checks across both Twitch entrypoints. */
export const twitchConfigAdapter = {
listAccountIds,
resolveAccount: resolveTwitchAccount,
defaultAccountId: resolveDefaultTwitchAccountId,
isConfigured: (account: ResolvedTwitchAccount, cfg: OpenClawConfig) =>
resolveTwitchAccountContext(cfg, account.accountId).configured,
isEnabled: (account: ResolvedTwitchAccount | undefined) => account?.enabled !== false,
};
export function resolveTwitchSnapshotAccountId(
cfg: OpenClawConfig,
account: TwitchAccountConfig,

View File

@@ -2,6 +2,7 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../api.js";
import { twitchPlugin } from "./plugin.js";
import { twitchSetupPlugin } from "./setup-surface.js";
describe("twitchPlugin pairing", () => {
it("normalizes trimmed twitch user prefixes in allow entries", () => {
@@ -71,7 +72,7 @@ describe("twitchPlugin.status.buildAccountSnapshot", () => {
} as OpenClawConfig;
const snapshot = await twitchPlugin.status?.buildAccountSnapshot?.({
account: secondary,
account: twitchPlugin.config.resolveAccount(cfg, "secondary"),
cfg,
});
@@ -108,4 +109,72 @@ describe("twitchPlugin.config", () => {
expect(twitchPlugin.config.defaultAccountId?.(cfg)).toBe("secondary");
expect(twitchPlugin.config.resolveAccount(cfg).accountId).toBe("secondary");
});
it.each([
{
name: "does not borrow the configured default account's token",
defaultToken: "oauth:default-token",
selectedToken: "",
expected: false,
},
{
name: "does not reject a configured named account because the default lacks a token",
defaultToken: "",
selectedToken: "oauth:secondary-token",
expected: true,
},
])("$name", ({ defaultToken, selectedToken, expected }) => {
const cfg = {
channels: {
twitch: {
accounts: {
default: {
username: "default",
accessToken: defaultToken,
clientId: "default-client",
channel: "default-channel",
},
secondary: {
username: "secondary",
accessToken: selectedToken,
clientId: "secondary-client",
channel: "secondary-channel",
},
},
},
},
} as OpenClawConfig;
for (const config of [twitchPlugin.config, twitchSetupPlugin.config]) {
const account = config.resolveAccount(cfg, "secondary");
expect(account.accountId).toBe("secondary");
expect(config.isConfigured?.(account, cfg)).toBe(expected);
}
});
it("normalizes named accounts consistently across runtime and setup", () => {
const cfg = {
channels: {
twitch: {
accounts: {
Secondary: {
username: "secondary",
accessToken: "oauth:secondary-token",
clientId: "secondary-client",
channel: "secondary-channel",
},
},
},
},
} as OpenClawConfig;
for (const config of [twitchPlugin.config, twitchSetupPlugin.config]) {
const account = config.resolveAccount(cfg, "SECONDARY\r\n");
expect(account).toMatchObject({
accountId: "secondary",
username: "secondary",
});
expect(config.isConfigured?.(account, cfg)).toBe(true);
}
});
});

View File

@@ -29,10 +29,11 @@ import { TwitchConfigSchema } from "./config-schema.js";
import {
DEFAULT_ACCOUNT_ID,
getAccountConfig,
listAccountIds,
resolveDefaultTwitchAccountId,
resolveTwitchAccountContext,
resolveTwitchSnapshotAccountId,
twitchConfigAdapter,
type ResolvedTwitchAccount,
} from "./config.js";
import { twitchMessageAdapter, twitchOutbound } from "./outbound.js";
import { probeTwitch } from "./probe.js";
@@ -48,8 +49,6 @@ import type {
} from "./types.js";
import { isAccountConfigured, normalizeTwitchChannel } from "./utils/twitch.js";
type ResolvedTwitchAccount = TwitchAccountConfig & { accountId?: string | null };
function normalizeTwitchMessagingTarget(target: string): string {
const providerTarget = stripChannelTargetPrefix(target, "twitch", "twitch-chat");
const kindMatch = /^(user|dm|channel|group|conversation|room):/i.exec(providerTarget);
@@ -117,30 +116,7 @@ export const twitchPlugin: ChannelPlugin<ResolvedTwitchAccount> =
message: twitchMessageAdapter,
configSchema: buildChannelConfigSchema(TwitchConfigSchema),
config: {
listAccountIds: (cfg: OpenClawConfig): string[] => listAccountIds(cfg),
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null): ResolvedTwitchAccount => {
const resolvedAccountId = accountId ?? resolveDefaultTwitchAccountId(cfg);
const account = getAccountConfig(cfg, resolvedAccountId);
if (!account) {
return {
accountId: resolvedAccountId,
channel: "",
username: "",
accessToken: "",
clientId: "",
enabled: false,
};
}
return {
accountId: resolvedAccountId,
...account,
};
},
defaultAccountId: (cfg: OpenClawConfig): string => resolveDefaultTwitchAccountId(cfg),
isConfigured: (_account: unknown, cfg: OpenClawConfig): boolean =>
resolveTwitchAccountContext(cfg).configured,
isEnabled: (account: ResolvedTwitchAccount | undefined): boolean =>
account?.enabled !== false,
...twitchConfigAdapter,
describeAccount: (account: TwitchAccountConfig | undefined) =>
account
? describeAccountSnapshot({

View File

@@ -20,9 +20,10 @@ import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtim
import {
DEFAULT_ACCOUNT_ID,
getAccountConfig,
listAccountIds,
resolveDefaultTwitchAccountId,
resolveTwitchAccountContext,
twitchConfigAdapter,
type ResolvedTwitchAccount,
} from "./config.js";
import type { TwitchAccountConfig, TwitchRole } from "./types.js";
import { isAccountConfigured } from "./utils/twitch.js";
@@ -493,38 +494,13 @@ export const twitchSetupWizard: ChannelSetupWizard = {
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
};
type ResolvedTwitchAccount = TwitchAccountConfig & { accountId?: string | null };
export const twitchSetupPlugin: ChannelPlugin<ResolvedTwitchAccount> = {
id: channel,
meta: getChatChannelMeta(channel),
capabilities: {
chatTypes: ["group"],
},
config: {
listAccountIds: (cfg) => listAccountIds(cfg),
resolveAccount: (cfg, accountId) => {
const resolvedAccountId = normalizeAccountId(accountId ?? resolveDefaultTwitchAccountId(cfg));
const account = getAccountConfig(cfg, resolvedAccountId);
if (!account) {
return {
accountId: resolvedAccountId,
username: "",
accessToken: "",
clientId: "",
channel: "",
enabled: false,
};
}
return {
accountId: resolvedAccountId,
...account,
};
},
defaultAccountId: (cfg) => resolveDefaultTwitchAccountId(cfg),
isConfigured: (account, cfg) => resolveTwitchAccountContext(cfg, account?.accountId).configured,
isEnabled: (account) => account.enabled !== false,
},
config: twitchConfigAdapter,
setup: twitchSetupAdapter,
setupContract: twitchSetupContract,
setupWizard: twitchSetupWizard,

View File

@@ -1,8 +1,5 @@
// Zalo plugin module implements accounts behavior.
import {
createAccountListHelpers,
resolveMergedAccountConfig,
} from "openclaw/plugin-sdk/account-helpers";
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -11,26 +8,19 @@ import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types
export type { ResolvedZaloAccount };
const { listAccountIds: listZaloAccountIds, resolveDefaultAccountId: resolveDefaultZaloAccountId } =
createAccountListHelpers("zalo", {
implicitDefaultAccount: {
channelKeys: ["botToken", "tokenFile"],
envVars: ["ZALO_BOT_TOKEN"],
},
});
const {
listAccountIds: listZaloAccountIds,
resolveDefaultAccountId: resolveDefaultZaloAccountId,
resolveAccountConfig: mergeZaloAccountConfig,
} = createAccountListHelpers<ZaloAccountConfig>("zalo", {
omitKeys: ["defaultAccount"],
implicitDefaultAccount: {
channelKeys: ["botToken", "tokenFile"],
envVars: ["ZALO_BOT_TOKEN"],
},
});
export { listZaloAccountIds, resolveDefaultZaloAccountId };
function mergeZaloAccountConfig(cfg: OpenClawConfig, accountId: string): ZaloAccountConfig {
return resolveMergedAccountConfig<ZaloAccountConfig>({
channelConfig: cfg.channels?.zalo as ZaloAccountConfig | undefined,
accounts: (cfg.channels?.zalo as ZaloConfig | undefined)?.accounts as
| Record<string, Partial<ZaloAccountConfig>>
| undefined,
accountId,
omitKeys: ["defaultAccount"],
});
}
export function resolveZaloAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;

View File

@@ -1,10 +1,6 @@
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
// Zalouser plugin module implements accounts behavior.
import {
createAccountListHelpers,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
resolveMergedAccountConfig,
} from "openclaw/plugin-sdk/account-resolution";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-resolution";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -15,7 +11,9 @@ const loadZalouserAccountsRuntime = createLazyRuntimeModule(() => import("./acco
const {
listAccountIds: listZalouserAccountIds,
resolveDefaultAccountId: resolveDefaultZalouserAccountId,
} = createAccountListHelpers("zalouser", {
resolveAccountConfig: resolveMergedZalouserAccountConfig,
} = createAccountListHelpers<ZalouserAccountConfig>("zalouser", {
omitKeys: ["defaultAccount"],
implicitDefaultAccount: {
channelKeys: ["profile"],
envVars: ["ZALOUSER_PROFILE", "ZCA_PROFILE"],
@@ -24,14 +22,7 @@ const {
export { listZalouserAccountIds, resolveDefaultZalouserAccountId };
function mergeZalouserAccountConfig(cfg: OpenClawConfig, accountId: string): ZalouserAccountConfig {
const merged = resolveMergedAccountConfig<ZalouserAccountConfig>({
channelConfig: cfg.channels?.zalouser as ZalouserAccountConfig | undefined,
accounts: (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts as
| Record<string, Partial<ZalouserAccountConfig>>
| undefined,
accountId,
omitKeys: ["defaultAccount"],
});
const merged = resolveMergedZalouserAccountConfig(cfg, accountId);
return {
...merged,
// Match Telegram's safe default: groups stay allowlisted unless explicitly opened.

View File

@@ -158,6 +158,53 @@ describe("createAccountListHelpers", () => {
}
}
});
it("does not synthesize an account when an owner disables the empty fallback", () => {
const helpers = createAccountListHelpers("testchannel", {
fallbackAccountIdWhenEmpty: false,
implicitDefaultAccount: { channelKeys: ["token"] },
});
expect(helpers.listAccountIds({} as OpenClawConfig)).toEqual([]);
expect(helpers.listAccountIds(cfg({}))).toEqual([]);
expect(
helpers.listAccountIds({
channels: { testchannel: { token: "root-token" } },
} as unknown as OpenClawConfig),
).toEqual(["default"]);
expect(helpers.resolveDefaultAccountId({} as OpenClawConfig)).toBe("default");
});
it("combines additional owner-discovered accounts without changing stable order", () => {
const helpers = createAccountListHelpers("testchannel", {
additionalAccountIds: () => ["bound", "work", "bound"],
});
expect(helpers.listAccountIds(cfg({ work: {}, alerts: {} }))).toEqual([
"alerts",
"bound",
"work",
]);
});
it("allows a single-account owner to name its configured implicit account", () => {
const helpers = createAccountListHelpers("testchannel", {
fallbackAccountIdWhenEmpty: false,
resolveImplicitAccountId: (config) => {
const channel = config.channels?.["testchannel"] as
| { token?: string; defaultAccount?: string }
| undefined;
return channel?.token ? (channel.defaultAccount ?? "default") : undefined;
},
});
expect(helpers.listAccountIds({} as OpenClawConfig)).toEqual([]);
expect(
helpers.listAccountIds({
channels: { testchannel: { token: "root-token", defaultAccount: "work" } },
} as unknown as OpenClawConfig),
).toEqual(["work"]);
});
});
describe("resolveDefaultAccountId", () => {
@@ -208,6 +255,86 @@ describe("createAccountListHelpers", () => {
});
});
describe("createAccountListHelpers account resolution", () => {
type TestAccountConfig = {
enabled?: boolean;
defaultAccount?: string;
name?: string;
token?: string | { source: "env"; provider: string; id: string };
commands?: { native?: boolean; callbackPath?: string };
accounts?: Record<string, Partial<TestAccountConfig>>;
};
const resolver = createAccountListHelpers<TestAccountConfig>("testchannel", {
normalizeAccountId,
omitKeys: ["defaultAccount"],
nestedObjectKeys: ["commands"],
implicitDefaultAccount: { channelKeys: ["token"] },
});
it("shares normalized account enumeration and configured default selection", () => {
const input = cfg({ "Work Team": { name: "Work" }, alerts: {} }, "Work Team");
expect(resolver.listConfiguredAccountIds(input)).toEqual(["work-team", "alerts"]);
expect(resolver.listAccountIds(input)).toEqual(["alerts", "work-team"]);
expect(resolver.resolveDefaultAccountId(input)).toBe("work-team");
});
it("merges owner-declared nested fields while omitting account-selection metadata", () => {
const input = {
channels: {
testchannel: {
enabled: true,
defaultAccount: "Work Team",
commands: { native: true },
accounts: {
"Work Team": {
name: "Work",
commands: { callbackPath: "/work" },
},
},
},
},
} as unknown as OpenClawConfig;
expect(resolver.resolveAccountConfig(input, "work-team")).toEqual({
enabled: true,
name: "Work",
commands: { native: true, callbackPath: "/work" },
});
});
it("preserves unresolved SecretRef values without inspecting credentials", () => {
const token = { source: "env" as const, provider: "default", id: "TESTCHANNEL_TOKEN" };
const input = {
channels: {
testchannel: {
accounts: { work: { token } },
},
},
} as unknown as OpenClawConfig;
expect(resolver.resolveAccountConfig(input, "work").token).toBe(token);
});
it("keeps a disabled root and explicit account overrides visible to their owner", () => {
const input = {
channels: {
testchannel: {
enabled: false,
accounts: { work: { enabled: true, name: "Work" } },
},
},
} as unknown as OpenClawConfig;
expect(resolver.resolveAccountConfig(input, "work")).toEqual({
enabled: true,
name: "Work",
});
expect(input.channels?.["testchannel"]).toMatchObject({ enabled: false });
});
});
describe("listCombinedAccountIds", () => {
it("combines configured, additional, and implicit ids once", () => {
expect(

View File

@@ -18,18 +18,25 @@ import {
import type { ChannelAccountSnapshot } from "./types.core.js";
/**
* Creates reusable account id listing and default-account resolution helpers for a channel.
* Creates reusable account listing, default selection, and merged config helpers for a channel.
*/
export function createAccountListHelpers(
export function createAccountListHelpers<
TConfig extends Record<string, unknown> = Record<string, unknown>,
>(
channelKey: string,
options?: {
normalizeAccountId?: (id: string) => string;
omitKeys?: Array<(keyof TConfig & string) | "defaultAccount">;
nestedObjectKeys?: Array<keyof TConfig & string>;
allowUnlistedDefaultAccount?: boolean;
additionalAccountIds?: (cfg: OpenClawConfig) => Iterable<string>;
fallbackAccountIdWhenEmpty?: string | false;
implicitDefaultAccount?: {
channelKeys?: readonly string[];
envVars?: readonly string[];
};
hasImplicitDefaultAccount?: (cfg: OpenClawConfig) => boolean;
resolveImplicitAccountId?: (cfg: OpenClawConfig) => string | undefined;
},
) {
function hasImplicitDefaultAccount(cfg: OpenClawConfig): boolean {
@@ -88,8 +95,16 @@ export function createAccountListHelpers(
function listAccountIds(cfg: OpenClawConfig): string[] {
return listCombinedAccountIds({
configuredAccountIds: listConfiguredAccountIds(cfg),
implicitAccountId: hasImplicitDefaultAccount(cfg) ? DEFAULT_ACCOUNT_ID : undefined,
fallbackAccountIdWhenEmpty: DEFAULT_ACCOUNT_ID,
additionalAccountIds: options?.additionalAccountIds?.(cfg),
implicitAccountId: options?.resolveImplicitAccountId
? options.resolveImplicitAccountId(cfg)
: hasImplicitDefaultAccount(cfg)
? DEFAULT_ACCOUNT_ID
: undefined,
fallbackAccountIdWhenEmpty:
options?.fallbackAccountIdWhenEmpty === false
? undefined
: (options?.fallbackAccountIdWhenEmpty ?? DEFAULT_ACCOUNT_ID),
});
}
@@ -101,7 +116,27 @@ export function createAccountListHelpers(
});
}
return { listConfiguredAccountIds, listAccountIds, resolveDefaultAccountId };
return {
listConfiguredAccountIds,
listAccountIds,
resolveDefaultAccountId,
// Channel owners destructure this resolver; an arrow keeps it independent of `this`.
resolveAccountConfig: (cfg: OpenClawConfig, accountId: string): TConfig => {
const channelConfig = cfg.channels?.[channelKey] as TConfig | undefined;
const accounts = (
channelConfig as (TConfig & { accounts?: Record<string, Partial<TConfig>> }) | undefined
)?.accounts;
return resolveMergedAccountConfig<TConfig>({
channelConfig,
accounts,
accountId,
omitKeys: options?.omitKeys,
normalizeAccountId: options?.normalizeAccountId,
nestedObjectKeys: options?.nestedObjectKeys,
});
},
};
}
/**

View File

@@ -13,6 +13,7 @@ import {
createEnvPatchedAccountSetupAdapter,
createPatchedAccountSetupAdapter,
moveSingleAccountChannelSectionToDefaultAccount,
patchScopedAccountConfig,
prepareScopedSetupConfig,
} from "./setup-helpers.js";
import type { ChannelSetupAdapter } from "./types.adapters.js";
@@ -203,6 +204,90 @@ describe("applySetupAccountConfigPatch", () => {
});
});
describe("patchScopedAccountConfig credential clearing", () => {
it("clears only default-account credential fields before applying their replacement", () => {
const next = patchScopedAccountConfig({
cfg: asConfig({
channels: {
"demo-setup": {
enabled: false,
token: "old-token",
tokenFile: "/old/token",
webhookPath: "/keep",
},
},
}),
channelKey: "demo-setup",
accountId: DEFAULT_ACCOUNT_ID,
clearFields: ["token", "tokenFile"],
patch: { token: "new-token" },
ensureChannelEnabled: false,
});
expect(channelRecord(next, "demo-setup")).toEqual({
enabled: false,
token: "new-token",
webhookPath: "/keep",
});
});
it("clears only selected named-account credentials and preserves disabled siblings", () => {
const next = patchScopedAccountConfig({
cfg: asConfig({
channels: {
"demo-setup": {
enabled: false,
token: "root-token",
accounts: {
work: { enabled: false, token: "old-token", tokenFile: "/old/token" },
alerts: { enabled: false, token: "alerts-token" },
},
},
},
}),
channelKey: "demo-setup",
accountId: "work",
clearFields: ["token", "tokenFile"],
patch: { token: "new-token" },
ensureChannelEnabled: false,
ensureAccountEnabled: false,
});
const channel = channelRecord(next, "demo-setup");
expect(channel.enabled).toBe(false);
expect(channel.token).toBe("root-token");
expect(accountRecord(channel, "work")).toEqual({ enabled: false, token: "new-token" });
expect(accountRecord(channel, "alerts")).toEqual({
enabled: false,
token: "alerts-token",
});
});
it("allows setup to explicitly re-enable an existing disabled named account", () => {
const next = patchScopedAccountConfig({
cfg: asConfig({
channels: {
"demo-setup": {
enabled: false,
accounts: { work: { enabled: false, tokenFile: "/old/token" } },
},
},
}),
channelKey: "demo-setup",
accountId: "work",
patch: { token: "new-token" },
accountPatch: { enabled: true, token: "new-token" },
clearFields: ["tokenFile"],
ensureChannelEnabled: true,
ensureAccountEnabled: false,
});
const channel = channelRecord(next, "demo-setup");
expect(channel.enabled).toBe(true);
expect(accountRecord(channel, "work")).toEqual({ enabled: true, token: "new-token" });
});
});
describe("createPatchedAccountSetupAdapter", () => {
it("stores default-account patch at channel root", () => {
const adapter = createPatchedAccountSetupAdapter({

View File

@@ -295,6 +295,7 @@ export function patchScopedAccountConfig(params: {
accountId: string;
patch: Record<string, unknown>;
accountPatch?: Record<string, unknown>;
clearFields?: readonly string[];
ensureChannelEnabled?: boolean;
ensureAccountEnabled?: boolean;
scopeDefaultToAccounts?: boolean;
@@ -312,6 +313,16 @@ export function patchScopedAccountConfig(params: {
const ensureAccountEnabled = params.ensureAccountEnabled ?? ensureChannelEnabled;
const patch = params.patch;
const accountPatch = params.accountPatch ?? patch;
const clearFields = (record: Record<string, unknown>): Record<string, unknown> => {
if (!params.clearFields?.length) {
return record;
}
const cleared = { ...record };
for (const field of params.clearFields) {
delete cleared[field];
}
return cleared;
};
if (accountId === DEFAULT_ACCOUNT_ID && !params.scopeDefaultToAccounts) {
// Default accounts historically live at channel root unless the channel opts into accounts.default.
return {
@@ -319,7 +330,7 @@ export function patchScopedAccountConfig(params: {
channels: {
...params.cfg.channels,
[params.channelKey]: {
...base,
...clearFields(base ?? {}),
...(ensureChannelEnabled ? { enabled: true } : {}),
...patch,
},
@@ -328,7 +339,7 @@ export function patchScopedAccountConfig(params: {
}
const accounts = base?.accounts ?? {};
const existingAccount = accounts[accountId] ?? {};
const existingAccount = clearFields(accounts[accountId] ?? {});
// Preserve an explicit disabled account while enabling newly created accounts by default.
return {
...params.cfg,