mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 07:17:14 +00:00
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:
committed by
GitHub
parent
e9990b818a
commit
08b1ff73e2
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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"] },
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) ?? "";
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user