From 6184f17c9148a15f5a5dc6566d5a930cd061356c Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 13:49:08 -0400 Subject: [PATCH] Twitch: add bundled setup entry (#68008) Merged via squash. Prepared head SHA: 59305356a0e7ba0c6b44530e743da32dd593e771 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + extensions/twitch/index.test.ts | 20 ++ extensions/twitch/package.json | 1 + extensions/twitch/setup-entry.ts | 9 + extensions/twitch/setup-plugin-api.ts | 3 + extensions/twitch/src/config.test.ts | 67 +++++ extensions/twitch/src/config.ts | 31 ++- extensions/twitch/src/setup-surface.test.ts | 272 +++++++++++++++++++- extensions/twitch/src/setup-surface.ts | 186 +++++++++---- extensions/twitch/src/token.test.ts | 21 ++ extensions/twitch/src/token.ts | 12 +- 11 files changed, 556 insertions(+), 67 deletions(-) create mode 100644 extensions/twitch/index.test.ts create mode 100644 extensions/twitch/setup-entry.ts create mode 100644 extensions/twitch/setup-plugin-api.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index eaaa2f8ba79..684d19f11c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - OpenAI Codex/OAuth: keep OpenClaw as the canonical owner for imported Codex CLI OAuth sessions, stop writing refreshed credentials back into `.codex`, and prefer fresher OpenClaw credentials over stale imported CLI state so refresh recovery stays stable. Thanks @vincentkoc. - OpenAI Codex/OAuth: treat the OpenAI TLS prerequisites probe as advisory instead of a hard blocker, so Codex sign-in can still proceed when the speculative Node/OpenSSL precheck fails but the real OAuth flow still works. Thanks @vincentkoc. - Models status/OAuth health: align OAuth health reporting with the same effective credential view runtime uses, so expired refreshable sessions stop showing healthy by default and fresher imported Codex CLI credentials surface correctly in `models status`, doctor, and gateway auth status. Thanks @vincentkoc. +- Twitch/setup: load Twitch through the bundled setup-entry discovery path and keep setup/status account detection aligned with runtime config. (#68008) Thanks @gumadeiras. ## 2026.4.15 diff --git a/extensions/twitch/index.test.ts b/extensions/twitch/index.test.ts new file mode 100644 index 00000000000..251bc173382 --- /dev/null +++ b/extensions/twitch/index.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { assertBundledChannelEntries } from "../../test/helpers/bundled-channel-entry.ts"; +import entry from "./index.js"; +import setupEntry from "./setup-entry.js"; + +describe("twitch bundled entries", () => { + assertBundledChannelEntries({ + entry, + expectedId: "twitch", + expectedName: "Twitch", + setupEntry, + }); + + it("loads the setup-only channel plugin", () => { + const plugin = setupEntry.loadSetupPlugin?.(); + + expect(plugin?.id).toBe("twitch"); + expect(plugin?.setupWizard).toBeDefined(); + }); +}); diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json index 2412de1b605..a47e9cb9238 100644 --- a/extensions/twitch/package.json +++ b/extensions/twitch/package.json @@ -15,6 +15,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "install": { "minHostVersion": ">=2026.4.10" }, diff --git a/extensions/twitch/setup-entry.ts b/extensions/twitch/setup-entry.ts new file mode 100644 index 00000000000..2c06653ec9a --- /dev/null +++ b/extensions/twitch/setup-entry.ts @@ -0,0 +1,9 @@ +import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract"; + +export default defineBundledChannelSetupEntry({ + importMetaUrl: import.meta.url, + plugin: { + specifier: "./setup-plugin-api.js", + exportName: "twitchSetupPlugin", + }, +}); diff --git a/extensions/twitch/setup-plugin-api.ts b/extensions/twitch/setup-plugin-api.ts new file mode 100644 index 00000000000..ed9ff5909df --- /dev/null +++ b/extensions/twitch/setup-plugin-api.ts @@ -0,0 +1,3 @@ +// Keep bundled setup entry imports narrow so setup loads do not pull the +// broader Twitch channel plugin surface. +export { twitchSetupPlugin } from "./src/setup-surface.js"; diff --git a/extensions/twitch/src/config.test.ts b/extensions/twitch/src/config.test.ts index 8cb7f02c468..97bb6989f50 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", () => { @@ -163,4 +202,32 @@ describe("resolveTwitchAccountContext", () => { expect(context.accountId).toBe("secondary"); expect(context.account?.username).toBe("second-bot"); }); + + it("keeps account and token lookup aligned after account id normalization", () => { + const context = resolveTwitchAccountContext( + { + channels: { + twitch: { + accounts: { + Secondary: { + username: "second-bot", + accessToken: "oauth:second-token", + clientId: "second-client", + channel: "#second", + }, + }, + }, + }, + } as Parameters[0], + "secondary", + ); + + expect(context.accountId).toBe("secondary"); + expect(context.account?.username).toBe("second-bot"); + expect(context.tokenResolution).toEqual({ + token: "oauth:second-token", + source: "config", + }); + expect(context.configured).toBe(true); + }); }); 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 3d35b8325bd..967eb3cfba2 100644 --- a/extensions/twitch/src/setup-surface.test.ts +++ b/extensions/twitch/src/setup-surface.test.ts @@ -20,6 +20,8 @@ import { promptRefreshTokenSetup, promptToken, promptUsername, + setTwitchAccount, + twitchSetupPlugin, twitchSetupWizard, } from "./setup-surface.js"; import type { TwitchAccountConfig } from "./types.js"; @@ -27,10 +29,13 @@ import type { TwitchAccountConfig } from "./types.js"; // Mock the helpers we're testing const mockPromptText = vi.fn(); const mockPromptConfirm = vi.fn(); +const mockPromptNote = vi.fn(); const mockPrompter: WizardPrompter = { text: mockPromptText, confirm: mockPromptConfirm, + note: mockPromptNote, } as unknown as WizardPrompter; +const originalEnvToken = process.env.OPENCLAW_TWITCH_ACCESS_TOKEN; const mockAccount: TwitchAccountConfig = { username: "testbot", @@ -45,6 +50,11 @@ describe("setup surface helpers", () => { }); afterEach(() => { + if (originalEnvToken === undefined) { + delete process.env.OPENCLAW_TWITCH_ACCESS_TOKEN; + } else { + process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = originalEnvToken; + } // Don't restoreAllMocks as it breaks module-level mocks }); @@ -198,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() @@ -220,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(); }); }); @@ -251,5 +258,256 @@ describe("setup surface helpers", () => { expect(lines).toEqual(["Twitch (secondary): configured"]); }); + + it("reports status for the requested account override", async () => { + const lines = twitchSetupWizard.status?.resolveStatusLines?.({ + cfg: { + channels: { + twitch: { + accounts: { + default: { + username: "default-bot", + accessToken: "oauth:default", + clientId: "default-client", + channel: "#default", + }, + secondary: { + username: "secondary-bot", + accessToken: "oauth:secondary", + clientId: "secondary-client", + channel: "#secondary", + }, + }, + }, + }, + }, + accountId: "secondary", + configured: true, + } as never); + + expect(lines).toEqual(["Twitch (secondary): configured"]); + }); + + it("reports env-token default account setup as configured", async () => { + process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:fromenv"; + + const cfg = { + channels: { + twitch: { + accounts: { + default: { + username: "env-bot", + accessToken: "", + clientId: "env-client", + channel: "#env", + }, + }, + }, + }, + } as Parameters["resolveConfigured"]>[0]["cfg"]; + + expect(twitchSetupWizard.status?.resolveConfigured({ cfg })).toBe(true); + const account = twitchSetupPlugin.config.resolveAccount(cfg, "default"); + expect(await twitchSetupPlugin.config.isConfigured?.(account, cfg)).toBe(true); + }); + }); + + describe("setup wizard account routing", () => { + it("rejects reserved account ids before using them as config keys", () => { + expect(() => + setTwitchAccount( + {} as Parameters[0], + { + username: "reserved-bot", + accessToken: "oauth:reserved", + clientId: "reserved-client", + channel: "#reserved", + }, + "__proto__", + ), + ).toThrow("Invalid Twitch account id"); + + expect(Object.prototype).not.toHaveProperty("username"); + }); + + it("rejects reserved account ids before env-token writes", async () => { + await expect( + configureWithEnvToken( + {} as Parameters[0], + mockPrompter, + null, + "oauth:fromenv", + false, + {} as Parameters[5], + "__proto__", + ), + ).rejects.toThrow("Invalid Twitch account id"); + + expect(mockPromptConfirm).not.toHaveBeenCalled(); + }); + + it("normalizes account ids before rendering status lines", () => { + expect( + twitchSetupWizard.status?.resolveStatusLines?.({ + cfg: {}, + accountId: "Alerts\r\n\u001b[31m", + configured: false, + } as never), + ).toEqual(["Twitch (alerts-31m): needs username, token, and clientId"]); + }); + + it("reports account-scoped DM policy config keys", () => { + expect( + twitchSetupWizard.dmPolicy?.resolveConfigKeys?.( + { + channels: { + twitch: { + defaultAccount: "secondary", + }, + }, + } as Parameters< + NonNullable["resolveConfigKeys"]> + >[0], + undefined, + ), + ).toEqual({ + policyKey: "channels.twitch.accounts.secondary.allowedRoles", + allowFromKey: "channels.twitch.accounts.secondary.allowFrom", + }); + + expect(twitchSetupWizard.dmPolicy?.resolveConfigKeys?.({} as never, "alerts")).toEqual({ + policyKey: "channels.twitch.accounts.alerts.allowedRoles", + allowFromKey: "channels.twitch.accounts.alerts.allowFrom", + }); + }); + + it("writes to the requested account when defaultAccount is not created yet", async () => { + mockPromptText + .mockReset() + .mockResolvedValueOnce("secondary-bot" as never) + .mockResolvedValueOnce("oauth:secondary" 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: { + defaultAccount: "secondary", + accounts: { + default: { + username: "default-bot", + accessToken: "oauth:default", + clientId: "default-client", + channel: "#default", + }, + }, + }, + }, + } 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?.username).toBe("secondary-bot"); + 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", () => { + it("lists all configured Twitch accounts", () => { + const cfg = { + channels: { + twitch: { + defaultAccount: "secondary", + accounts: { + default: { + username: "default-bot", + accessToken: "oauth:default", + clientId: "default-client", + channel: "#default", + }, + secondary: { + username: "secondary-bot", + accessToken: "oauth:secondary", + clientId: "secondary-client", + channel: "#secondary", + }, + }, + }, + }, + } as Parameters[0]; + + 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 45e60372be1..a464929c4a7 100644 --- a/extensions/twitch/src/setup-surface.ts +++ b/extensions/twitch/src/setup-surface.ts @@ -2,6 +2,8 @@ * Twitch setup wizard surface for CLI setup. */ +import { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id"; +import { getChatChannelMeta, type ChannelPlugin } from "openclaw/plugin-sdk/core"; import { formatDocsLink, type ChannelSetupAdapter, @@ -9,16 +11,37 @@ import { type ChannelSetupWizard, type OpenClawConfig, type WizardPrompter, + normalizeAccountId, } from "openclaw/plugin-sdk/setup"; -import { DEFAULT_ACCOUNT_ID, getAccountConfig, resolveDefaultTwitchAccountId } from "./config.js"; +import { + DEFAULT_ACCOUNT_ID, + getAccountConfig, + listAccountIds, + resolveDefaultTwitchAccountId, + resolveTwitchAccountContext, +} from "./config.js"; import type { TwitchAccountConfig, TwitchRole } from "./types.js"; import { isAccountConfigured } from "./utils/twitch.js"; const channel = "twitch" as const; +const INVALID_ACCOUNT_ID_MESSAGE = "Invalid Twitch account id"; + +function normalizeRequestedSetupAccountId(accountId: string): string { + const normalized = normalizeOptionalAccountId(accountId); + if (!normalized) { + throw new Error(INVALID_ACCOUNT_ID_MESSAGE); + } + return normalized; +} + +function resolveSetupAccountId(cfg: OpenClawConfig, requestedAccountId?: string): string { + const requested = requestedAccountId?.trim(); + if (requested) { + return normalizeRequestedSetupAccountId(requested); + } -function resolveSetupAccountId(cfg: OpenClawConfig): string { const preferred = cfg.channels?.twitch?.defaultAccount?.trim(); - return preferred || resolveDefaultTwitchAccountId(cfg); + return preferred ? normalizeAccountId(preferred) : resolveDefaultTwitchAccountId(cfg); } export function setTwitchAccount( @@ -26,7 +49,10 @@ export function setTwitchAccount( account: Partial, accountId: string = resolveSetupAccountId(cfg), ): OpenClawConfig { - const existing = getAccountConfig(cfg, accountId); + const resolvedAccountId = accountId.trim() + ? normalizeRequestedSetupAccountId(accountId) + : resolveSetupAccountId(cfg); + const existing = getAccountConfig(cfg, resolvedAccountId); const merged: TwitchAccountConfig = { username: account.username ?? existing?.username ?? "", accessToken: account.accessToken ?? existing?.accessToken ?? "", @@ -55,7 +81,7 @@ export function setTwitchAccount( ...(( (cfg.channels as Record)?.twitch as Record | undefined )?.accounts as Record | undefined), - [accountId]: merged, + [resolvedAccountId]: merged, }, }, }, @@ -192,7 +218,15 @@ export async function configureWithEnvToken( envToken: string, forceAllowFrom: boolean, dmPolicy: ChannelSetupDmPolicy, + accountId: string = resolveSetupAccountId(cfg), ): Promise<{ cfg: OpenClawConfig } | null> { + const resolvedAccountId = accountId.trim() + ? normalizeRequestedSetupAccountId(accountId) + : resolveSetupAccountId(cfg); + 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, @@ -204,15 +238,25 @@ export async function configureWithEnvToken( const username = await promptUsername(prompter, account); const clientId = await promptClientId(prompter, account); - const cfgWithAccount = setTwitchAccount(cfg, { - username, - clientId, - accessToken: "", - enabled: true, - }); + const cfgWithAccount = setTwitchAccount( + cfg, + { + username, + clientId, + accessToken: "", + enabled: true, + }, + resolvedAccountId, + ); if (forceAllowFrom && dmPolicy.promptAllowFrom) { - return { cfg: await dmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter }) }; + return { + cfg: await dmPolicy.promptAllowFrom({ + cfg: cfgWithAccount, + prompter, + accountId: resolvedAccountId, + }), + }; } return { cfg: cfgWithAccount }; @@ -222,9 +266,10 @@ function setTwitchAccessControl( cfg: OpenClawConfig, allowedRoles: TwitchRole[], requireMention: boolean, + accountId?: string, ): OpenClawConfig { - const accountId = resolveSetupAccountId(cfg); - const account = getAccountConfig(cfg, accountId); + const resolvedAccountId = resolveSetupAccountId(cfg, accountId); + const account = getAccountConfig(cfg, resolvedAccountId); if (!account) { return cfg; } @@ -236,12 +281,15 @@ function setTwitchAccessControl( allowedRoles, requireMention, }, - accountId, + resolvedAccountId, ); } -function resolveTwitchGroupPolicy(cfg: OpenClawConfig): "open" | "allowlist" | "disabled" { - const account = getAccountConfig(cfg, resolveSetupAccountId(cfg)); +function resolveTwitchGroupPolicy( + cfg: OpenClawConfig, + accountId?: string, +): "open" | "allowlist" | "disabled" { + const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId)); if (account?.allowedRoles?.includes("all")) { return "open"; } @@ -254,19 +302,27 @@ function resolveTwitchGroupPolicy(cfg: OpenClawConfig): "open" | "allowlist" | " function setTwitchGroupPolicy( cfg: OpenClawConfig, policy: "open" | "allowlist" | "disabled", + accountId?: string, ): OpenClawConfig { const allowedRoles: TwitchRole[] = policy === "open" ? ["all"] : policy === "allowlist" ? ["moderator", "vip"] : []; - return setTwitchAccessControl(cfg, allowedRoles, true); + return setTwitchAccessControl(cfg, allowedRoles, true, accountId); } const twitchDmPolicy: ChannelSetupDmPolicy = { label: "Twitch", channel, - policyKey: "channels.twitch.allowedRoles", - allowFromKey: "channels.twitch.accounts..allowFrom", - getCurrent: (cfg) => { - const account = getAccountConfig(cfg, resolveSetupAccountId(cfg)); + policyKey: "channels.twitch.accounts.default.allowedRoles", + allowFromKey: "channels.twitch.accounts.default.allowFrom", + resolveConfigKeys: (cfg, accountId) => { + const resolvedAccountId = resolveSetupAccountId(cfg, accountId); + return { + policyKey: `channels.twitch.accounts.${resolvedAccountId}.allowedRoles`, + allowFromKey: `channels.twitch.accounts.${resolvedAccountId}.allowFrom`, + }; + }, + getCurrent: (cfg, accountId) => { + const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId)); if (account?.allowedRoles?.includes("all")) { return "open"; } @@ -275,14 +331,14 @@ const twitchDmPolicy: ChannelSetupDmPolicy = { } return "disabled"; }, - setPolicy: (cfg, policy) => { + setPolicy: (cfg, policy, accountId) => { const allowedRoles: TwitchRole[] = policy === "open" ? ["all"] : policy === "allowlist" ? [] : ["moderator"]; - return setTwitchAccessControl(cfg, allowedRoles, true); + return setTwitchAccessControl(cfg, allowedRoles, true, accountId); }, - promptAllowFrom: async ({ cfg, prompter }) => { - const accountId = resolveSetupAccountId(cfg); - const account = getAccountConfig(cfg, accountId); + promptAllowFrom: async ({ cfg, prompter, accountId }) => { + const resolvedAccountId = resolveSetupAccountId(cfg, accountId); + const account = getAccountConfig(cfg, resolvedAccountId); const existingAllowFrom = account?.allowFrom ?? []; const entry = await prompter.text({ @@ -302,7 +358,7 @@ const twitchDmPolicy: ChannelSetupDmPolicy = { ...(account ?? undefined), allowFrom, }, - accountId, + resolvedAccountId, ); }, }; @@ -311,16 +367,16 @@ const twitchGroupAccess: NonNullable = { label: "Twitch chat", placeholder: "", skipAllowlistEntries: true, - currentPolicy: ({ cfg }) => resolveTwitchGroupPolicy(cfg), - currentEntries: ({ cfg }) => { - const account = getAccountConfig(cfg, resolveSetupAccountId(cfg)); + currentPolicy: ({ cfg, accountId }) => resolveTwitchGroupPolicy(cfg, accountId), + currentEntries: ({ cfg, accountId }) => { + const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId)); return account?.allowFrom ?? []; }, - updatePrompt: ({ cfg }) => { - const account = getAccountConfig(cfg, resolveSetupAccountId(cfg)); + updatePrompt: ({ cfg, accountId }) => { + const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId)); return Boolean(account?.allowedRoles?.length || account?.allowFrom?.length); }, - setPolicy: ({ cfg, policy }) => setTwitchGroupPolicy(cfg, policy), + setPolicy: ({ cfg, accountId, policy }) => setTwitchGroupPolicy(cfg, policy, accountId), resolveAllowlist: async () => [], applyAllowlist: ({ cfg }) => cfg, }; @@ -339,29 +395,28 @@ export const twitchSetupAdapter: ChannelSetupAdapter = { export const twitchSetupWizard: ChannelSetupWizard = { channel, - resolveAccountIdForConfigure: ({ defaultAccountId }) => defaultAccountId, + resolveAccountIdForConfigure: ({ cfg, accountOverride }) => + resolveSetupAccountId(cfg, accountOverride), resolveShouldPromptAccountIds: () => false, status: { configuredLabel: "configured", unconfiguredLabel: "needs username, token, and clientId", configuredHint: "configured", unconfiguredHint: "needs setup", - resolveConfigured: ({ cfg }) => { - const account = getAccountConfig(cfg, resolveSetupAccountId(cfg)); - return account ? isAccountConfigured(account) : false; + resolveConfigured: ({ cfg, accountId }) => { + return resolveTwitchAccountContext(cfg, resolveSetupAccountId(cfg, accountId)).configured; }, - resolveStatusLines: ({ cfg }) => { - const accountId = resolveSetupAccountId(cfg); - const account = getAccountConfig(cfg, accountId); - const configured = account ? isAccountConfigured(account) : false; + resolveStatusLines: ({ cfg, accountId }) => { + const resolvedAccountId = resolveSetupAccountId(cfg, accountId); + const configured = resolveTwitchAccountContext(cfg, resolvedAccountId).configured; return [ - `Twitch${accountId !== DEFAULT_ACCOUNT_ID ? ` (${accountId})` : ""}: ${configured ? "configured" : "needs username, token, and clientId"}`, + `Twitch${resolvedAccountId !== DEFAULT_ACCOUNT_ID ? ` (${resolvedAccountId})` : ""}: ${configured ? "configured" : "needs username, token, and clientId"}`, ]; }, }, credentials: [], - finalize: async ({ cfg, prompter, forceAllowFrom }) => { - const accountId = resolveSetupAccountId(cfg); + finalize: async ({ cfg, accountId: requestedAccountId, prompter, forceAllowFrom }) => { + const accountId = resolveSetupAccountId(cfg, requestedAccountId); const account = getAccountConfig(cfg, accountId); if (!account || !isAccountConfigured(account)) { @@ -370,7 +425,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, @@ -378,6 +433,7 @@ export const twitchSetupWizard: ChannelSetupWizard = { envToken, forceAllowFrom, twitchDmPolicy, + accountId, ); if (envResult) { return envResult; @@ -406,7 +462,7 @@ export const twitchSetupWizard: ChannelSetupWizard = { const cfgWithAllowFrom = forceAllowFrom && twitchDmPolicy.promptAllowFrom - ? await twitchDmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter }) + ? await twitchDmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter, accountId }) : cfgWithAccount; return { cfg: cfgWithAllowFrom }; @@ -426,3 +482,39 @@ export const twitchSetupWizard: ChannelSetupWizard = { }; }, }; + +type ResolvedTwitchAccount = TwitchAccountConfig & { accountId?: string | null }; + +export const twitchSetupPlugin: ChannelPlugin = { + 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, + }, + setup: twitchSetupAdapter, + setupWizard: twitchSetupWizard, +}; diff --git a/extensions/twitch/src/token.test.ts b/extensions/twitch/src/token.test.ts index ac9c96f5221..1e33dc6bdea 100644 --- a/extensions/twitch/src/token.test.ts +++ b/extensions/twitch/src/token.test.ts @@ -65,6 +65,27 @@ describe("token", () => { expect(result.source).toBe("config"); }); + it("should resolve token from normalized account id", () => { + const result = resolveTwitchToken( + { + channels: { + twitch: { + accounts: { + Secondary: { + username: "secondary", + accessToken: "oauth:secondary-token", + }, + }, + }, + }, + } as unknown as OpenClawConfig, + { accountId: "secondary" }, + ); + + expect(result.token).toBe("oauth:secondary-token"); + expect(result.source).toBe("config"); + }); + it("should prioritize config token over env var (simplified config)", () => { process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:env-token"; diff --git a/extensions/twitch/src/token.ts b/extensions/twitch/src/token.ts index 40ba98e6da0..13ef6ec20c2 100644 --- a/extensions/twitch/src/token.ts +++ b/extensions/twitch/src/token.ts @@ -9,8 +9,12 @@ * 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only) */ +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + resolveNormalizedAccountEntry, +} from "openclaw/plugin-sdk/account-resolution"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; export type TwitchTokenSource = "env" | "config" | "none"; @@ -56,10 +60,8 @@ export function resolveTwitchToken( // Get merged account config (handles both simplified and multi-account patterns) const twitchCfg = cfg?.channels?.twitch; - const accountCfg = - accountId === DEFAULT_ACCOUNT_ID - ? (twitchCfg?.accounts?.[DEFAULT_ACCOUNT_ID] as Record | undefined) - : (twitchCfg?.accounts?.[accountId] as Record | undefined); + const accounts = twitchCfg?.accounts as Record> | undefined; + const accountCfg = resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId); // For default account, also check base-level config let token: string | undefined;