/** * Twitch setup wizard surface for CLI setup. */ import { formatDocsLink, type ChannelSetupAdapter, type ChannelSetupDmPolicy, type ChannelSetupWizard, type OpenClawConfig, type WizardPrompter, } from "openclaw/plugin-sdk/setup"; import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js"; import type { TwitchAccountConfig, TwitchRole } from "./types.js"; import { isAccountConfigured } from "./utils/twitch.js"; const channel = "twitch" as const; export function setTwitchAccount( cfg: OpenClawConfig, account: Partial, ): OpenClawConfig { const existing = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID); const merged: TwitchAccountConfig = { username: account.username ?? existing?.username ?? "", accessToken: account.accessToken ?? existing?.accessToken ?? "", clientId: account.clientId ?? existing?.clientId ?? "", channel: account.channel ?? existing?.channel ?? "", enabled: account.enabled ?? existing?.enabled ?? true, allowFrom: account.allowFrom ?? existing?.allowFrom, allowedRoles: account.allowedRoles ?? existing?.allowedRoles, requireMention: account.requireMention ?? existing?.requireMention, clientSecret: account.clientSecret ?? existing?.clientSecret, refreshToken: account.refreshToken ?? existing?.refreshToken, expiresIn: account.expiresIn ?? existing?.expiresIn, obtainmentTimestamp: account.obtainmentTimestamp ?? existing?.obtainmentTimestamp, }; return { ...cfg, channels: { ...cfg.channels, twitch: { ...((cfg.channels as Record)?.twitch as | Record | undefined), enabled: true, accounts: { ...(( (cfg.channels as Record)?.twitch as Record | undefined )?.accounts as Record | undefined), [DEFAULT_ACCOUNT_ID]: merged, }, }, }, }; } async function noteTwitchSetupHelp(prompter: WizardPrompter): Promise { await prompter.note( [ "Twitch requires a bot account with OAuth token.", "1. Create a Twitch application at https://dev.twitch.tv/console", "2. Generate a token with scopes: chat:read and chat:write", " Use https://twitchtokengenerator.com/ or https://twitchapps.com/tmi/", "3. Copy the token (starts with 'oauth:') and Client ID", "Env vars supported: OPENCLAW_TWITCH_ACCESS_TOKEN", `Docs: ${formatDocsLink("/channels/twitch", "channels/twitch")}`, ].join("\n"), "Twitch setup", ); } export async function promptToken( prompter: WizardPrompter, account: TwitchAccountConfig | null, envToken: string | undefined, ): Promise { const existingToken = account?.accessToken ?? ""; if (existingToken && !envToken) { const keepToken = await prompter.confirm({ message: "Access token already configured. Keep it?", initialValue: true, }); if (keepToken) { return existingToken; } } return String( await prompter.text({ message: "Twitch OAuth token (oauth:...)", initialValue: envToken ?? "", validate: (value) => { const raw = String(value ?? "").trim(); if (!raw) { return "Required"; } if (!raw.startsWith("oauth:")) { return "Token should start with 'oauth:'"; } return undefined; }, }), ).trim(); } export async function promptUsername( prompter: WizardPrompter, account: TwitchAccountConfig | null, ): Promise { return String( await prompter.text({ message: "Twitch bot username", initialValue: account?.username ?? "", validate: (value) => (value?.trim() ? undefined : "Required"), }), ).trim(); } export async function promptClientId( prompter: WizardPrompter, account: TwitchAccountConfig | null, ): Promise { return String( await prompter.text({ message: "Twitch Client ID", initialValue: account?.clientId ?? "", validate: (value) => (value?.trim() ? undefined : "Required"), }), ).trim(); } export async function promptChannelName( prompter: WizardPrompter, account: TwitchAccountConfig | null, ): Promise { return String( await prompter.text({ message: "Channel to join", initialValue: account?.channel ?? "", validate: (value) => (value?.trim() ? undefined : "Required"), }), ).trim(); } export async function promptRefreshTokenSetup( prompter: WizardPrompter, account: TwitchAccountConfig | null, ): Promise<{ clientSecret?: string; refreshToken?: string }> { const useRefresh = await prompter.confirm({ message: "Enable automatic token refresh (requires client secret and refresh token)?", initialValue: Boolean(account?.clientSecret && account?.refreshToken), }); if (!useRefresh) { return {}; } const clientSecret = String( await prompter.text({ message: "Twitch Client Secret (for token refresh)", initialValue: account?.clientSecret ?? "", validate: (value) => (value?.trim() ? undefined : "Required"), }), ).trim() || undefined; const refreshToken = String( await prompter.text({ message: "Twitch Refresh Token", initialValue: account?.refreshToken ?? "", validate: (value) => (value?.trim() ? undefined : "Required"), }), ).trim() || undefined; return { clientSecret, refreshToken }; } export async function configureWithEnvToken( cfg: OpenClawConfig, prompter: WizardPrompter, account: TwitchAccountConfig | null, envToken: string, forceAllowFrom: boolean, dmPolicy: ChannelSetupDmPolicy, ): Promise<{ cfg: OpenClawConfig } | null> { const useEnv = await prompter.confirm({ message: "Twitch env var OPENCLAW_TWITCH_ACCESS_TOKEN detected. Use env token?", initialValue: true, }); if (!useEnv) { return null; } const username = await promptUsername(prompter, account); const clientId = await promptClientId(prompter, account); const cfgWithAccount = setTwitchAccount(cfg, { username, clientId, accessToken: "", enabled: true, }); if (forceAllowFrom && dmPolicy.promptAllowFrom) { return { cfg: await dmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter }) }; } return { cfg: cfgWithAccount }; } function setTwitchAccessControl( cfg: OpenClawConfig, allowedRoles: TwitchRole[], requireMention: boolean, ): OpenClawConfig { const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID); if (!account) { return cfg; } return setTwitchAccount(cfg, { ...account, allowedRoles, requireMention, }); } function resolveTwitchGroupPolicy(cfg: OpenClawConfig): "open" | "allowlist" | "disabled" { const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID); if (account?.allowedRoles?.includes("all")) { return "open"; } if (account?.allowedRoles?.includes("moderator")) { return "allowlist"; } return "disabled"; } function setTwitchGroupPolicy( cfg: OpenClawConfig, policy: "open" | "allowlist" | "disabled", ): OpenClawConfig { const allowedRoles: TwitchRole[] = policy === "open" ? ["all"] : policy === "allowlist" ? ["moderator", "vip"] : []; return setTwitchAccessControl(cfg, allowedRoles, true); } const twitchDmPolicy: ChannelSetupDmPolicy = { label: "Twitch", channel, policyKey: "channels.twitch.allowedRoles", allowFromKey: "channels.twitch.accounts.default.allowFrom", getCurrent: (cfg) => { const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); if (account?.allowedRoles?.includes("all")) { return "open"; } if (account?.allowFrom && account.allowFrom.length > 0) { return "allowlist"; } return "disabled"; }, setPolicy: (cfg, policy) => { const allowedRoles: TwitchRole[] = policy === "open" ? ["all"] : policy === "allowlist" ? [] : ["moderator"]; return setTwitchAccessControl(cfg as OpenClawConfig, allowedRoles, true); }, promptAllowFrom: async ({ cfg, prompter }) => { const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); const existingAllowFrom = account?.allowFrom ?? []; const entry = await prompter.text({ message: "Twitch allowFrom (user IDs, one per line, recommended for security)", placeholder: "123456789", initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, }); const allowFrom = String(entry ?? "") .split(/[\n,;]+/g) .map((s) => s.trim()) .filter(Boolean); return setTwitchAccount(cfg as OpenClawConfig, { ...(account ?? undefined), allowFrom, }); }, }; const twitchGroupAccess: NonNullable = { label: "Twitch chat", placeholder: "", skipAllowlistEntries: true, currentPolicy: ({ cfg }) => resolveTwitchGroupPolicy(cfg as OpenClawConfig), currentEntries: ({ cfg }) => { const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); return account?.allowFrom ?? []; }, updatePrompt: ({ cfg }) => { const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); return Boolean(account?.allowedRoles?.length || account?.allowFrom?.length); }, setPolicy: ({ cfg, policy }) => setTwitchGroupPolicy(cfg as OpenClawConfig, policy), resolveAllowlist: async () => [], applyAllowlist: ({ cfg }) => cfg as OpenClawConfig, }; export const twitchSetupAdapter: ChannelSetupAdapter = { resolveAccountId: () => DEFAULT_ACCOUNT_ID, applyAccountConfig: ({ cfg }) => setTwitchAccount(cfg, { enabled: true, }), }; export const twitchSetupWizard: ChannelSetupWizard = { channel, resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID, resolveShouldPromptAccountIds: () => false, status: { configuredLabel: "configured", unconfiguredLabel: "needs username, token, and clientId", configuredHint: "configured", unconfiguredHint: "needs setup", resolveConfigured: ({ cfg }) => { const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); return account ? isAccountConfigured(account) : false; }, resolveStatusLines: ({ cfg }) => { const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); const configured = account ? isAccountConfigured(account) : false; return [`Twitch: ${configured ? "configured" : "needs username, token, and clientId"}`]; }, }, credentials: [], finalize: async ({ cfg, prompter, forceAllowFrom }) => { const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); if (!account || !isAccountConfigured(account)) { await noteTwitchSetupHelp(prompter); } const envToken = process.env.OPENCLAW_TWITCH_ACCESS_TOKEN?.trim(); if (envToken && !account?.accessToken) { const envResult = await configureWithEnvToken( cfg as OpenClawConfig, prompter, account, envToken, forceAllowFrom, twitchDmPolicy, ); if (envResult) { return envResult; } } const username = await promptUsername(prompter, account); const token = await promptToken(prompter, account, envToken); const clientId = await promptClientId(prompter, account); const channelName = await promptChannelName(prompter, account); const { clientSecret, refreshToken } = await promptRefreshTokenSetup(prompter, account); const cfgWithAccount = setTwitchAccount(cfg as OpenClawConfig, { username, accessToken: token, clientId, channel: channelName, clientSecret, refreshToken, enabled: true, }); const cfgWithAllowFrom = forceAllowFrom && twitchDmPolicy.promptAllowFrom ? await twitchDmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter }) : cfgWithAccount; return { cfg: cfgWithAllowFrom }; }, dmPolicy: twitchDmPolicy, groupAccess: twitchGroupAccess, disable: (cfg) => { const twitch = (cfg.channels as Record)?.twitch as | Record | undefined; return { ...cfg, channels: { ...cfg.channels, twitch: { ...twitch, enabled: false }, }, }; }, };