diff --git a/extensions/irc/src/accounts.test.ts b/extensions/irc/src/accounts.test.ts index 5b4685795c6..1d8ab275af0 100644 --- a/extensions/irc/src/accounts.test.ts +++ b/extensions/irc/src/accounts.test.ts @@ -81,6 +81,29 @@ describe("resolveDefaultIrcAccountId", () => { }); describe("resolveIrcAccount", () => { + it("matches normalized configured account ids", () => { + const account = resolveIrcAccount({ + cfg: asConfig({ + channels: { + irc: { + accounts: { + "Ops Team": { + host: "irc.example.com", + nick: "claw", + }, + }, + }, + }, + }), + accountId: "ops-team", + }); + + expect(account.accountId).toBe("ops-team"); + expect(account.host).toBe("irc.example.com"); + expect(account.nick).toBe("claw"); + expect(account.configured).toBe(true); + }); + it("parses delimited IRC_CHANNELS env values for the default account", () => { const previousChannels = process.env.IRC_CHANNELS; process.env.IRC_CHANNELS = "alpha, beta\ngamma; delta"; diff --git a/extensions/irc/src/accounts.ts b/extensions/irc/src/accounts.ts index 39e726e9b5f..d33290d0310 100644 --- a/extensions/irc/src/accounts.ts +++ b/extensions/irc/src/accounts.ts @@ -1,5 +1,6 @@ import { createAccountListHelpers, mergeAccountConfig } from "openclaw/plugin-sdk/account-helpers"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import { resolveNormalizedAccountEntry } from "openclaw/plugin-sdk/account-resolution"; import { parseOptionalDelimitedEntries } from "openclaw/plugin-sdk/core"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; @@ -46,17 +47,11 @@ const { listAccountIds: listIrcAccountIds, resolveDefaultAccountId: resolveDefau export { listIrcAccountIds, resolveDefaultIrcAccountId }; function resolveAccountConfig(cfg: CoreConfig, accountId: string): IrcAccountConfig | undefined { - const accounts = cfg.channels?.irc?.accounts; - if (!accounts || typeof accounts !== "object") { - return undefined; - } - const direct = accounts[accountId] as IrcAccountConfig | undefined; - if (direct) { - return direct; - } - const normalized = normalizeAccountId(accountId); - const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized); - return matchKey ? (accounts[matchKey] as IrcAccountConfig | undefined) : undefined; + return resolveNormalizedAccountEntry( + cfg.channels?.irc?.accounts as Record | undefined, + accountId, + normalizeAccountId, + ); } function mergeIrcAccountConfig(cfg: CoreConfig, accountId: string): IrcAccountConfig { diff --git a/extensions/nextcloud-talk/src/accounts.test.ts b/extensions/nextcloud-talk/src/accounts.test.ts index dbc43690a3b..77e35bf65fb 100644 --- a/extensions/nextcloud-talk/src/accounts.test.ts +++ b/extensions/nextcloud-talk/src/accounts.test.ts @@ -6,6 +6,29 @@ import { resolveNextcloudTalkAccount } from "./accounts.js"; import type { CoreConfig } from "./types.js"; describe("resolveNextcloudTalkAccount", () => { + it("matches normalized configured account ids", () => { + const account = resolveNextcloudTalkAccount({ + cfg: { + channels: { + "nextcloud-talk": { + accounts: { + "Ops Team": { + baseUrl: "https://cloud.example.com", + botSecret: "bot-secret", + }, + }, + }, + }, + } as CoreConfig, + accountId: "ops-team", + }); + + expect(account.accountId).toBe("ops-team"); + expect(account.baseUrl).toBe("https://cloud.example.com"); + expect(account.secret).toBe("bot-secret"); + expect(account.secretSource).toBe("config"); + }); + it.runIf(process.platform !== "win32")("rejects symlinked botSecretFile paths", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-nextcloud-talk-")); const secretFile = path.join(dir, "secret.txt"); diff --git a/extensions/nextcloud-talk/src/accounts.ts b/extensions/nextcloud-talk/src/accounts.ts index d82b1c3ffd6..27e3178c84c 100644 --- a/extensions/nextcloud-talk/src/accounts.ts +++ b/extensions/nextcloud-talk/src/accounts.ts @@ -1,4 +1,7 @@ -import { mergeAccountConfig } from "openclaw/plugin-sdk/account-resolution"; +import { + mergeAccountConfig, + resolveNormalizedAccountEntry, +} from "openclaw/plugin-sdk/account-resolution"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime"; import { createAccountListHelpers, @@ -48,17 +51,13 @@ function resolveAccountConfig( cfg: CoreConfig, accountId: string, ): NextcloudTalkAccountConfig | undefined { - const accounts = cfg.channels?.["nextcloud-talk"]?.accounts; - if (!accounts || typeof accounts !== "object") { - return undefined; - } - const direct = accounts[accountId] as NextcloudTalkAccountConfig | undefined; - if (direct) { - return direct; - } - const normalized = normalizeAccountId(accountId); - const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized); - return matchKey ? (accounts[matchKey] as NextcloudTalkAccountConfig | undefined) : undefined; + return resolveNormalizedAccountEntry( + cfg.channels?.["nextcloud-talk"]?.accounts as + | Record + | undefined, + accountId, + normalizeAccountId, + ); } function mergeNextcloudTalkAccountConfig( diff --git a/extensions/telegram/src/token.test.ts b/extensions/telegram/src/token.test.ts index 74218f83ddd..b7b3ebd2d20 100644 --- a/extensions/telegram/src/token.test.ts +++ b/extensions/telegram/src/token.test.ts @@ -117,6 +117,23 @@ describe("resolveTelegramToken", () => { expect(res.source).toBe("config"); }); + it("resolves per-account tokens when config keys normalize spaces to dashes", () => { + vi.stubEnv("TELEGRAM_BOT_TOKEN", ""); + const cfg = { + channels: { + telegram: { + accounts: { + "Carey Notifications": { botToken: "acct-token" }, + }, + }, + }, + } as OpenClawConfig; + + const res = resolveTelegramToken(cfg, { accountId: "carey-notifications" }); + expect(res.token).toBe("acct-token"); + expect(res.source).toBe("config"); + }); + it("falls back to top-level token for non-default accounts without account token", () => { const cfg = { channels: { diff --git a/extensions/telegram/src/token.ts b/extensions/telegram/src/token.ts index c2482772c61..29d3298c72f 100644 --- a/extensions/telegram/src/token.ts +++ b/extensions/telegram/src/token.ts @@ -1,3 +1,4 @@ +import { resolveNormalizedAccountEntry } from "openclaw/plugin-sdk/account-resolution"; import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime"; @@ -28,17 +29,9 @@ export function resolveTelegramToken( // be normalized, so resolve per-account config by matching normalized IDs. const resolveAccountCfg = (id: string): TelegramAccountConfig | undefined => { const accounts = telegramCfg?.accounts; - if (!accounts || typeof accounts !== "object" || Array.isArray(accounts)) { - return undefined; - } - // Direct hit (already normalized key) - const direct = accounts[id]; - if (direct) { - return direct; - } - // Fallback: match by normalized key - const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === id); - return matchKey ? accounts[matchKey] : undefined; + return Array.isArray(accounts) + ? undefined + : resolveNormalizedAccountEntry(accounts, id, normalizeAccountId); }; const accountCfg = resolveAccountCfg( diff --git a/src/gateway/server-channels.ts b/src/gateway/server-channels.ts index 16cad24b07d..9496426cf5a 100644 --- a/src/gateway/server-channels.ts +++ b/src/gateway/server-channels.ts @@ -7,7 +7,7 @@ import { formatErrorMessage } from "../infra/errors.js"; import { resetDirectoryCache } from "../infra/outbound/target-resolver.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; -import { resolveAccountEntry } from "../routing/account-lookup.js"; +import { resolveAccountEntry, resolveNormalizedAccountEntry } from "../routing/account-lookup.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId, @@ -162,13 +162,15 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage if (!normalizedAccountId) { return undefined; } - const matchKey = Object.keys(channelConfig.accounts).find( - (key) => normalizeAccountId(key) === normalizedAccountId, + const match = resolveNormalizedAccountEntry( + channelConfig.accounts, + normalizedAccountId, + normalizeAccountId, ); - if (!matchKey) { + if (typeof match?.healthMonitor?.enabled !== "boolean") { return undefined; } - return channelConfig.accounts[matchKey]?.healthMonitor?.enabled; + return match.healthMonitor.enabled; }; const isHealthMonitorEnabled = (channelId: ChannelId, accountId: string): boolean => { diff --git a/src/plugin-sdk/account-resolution.ts b/src/plugin-sdk/account-resolution.ts index 38f3f25e69d..95527e3a082 100644 --- a/src/plugin-sdk/account-resolution.ts +++ b/src/plugin-sdk/account-resolution.ts @@ -6,7 +6,7 @@ export { mergeAccountConfig, } from "../channels/plugins/account-helpers.js"; export { normalizeChatType } from "../channels/chat-type.js"; -export { resolveAccountEntry } from "../routing/account-lookup.js"; +export { resolveAccountEntry, resolveNormalizedAccountEntry } from "../routing/account-lookup.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId, diff --git a/src/routing/account-lookup.test.ts b/src/routing/account-lookup.test.ts index 1960c8dd692..d44053e0f92 100644 --- a/src/routing/account-lookup.test.ts +++ b/src/routing/account-lookup.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveAccountEntry } from "./account-lookup.js"; +import { resolveAccountEntry, resolveNormalizedAccountEntry } from "./account-lookup.js"; describe("resolveAccountEntry", () => { it("resolves direct and case-insensitive account keys", () => { @@ -17,3 +17,26 @@ describe("resolveAccountEntry", () => { expect(resolveAccountEntry(accounts, "default")).toBeUndefined(); }); }); + +describe("resolveNormalizedAccountEntry", () => { + it("resolves normalized account keys with a custom normalizer", () => { + const accounts = { + "Ops Team": { id: "ops" }, + }; + + expect( + resolveNormalizedAccountEntry(accounts, "ops-team", (accountId) => + accountId.trim().toLowerCase().replaceAll(" ", "-"), + ), + ).toEqual({ id: "ops" }); + }); + + it("ignores prototype-chain values", () => { + const inherited = { default: { id: "polluted" } }; + const accounts = Object.create(inherited) as Record; + + expect( + resolveNormalizedAccountEntry(accounts, "default", (accountId) => accountId), + ).toBeUndefined(); + }); +}); diff --git a/src/routing/account-lookup.ts b/src/routing/account-lookup.ts index fc891306f67..c9b8d81a891 100644 --- a/src/routing/account-lookup.ts +++ b/src/routing/account-lookup.ts @@ -12,3 +12,19 @@ export function resolveAccountEntry( const matchKey = Object.keys(accounts).find((key) => key.toLowerCase() === normalized); return matchKey ? accounts[matchKey] : undefined; } + +export function resolveNormalizedAccountEntry( + accounts: Record | undefined, + accountId: string, + normalizeAccountId: (accountId: string) => string, +): T | undefined { + if (!accounts || typeof accounts !== "object") { + return undefined; + } + if (Object.hasOwn(accounts, accountId)) { + return accounts[accountId]; + } + const normalized = normalizeAccountId(accountId); + const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized); + return matchKey ? accounts[matchKey] : undefined; +}