diff --git a/extensions/twitch/src/setup-surface.test.ts b/extensions/twitch/src/setup-surface.test.ts index 106d9104cef..c79af47c526 100644 --- a/extensions/twitch/src/setup-surface.test.ts +++ b/extensions/twitch/src/setup-surface.test.ts @@ -28,9 +28,11 @@ 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; @@ -259,6 +261,35 @@ 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"; @@ -283,6 +314,47 @@ describe("setup surface helpers", () => { }); }); + describe("setup wizard account routing", () => { + 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"); + }); + }); + describe("setup-only plugin config", () => { it("lists all configured Twitch accounts", () => { const cfg = { diff --git a/extensions/twitch/src/setup-surface.ts b/extensions/twitch/src/setup-surface.ts index 8a03416de61..9359543cf83 100644 --- a/extensions/twitch/src/setup-surface.ts +++ b/extensions/twitch/src/setup-surface.ts @@ -28,6 +28,10 @@ function resolveSetupAccountId(cfg: OpenClawConfig): string { return preferred || resolveDefaultTwitchAccountId(cfg); } +function resolveSetupTargetAccountId(cfg: OpenClawConfig, accountId?: string | null): string { + return accountId?.trim() || resolveSetupAccountId(cfg); +} + export function setTwitchAccount( cfg: OpenClawConfig, account: Partial, @@ -199,6 +203,7 @@ export async function configureWithEnvToken( envToken: string, forceAllowFrom: boolean, dmPolicy: ChannelSetupDmPolicy, + accountId: string = resolveSetupAccountId(cfg), ): Promise<{ cfg: OpenClawConfig } | null> { const useEnv = await prompter.confirm({ message: "Twitch env var OPENCLAW_TWITCH_ACCESS_TOKEN detected. Use env token?", @@ -211,15 +216,19 @@ 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, + }, + accountId, + ); if (forceAllowFrom && dmPolicy.promptAllowFrom) { - return { cfg: await dmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter }) }; + return { cfg: await dmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter, accountId }) }; } return { cfg: cfgWithAccount }; @@ -229,9 +238,10 @@ function setTwitchAccessControl( cfg: OpenClawConfig, allowedRoles: TwitchRole[], requireMention: boolean, + accountId?: string | null, ): OpenClawConfig { - const accountId = resolveSetupAccountId(cfg); - const account = getAccountConfig(cfg, accountId); + const resolvedAccountId = resolveSetupTargetAccountId(cfg, accountId); + const account = getAccountConfig(cfg, resolvedAccountId); if (!account) { return cfg; } @@ -243,12 +253,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 | null, +): "open" | "allowlist" | "disabled" { + const account = getAccountConfig(cfg, resolveSetupTargetAccountId(cfg, accountId)); if (account?.allowedRoles?.includes("all")) { return "open"; } @@ -261,10 +274,11 @@ function resolveTwitchGroupPolicy(cfg: OpenClawConfig): "open" | "allowlist" | " function setTwitchGroupPolicy( cfg: OpenClawConfig, policy: "open" | "allowlist" | "disabled", + accountId?: string | null, ): 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 = { @@ -272,8 +286,8 @@ const twitchDmPolicy: ChannelSetupDmPolicy = { channel, policyKey: "channels.twitch.allowedRoles", allowFromKey: "channels.twitch.accounts..allowFrom", - getCurrent: (cfg) => { - const account = getAccountConfig(cfg, resolveSetupAccountId(cfg)); + getCurrent: (cfg, accountId) => { + const account = getAccountConfig(cfg, resolveSetupTargetAccountId(cfg, accountId)); if (account?.allowedRoles?.includes("all")) { return "open"; } @@ -282,14 +296,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 = resolveSetupTargetAccountId(cfg, accountId); + const account = getAccountConfig(cfg, resolvedAccountId); const existingAllowFrom = account?.allowFrom ?? []; const entry = await prompter.text({ @@ -309,7 +323,7 @@ const twitchDmPolicy: ChannelSetupDmPolicy = { ...(account ?? undefined), allowFrom, }, - accountId, + resolvedAccountId, ); }, }; @@ -318,16 +332,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, resolveSetupTargetAccountId(cfg, accountId)); return account?.allowFrom ?? []; }, - updatePrompt: ({ cfg }) => { - const account = getAccountConfig(cfg, resolveSetupAccountId(cfg)); + updatePrompt: ({ cfg, accountId }) => { + const account = getAccountConfig(cfg, resolveSetupTargetAccountId(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, }; @@ -346,27 +360,29 @@ export const twitchSetupAdapter: ChannelSetupAdapter = { export const twitchSetupWizard: ChannelSetupWizard = { channel, - resolveAccountIdForConfigure: ({ defaultAccountId }) => defaultAccountId, + resolveAccountIdForConfigure: ({ cfg, accountOverride }) => + resolveSetupTargetAccountId(cfg, accountOverride), resolveShouldPromptAccountIds: () => false, status: { configuredLabel: "configured", unconfiguredLabel: "needs username, token, and clientId", configuredHint: "configured", unconfiguredHint: "needs setup", - resolveConfigured: ({ cfg }) => { - return resolveTwitchAccountContext(cfg, resolveSetupAccountId(cfg)).configured; + resolveConfigured: ({ cfg, accountId }) => { + return resolveTwitchAccountContext(cfg, resolveSetupTargetAccountId(cfg, accountId)) + .configured; }, - resolveStatusLines: ({ cfg }) => { - const accountId = resolveSetupAccountId(cfg); - const configured = resolveTwitchAccountContext(cfg, accountId).configured; + resolveStatusLines: ({ cfg, accountId }) => { + const resolvedAccountId = resolveSetupTargetAccountId(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 = resolveSetupTargetAccountId(cfg, requestedAccountId); const account = getAccountConfig(cfg, accountId); if (!account || !isAccountConfigured(account)) { @@ -383,6 +399,7 @@ export const twitchSetupWizard: ChannelSetupWizard = { envToken, forceAllowFrom, twitchDmPolicy, + accountId, ); if (envResult) { return envResult; @@ -411,7 +428,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 };