Files
openclaw/extensions/twitch/src/setup-surface.ts
2026-03-16 20:17:13 -07:00

401 lines
12 KiB
TypeScript

/**
* 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<TwitchAccountConfig>,
): 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<string, unknown>)?.twitch as
| Record<string, unknown>
| undefined),
enabled: true,
accounts: {
...((
(cfg.channels as Record<string, unknown>)?.twitch as Record<string, unknown> | undefined
)?.accounts as Record<string, unknown> | undefined),
[DEFAULT_ACCOUNT_ID]: merged,
},
},
},
};
}
async function noteTwitchSetupHelp(prompter: WizardPrompter): Promise<void> {
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<string> {
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<string> {
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<string> {
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<string> {
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<ChannelSetupWizard["groupAccess"]> = {
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<string, unknown>)?.twitch as
| Record<string, unknown>
| undefined;
return {
...cfg,
channels: {
...cfg.channels,
twitch: { ...twitch, enabled: false },
},
};
},
};