diff --git a/extensions/twitch/src/config.test.ts b/extensions/twitch/src/config.test.ts index 8cb7f02c468..db072c723a6 100644 --- a/extensions/twitch/src/config.test.ts +++ b/extensions/twitch/src/config.test.ts @@ -54,6 +54,30 @@ describe("getAccountConfig", () => { expect(result?.username).toBe("secondbot"); }); + it("normalizes account ids without reading inherited account properties", () => { + const accounts = Object.create({ + inherited: { + username: "inherited-bot", + accessToken: "oauth:inherited", + }, + }) as Record; + accounts.Secondary = { + username: "secondbot", + accessToken: "oauth:secondary", + }; + + const cfg = { + channels: { + twitch: { + accounts, + }, + }, + }; + + expect(getAccountConfig(cfg, "SECONDARY\r\n")).toMatchObject({ username: "secondbot" }); + expect(getAccountConfig(cfg, "inherited")).toBeNull(); + }); + it("returns null for non-existent account ID", () => { const result = getAccountConfig(mockMultiAccountConfig, "nonexistent"); @@ -120,6 +144,21 @@ describe("listAccountIds", () => { } as Parameters[0]), ).toEqual(["default", "secondary"]); }); + + it("normalizes configured account ids", () => { + expect( + listAccountIds({ + channels: { + twitch: { + accounts: { + Secondary: { username: "secondbot" }, + "Alerts\r\n\u001b[31m": { username: "alerts" }, + }, + }, + }, + } as Parameters[0]), + ).toEqual(["alerts-31m", "secondary"]); + }); }); describe("resolveDefaultTwitchAccountId", () => { diff --git a/extensions/twitch/src/config.ts b/extensions/twitch/src/config.ts index fa102de0ed8..bc653d93108 100644 --- a/extensions/twitch/src/config.ts +++ b/extensions/twitch/src/config.ts @@ -1,4 +1,8 @@ -import { listCombinedAccountIds } from "openclaw/plugin-sdk/account-resolution"; +import { + listCombinedAccountIds, + normalizeAccountId, + resolveNormalizedAccountEntry, +} from "openclaw/plugin-sdk/account-resolution"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveTwitchToken, type TwitchTokenResolution } from "./token.js"; import type { TwitchAccountConfig } from "./types.js"; @@ -36,14 +40,19 @@ export function getAccountConfig( } const cfg = coreConfig as OpenClawConfig; + const normalizedAccountId = normalizeAccountId(accountId); const twitch = cfg.channels?.twitch; // Access accounts via unknown to handle union type (single-account vs multi-account) const twitchRaw = twitch as Record | undefined; const accounts = twitchRaw?.accounts as Record | undefined; // For default account, check base-level config first - if (accountId === DEFAULT_ACCOUNT_ID) { - const accountFromAccounts = accounts?.[DEFAULT_ACCOUNT_ID]; + if (normalizedAccountId === DEFAULT_ACCOUNT_ID) { + const accountFromAccounts = resolveNormalizedAccountEntry( + accounts, + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + ); // Base-level properties that can form an implicit default account const baseLevel = { @@ -87,11 +96,12 @@ export function getAccountConfig( } // For non-default accounts, only check accounts object - if (!accounts || !accounts[accountId]) { + const account = resolveNormalizedAccountEntry(accounts, normalizedAccountId, normalizeAccountId); + if (!account) { return null; } - return accounts[accountId] as TwitchAccountConfig | null; + return account; } /** @@ -113,16 +123,19 @@ export function listAccountIds(cfg: OpenClawConfig): string[] { typeof twitchRaw.channel === "string"); return listCombinedAccountIds({ - configuredAccountIds: Object.keys(accountMap ?? {}), + configuredAccountIds: Object.keys(accountMap ?? {}).map((accountId) => + normalizeAccountId(accountId), + ), implicitAccountId: hasBaseLevelConfig ? DEFAULT_ACCOUNT_ID : undefined, }); } export function resolveDefaultTwitchAccountId(cfg: OpenClawConfig): string { - const preferred = + 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; @@ -137,7 +150,9 @@ export function resolveTwitchAccountContext( cfg: OpenClawConfig, accountId?: string | null, ): ResolvedTwitchAccountContext { - const resolvedAccountId = accountId?.trim() || resolveDefaultTwitchAccountId(cfg); + const resolvedAccountId = accountId?.trim() + ? normalizeAccountId(accountId) + : resolveDefaultTwitchAccountId(cfg); const account = getAccountConfig(cfg, resolvedAccountId); const tokenResolution = resolveTwitchToken(cfg, { accountId: resolvedAccountId }); return { diff --git a/extensions/twitch/src/setup-surface.test.ts b/extensions/twitch/src/setup-surface.test.ts index 3f2ef0f01c1..4336782eb9a 100644 --- a/extensions/twitch/src/setup-surface.test.ts +++ b/extensions/twitch/src/setup-surface.test.ts @@ -208,7 +208,7 @@ describe("setup surface helpers", () => { expect(defaultAccount?.clientId).toBe("test-client-id"); }); - it("writes env-token setup to the configured default account", async () => { + it("skips env-token shortcut for non-default accounts", async () => { mockPromptConfirm.mockReset().mockResolvedValue(true as never); mockPromptText .mockReset() @@ -230,12 +230,9 @@ describe("setup surface helpers", () => { {} as Parameters[5], ); - const secondaryAccount = result?.cfg.channels?.twitch?.accounts?.secondary as - | { username?: string; clientId?: string } - | undefined; - expect(secondaryAccount?.username).toBe("secondary-bot"); - expect(secondaryAccount?.clientId).toBe("secondary-client"); - expect(result?.cfg.channels?.twitch?.accounts?.default).toBeUndefined(); + expect(result).toBeNull(); + expect(mockPromptConfirm).not.toHaveBeenCalled(); + expect(mockPromptText).not.toHaveBeenCalled(); }); }); @@ -402,6 +399,41 @@ describe("setup surface helpers", () => { expect(twitch?.accounts?.secondary?.accessToken).toBe("oauth:secondary"); expect(twitch?.accounts?.default?.username).toBe("default-bot"); }); + + it("persists a token instead of using env-token shortcut for non-default finalize", async () => { + process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:fromenv"; + mockPromptText + .mockReset() + .mockResolvedValueOnce("secondary-bot" as never) + .mockResolvedValueOnce("oauth:persisted" as never) + .mockResolvedValueOnce("secondary-client" as never) + .mockResolvedValueOnce("#secondary" as never); + mockPromptConfirm.mockReset().mockResolvedValue(false as never); + + const result = await twitchSetupWizard.finalize?.({ + cfg: { + channels: { + twitch: { + accounts: {}, + }, + }, + } as Parameters>[0]["cfg"], + accountId: "secondary", + credentialValues: {}, + runtime: {} as Parameters>[0]["runtime"], + prompter: mockPrompter, + options: {}, + forceAllowFrom: false, + }); + + const twitch = result?.cfg?.channels?.twitch; + expect(twitch?.accounts?.secondary?.accessToken).toBe("oauth:persisted"); + expect(mockPromptConfirm).toHaveBeenCalledTimes(1); + expect(mockPromptConfirm).toHaveBeenCalledWith({ + message: "Enable automatic token refresh (requires client secret and refresh token)?", + initialValue: false, + }); + }); }); describe("setup-only plugin config", () => { @@ -431,5 +463,31 @@ describe("setup surface helpers", () => { expect(twitchSetupPlugin.config.listAccountIds(cfg)).toEqual(["default", "secondary"]); expect(twitchSetupPlugin.config.defaultAccountId?.(cfg)).toBe("secondary"); }); + + it("normalizes exposed account ids", () => { + const cfg = { + channels: { + twitch: { + accounts: { + Secondary: { + username: "secondary-bot", + accessToken: "oauth:secondary", + clientId: "secondary-client", + channel: "#secondary", + }, + }, + }, + }, + } as Parameters[0]; + + expect(twitchSetupPlugin.config.listAccountIds(cfg)).toEqual(["secondary"]); + expect(twitchSetupPlugin.config.defaultAccountId?.(cfg)).toBe("secondary"); + expect(twitchSetupPlugin.config.resolveAccount(cfg, "SECONDARY\r\n").accountId).toBe( + "secondary", + ); + expect(twitchSetupPlugin.config.resolveAccount(cfg, "SECONDARY\r\n").username).toBe( + "secondary-bot", + ); + }); }); }); diff --git a/extensions/twitch/src/setup-surface.ts b/extensions/twitch/src/setup-surface.ts index 818e16d6857..2ad4f540989 100644 --- a/extensions/twitch/src/setup-surface.ts +++ b/extensions/twitch/src/setup-surface.ts @@ -208,6 +208,11 @@ export async function configureWithEnvToken( dmPolicy: ChannelSetupDmPolicy, accountId: string = resolveSetupAccountId(cfg), ): Promise<{ cfg: OpenClawConfig } | null> { + const resolvedAccountId = normalizeAccountId(accountId); + if (resolvedAccountId !== DEFAULT_ACCOUNT_ID) { + return null; + } + const useEnv = await prompter.confirm({ message: "Twitch env var OPENCLAW_TWITCH_ACCESS_TOKEN detected. Use env token?", initialValue: true, @@ -227,11 +232,17 @@ export async function configureWithEnvToken( accessToken: "", enabled: true, }, - accountId, + resolvedAccountId, ); if (forceAllowFrom && dmPolicy.promptAllowFrom) { - return { cfg: await dmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter, accountId }) }; + return { + cfg: await dmPolicy.promptAllowFrom({ + cfg: cfgWithAccount, + prompter, + accountId: resolvedAccountId, + }), + }; } return { cfg: cfgWithAccount }; @@ -400,7 +411,7 @@ export const twitchSetupWizard: ChannelSetupWizard = { const envToken = process.env.OPENCLAW_TWITCH_ACCESS_TOKEN?.trim(); - if (envToken && !account?.accessToken) { + if (accountId === DEFAULT_ACCOUNT_ID && envToken && !account?.accessToken) { const envResult = await configureWithEnvToken( cfg, prompter, @@ -469,7 +480,7 @@ export const twitchSetupPlugin: ChannelPlugin = { config: { listAccountIds: (cfg) => listAccountIds(cfg), resolveAccount: (cfg, accountId) => { - const resolvedAccountId = accountId ?? resolveDefaultTwitchAccountId(cfg); + const resolvedAccountId = normalizeAccountId(accountId ?? resolveDefaultTwitchAccountId(cfg)); const account = getAccountConfig(cfg, resolvedAccountId); if (!account) { return {