refactor: share account id selection helpers

This commit is contained in:
Peter Steinberger
2026-03-22 19:41:47 +00:00
parent bddb6fca7b
commit 3c071a397f
10 changed files with 266 additions and 155 deletions

View File

@@ -3,6 +3,12 @@ import {
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import {
listCombinedAccountIds,
listConfiguredAccountIds,
resolveListedDefaultAccountId,
resolveNormalizedAccountEntry,
} from "openclaw/plugin-sdk/account-resolution";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { listMatrixEnvAccountIds } from "./env-vars.js";
@@ -27,15 +33,8 @@ export function findMatrixAccountEntry(
if (!accounts) {
return null;
}
const normalizedAccountId = normalizeAccountId(accountId);
for (const [rawAccountId, value] of Object.entries(accounts)) {
if (normalizeAccountId(rawAccountId) === normalizedAccountId && isRecord(value)) {
return value;
}
}
return null;
const entry = resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId);
return isRecord(entry) ? entry : null;
}
export function resolveConfiguredMatrixAccountIds(
@@ -43,22 +42,14 @@ export function resolveConfiguredMatrixAccountIds(
env: NodeJS.ProcessEnv = process.env,
): string[] {
const channel = resolveMatrixChannelConfig(cfg);
const ids = new Set<string>(listMatrixEnvAccountIds(env));
const accounts = channel && isRecord(channel.accounts) ? channel.accounts : null;
if (accounts) {
for (const [accountId, value] of Object.entries(accounts)) {
if (isRecord(value)) {
ids.add(normalizeAccountId(accountId));
}
}
}
if (ids.size === 0 && channel) {
ids.add(DEFAULT_ACCOUNT_ID);
}
return Array.from(ids).toSorted((a, b) => a.localeCompare(b));
return listCombinedAccountIds({
configuredAccountIds: listConfiguredAccountIds({
accounts: channel && isRecord(channel.accounts) ? channel.accounts : undefined,
normalizeAccountId,
}),
additionalAccountIds: listMatrixEnvAccountIds(env),
fallbackAccountIdWhenEmpty: channel ? DEFAULT_ACCOUNT_ID : undefined,
});
}
export function resolveMatrixDefaultOrOnlyAccountId(
@@ -74,17 +65,11 @@ export function resolveMatrixDefaultOrOnlyAccountId(
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
);
const configuredAccountIds = resolveConfiguredMatrixAccountIds(cfg, env);
if (configuredDefault && configuredAccountIds.includes(configuredDefault)) {
return configuredDefault;
}
if (configuredAccountIds.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
if (configuredAccountIds.length === 1) {
return configuredAccountIds[0] ?? DEFAULT_ACCOUNT_ID;
}
return DEFAULT_ACCOUNT_ID;
return resolveListedDefaultAccountId({
accountIds: configuredAccountIds,
configuredDefaultAccountId: configuredDefault,
ambiguousFallbackAccountId: DEFAULT_ACCOUNT_ID,
});
}
export function requiresExplicitMatrixDefaultAccount(

View File

@@ -1,4 +1,8 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
listConfiguredAccountIds,
resolveNormalizedAccountEntry,
} from "openclaw/plugin-sdk/account-resolution";
import { DEFAULT_ACCOUNT_ID } from "../runtime-api.js";
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js";
@@ -15,34 +19,21 @@ function resolveMatrixAccountsMap(cfg: CoreConfig): Readonly<Record<string, Matr
}
export function listNormalizedMatrixAccountIds(cfg: CoreConfig): string[] {
return [
...new Set(
Object.keys(resolveMatrixAccountsMap(cfg))
.filter(Boolean)
.map((accountId) => normalizeAccountId(accountId)),
),
];
return listConfiguredAccountIds({
accounts: resolveMatrixAccountsMap(cfg),
normalizeAccountId,
});
}
export function findMatrixAccountConfig(
cfg: CoreConfig,
accountId: string,
): MatrixAccountConfig | undefined {
const accounts = resolveMatrixAccountsMap(cfg);
if (accounts[accountId] && typeof accounts[accountId] === "object") {
return accounts[accountId];
}
const normalized = normalizeAccountId(accountId);
for (const key of Object.keys(accounts)) {
if (normalizeAccountId(key) === normalized) {
const candidate = accounts[key];
if (candidate && typeof candidate === "object") {
return candidate;
}
return undefined;
}
}
return undefined;
return resolveNormalizedAccountEntry(
resolveMatrixAccountsMap(cfg),
accountId,
normalizeAccountId,
);
}
export function hasExplicitMatrixAccountConfig(cfg: CoreConfig, accountId: string): boolean {

View File

@@ -3,6 +3,10 @@ import {
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import {
listCombinedAccountIds,
resolveListedDefaultAccountId,
} from "openclaw/plugin-sdk/account-resolution";
import type { OpenClawConfig } from "../api.js";
import type { NostrProfile } from "./config-schema.js";
import { DEFAULT_RELAYS } from "./default-relays.js";
@@ -45,28 +49,22 @@ export function listNostrAccountIds(cfg: OpenClawConfig): string[] {
const nostrCfg = (cfg.channels as Record<string, unknown> | undefined)?.nostr as
| NostrAccountConfig
| undefined;
// If privateKey is configured at top level, we have a default account
if (nostrCfg?.privateKey) {
return [resolveConfiguredDefaultNostrAccountId(cfg) ?? DEFAULT_ACCOUNT_ID];
}
return [];
return listCombinedAccountIds({
configuredAccountIds: [],
implicitAccountId: nostrCfg?.privateKey
? (resolveConfiguredDefaultNostrAccountId(cfg) ?? DEFAULT_ACCOUNT_ID)
: undefined,
});
}
/**
* Get the default account ID
*/
export function resolveDefaultNostrAccountId(cfg: OpenClawConfig): string {
const preferred = resolveConfiguredDefaultNostrAccountId(cfg);
if (preferred) {
return preferred;
}
const ids = listNostrAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID;
return resolveListedDefaultAccountId({
accountIds: listNostrAccountIds(cfg),
configuredDefaultAccountId: resolveConfiguredDefaultNostrAccountId(cfg),
});
}
/**

View File

@@ -2,10 +2,11 @@ import util from "node:util";
import {
createAccountActionGate,
DEFAULT_ACCOUNT_ID,
listConfiguredAccountIds as listConfiguredAccountIdsFromSection,
listCombinedAccountIds,
normalizeAccountId,
normalizeOptionalAccountId,
resolveAccountEntry,
resolveListedDefaultAccountId,
resolveAccountWithDefaultFallback,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-resolution";
@@ -55,21 +56,23 @@ export type ResolvedTelegramAccount = {
};
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
return listConfiguredAccountIdsFromSection({
accounts: cfg.channels?.telegram?.accounts,
normalizeAccountId,
});
const ids = new Set<string>();
for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) {
if (key) {
ids.add(normalizeAccountId(key));
}
}
return [...ids];
}
export function listTelegramAccountIds(cfg: OpenClawConfig): string[] {
const ids = Array.from(
new Set([...listConfiguredAccountIds(cfg), ...listBoundAccountIds(cfg, "telegram")]),
);
const ids = listCombinedAccountIds({
configuredAccountIds: listConfiguredAccountIds(cfg),
additionalAccountIds: listBoundAccountIds(cfg, "telegram"),
fallbackAccountIdWhenEmpty: DEFAULT_ACCOUNT_ID,
});
debugAccounts("listTelegramAccountIds", ids);
if (ids.length === 0) {
return [DEFAULT_ACCOUNT_ID];
}
return ids.toSorted((a, b) => a.localeCompare(b));
return ids;
}
let emittedMissingDefaultWarn = false;
@@ -84,16 +87,13 @@ export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string {
if (boundDefault) {
return boundDefault;
}
const preferred = normalizeOptionalAccountId(cfg.channels?.telegram?.defaultAccount);
if (
preferred &&
listTelegramAccountIds(cfg).some((accountId) => normalizeAccountId(accountId) === preferred)
) {
return preferred;
}
const ids = listTelegramAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
const resolved = resolveListedDefaultAccountId({
accountIds: ids,
configuredDefaultAccountId: normalizeOptionalAccountId(cfg.channels?.telegram?.defaultAccount),
});
if (resolved !== ids[0] || ids.includes(DEFAULT_ACCOUNT_ID) || ids.length <= 1) {
return resolved;
}
if (ids.length > 1 && !emittedMissingDefaultWarn) {
emittedMissingDefaultWarn = true;
@@ -102,7 +102,7 @@ export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string {
`${formatSetExplicitDefaultInstruction("telegram")} to avoid routing surprises in multi-account setups.`,
);
}
return ids[0] ?? DEFAULT_ACCOUNT_ID;
return resolved;
}
export function resolveTelegramAccountConfig(

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { getAccountConfig } from "./config.js";
import { getAccountConfig, listAccountIds } from "./config.js";
describe("getAccountConfig", () => {
const mockMultiAccountConfig = {
@@ -85,3 +85,34 @@ describe("getAccountConfig", () => {
expect(result).toBeNull();
});
});
describe("listAccountIds", () => {
it("includes the implicit default account from simplified config", () => {
expect(
listAccountIds({
channels: {
twitch: {
username: "testbot",
accessToken: "oauth:test123",
},
},
} as Parameters<typeof listAccountIds>[0]),
).toEqual(["default"]);
});
it("combines explicit accounts with the implicit default account once", () => {
expect(
listAccountIds({
channels: {
twitch: {
username: "testbot",
accounts: {
default: { username: "testbot" },
secondary: { username: "secondbot" },
},
},
},
} as Parameters<typeof listAccountIds>[0]),
).toEqual(["default", "secondary"]);
});
});

View File

@@ -1,3 +1,4 @@
import { listCombinedAccountIds } from "openclaw/plugin-sdk/account-resolution";
import type { OpenClawConfig } from "../runtime-api.js";
import type { TwitchAccountConfig } from "./types.js";
@@ -94,13 +95,6 @@ export function listAccountIds(cfg: OpenClawConfig): string[] {
const twitchRaw = twitch as Record<string, unknown> | undefined;
const accountMap = twitchRaw?.accounts as Record<string, unknown> | undefined;
const ids: string[] = [];
// Add explicit accounts
if (accountMap) {
ids.push(...Object.keys(accountMap));
}
// Add implicit "default" if base-level config exists and "default" not already present
const hasBaseLevelConfig =
twitchRaw &&
@@ -108,9 +102,8 @@ export function listAccountIds(cfg: OpenClawConfig): string[] {
typeof twitchRaw.accessToken === "string" ||
typeof twitchRaw.channel === "string");
if (hasBaseLevelConfig && !ids.includes(DEFAULT_ACCOUNT_ID)) {
ids.push(DEFAULT_ACCOUNT_ID);
}
return ids;
return listCombinedAccountIds({
configuredAccountIds: Object.keys(accountMap ?? {}),
implicitAccountId: hasBaseLevelConfig ? DEFAULT_ACCOUNT_ID : undefined,
});
}

View File

@@ -3,7 +3,9 @@ import type { OpenClawConfig } from "../../config/config.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import {
createAccountListHelpers,
listCombinedAccountIds,
mergeAccountConfig,
resolveListedDefaultAccountId,
resolveMergedAccountConfig,
} from "./account-helpers.js";
@@ -124,6 +126,74 @@ describe("createAccountListHelpers", () => {
});
});
describe("listCombinedAccountIds", () => {
it("combines configured, additional, and implicit ids once", () => {
expect(
listCombinedAccountIds({
configuredAccountIds: ["work", "alerts"],
additionalAccountIds: ["default", "alerts"],
implicitAccountId: "ops",
}),
).toEqual(["alerts", "default", "ops", "work"]);
});
it("uses the fallback id when no accounts are present", () => {
expect(
listCombinedAccountIds({
configuredAccountIds: [],
fallbackAccountIdWhenEmpty: "default",
}),
).toEqual(["default"]);
});
});
describe("resolveListedDefaultAccountId", () => {
it("prefers the configured default when present in the listed ids", () => {
expect(
resolveListedDefaultAccountId({
accountIds: ["alerts", "work"],
configuredDefaultAccountId: "work",
}),
).toBe("work");
});
it("matches configured defaults against normalized listed ids", () => {
expect(
resolveListedDefaultAccountId({
accountIds: ["Router D"],
configuredDefaultAccountId: "router-d",
}),
).toBe("router-d");
});
it("prefers the default account id when listed", () => {
expect(
resolveListedDefaultAccountId({
accountIds: ["default", "work"],
}),
).toBe("default");
});
it("can preserve an unlisted configured default", () => {
expect(
resolveListedDefaultAccountId({
accountIds: ["default", "work"],
configuredDefaultAccountId: "ops",
allowUnlistedDefaultAccount: true,
}),
).toBe("ops");
});
it("supports an explicit fallback id for ambiguous multi-account setups", () => {
expect(
resolveListedDefaultAccountId({
accountIds: ["alerts", "work"],
ambiguousFallbackAccountId: "default",
}),
).toBe("default");
});
});
describe("mergeAccountConfig", () => {
it("drops accounts from the base config before merging", () => {
const merged = mergeAccountConfig<{

View File

@@ -49,28 +49,76 @@ export function createAccountListHelpers(
}
function listAccountIds(cfg: OpenClawConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) {
return [DEFAULT_ACCOUNT_ID];
}
return ids.toSorted((a, b) => a.localeCompare(b));
return listCombinedAccountIds({
configuredAccountIds: listConfiguredAccountIds(cfg),
fallbackAccountIdWhenEmpty: DEFAULT_ACCOUNT_ID,
});
}
function resolveDefaultAccountId(cfg: OpenClawConfig): string {
const preferred = resolveConfiguredDefaultAccountId(cfg);
if (preferred) {
return preferred;
}
const ids = listAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID;
return resolveListedDefaultAccountId({
accountIds: listAccountIds(cfg),
configuredDefaultAccountId: resolveConfiguredDefaultAccountId(cfg),
allowUnlistedDefaultAccount: options?.allowUnlistedDefaultAccount,
});
}
return { listConfiguredAccountIds, listAccountIds, resolveDefaultAccountId };
}
export function listCombinedAccountIds(params: {
configuredAccountIds: Iterable<string>;
additionalAccountIds?: Iterable<string>;
implicitAccountId?: string | undefined;
fallbackAccountIdWhenEmpty?: string | undefined;
}): string[] {
const ids = new Set<string>();
for (const id of params.configuredAccountIds) {
if (id) {
ids.add(id);
}
}
for (const id of params.additionalAccountIds ?? []) {
if (id) {
ids.add(id);
}
}
if (params.implicitAccountId) {
ids.add(params.implicitAccountId);
}
if (ids.size === 0 && params.fallbackAccountIdWhenEmpty) {
return [params.fallbackAccountIdWhenEmpty];
}
return [...ids].toSorted((a, b) => a.localeCompare(b));
}
export function resolveListedDefaultAccountId(params: {
accountIds: readonly string[];
configuredDefaultAccountId?: string | undefined;
allowUnlistedDefaultAccount?: boolean;
ambiguousFallbackAccountId?: string | undefined;
normalizeListedAccountId?: ((accountId: string) => string) | undefined;
}): string {
const preferred = params.configuredDefaultAccountId;
const normalizeListedAccountId = params.normalizeListedAccountId ?? normalizeAccountId;
if (
preferred &&
(params.allowUnlistedDefaultAccount ||
params.accountIds.some((accountId) => normalizeListedAccountId(accountId) === preferred))
) {
return preferred;
}
if (params.accountIds.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
if (params.ambiguousFallbackAccountId && params.accountIds.length > 1) {
return params.ambiguousFallbackAccountId;
}
return params.accountIds[0] ?? DEFAULT_ACCOUNT_ID;
}
export function mergeAccountConfig<TConfig extends Record<string, unknown>>(params: {
channelConfig: TConfig | undefined;
accountConfig: Partial<TConfig> | undefined;

View File

@@ -1,3 +1,7 @@
import {
listCombinedAccountIds,
resolveListedDefaultAccountId,
} from "../channels/plugins/account-helpers.js";
import type { OpenClawConfig } from "../config/config.js";
import { tryReadSecretFileSync } from "../infra/secret-file.js";
import {
@@ -151,43 +155,32 @@ export function resolveLineAccount(params: {
export function listLineAccountIds(cfg: OpenClawConfig): string[] {
const lineConfig = cfg.channels?.line as LineConfig | undefined;
const accounts = lineConfig?.accounts;
const ids = new Set<string>();
// Add default account if configured at base level
if (
const hasBaseCredentials = Boolean(
lineConfig?.channelAccessToken?.trim() ||
lineConfig?.tokenFile ||
process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim()
) {
ids.add(DEFAULT_ACCOUNT_ID);
}
// Add named accounts
if (accounts) {
for (const id of Object.keys(accounts)) {
ids.add(id);
}
}
return Array.from(ids);
process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim(),
);
const preferred = normalizeOptionalAccountId(lineConfig?.defaultAccount);
const configuredAccountIds = [
...new Set(
Object.keys(lineConfig?.accounts ?? {})
.filter(Boolean)
.map(normalizeSharedAccountId),
),
];
return listCombinedAccountIds({
configuredAccountIds,
implicitAccountId: hasBaseCredentials ? (preferred ?? DEFAULT_ACCOUNT_ID) : undefined,
});
}
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,
),
});
}
export function normalizeAccountId(accountId: string | undefined): string {

View File

@@ -3,7 +3,9 @@ export type { OpenClawConfig } from "../config/config.js";
export { createAccountActionGate } from "../channels/plugins/account-action-gate.js";
export {
createAccountListHelpers,
listCombinedAccountIds,
mergeAccountConfig,
resolveListedDefaultAccountId,
resolveMergedAccountConfig,
} from "../channels/plugins/account-helpers.js";
export { normalizeChatType } from "../channels/chat-type.js";