@openclaw/twitch 2026.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Twitch onboarding adapter for CLI setup wizard.
3
+ */
4
+
5
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
6
+ import {
7
+ formatDocsLink,
8
+ promptChannelAccessConfig,
9
+ type ChannelOnboardingAdapter,
10
+ type ChannelOnboardingDmPolicy,
11
+ type WizardPrompter,
12
+ } from "openclaw/plugin-sdk";
13
+ import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js";
14
+ import type { TwitchAccountConfig, TwitchRole } from "./types.js";
15
+ import { isAccountConfigured } from "./utils/twitch.js";
16
+
17
+ const channel = "twitch" as const;
18
+
19
+ /**
20
+ * Set Twitch account configuration
21
+ */
22
+ function setTwitchAccount(
23
+ cfg: OpenClawConfig,
24
+ account: Partial<TwitchAccountConfig>,
25
+ ): OpenClawConfig {
26
+ const existing = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
27
+ const merged: TwitchAccountConfig = {
28
+ username: account.username ?? existing?.username ?? "",
29
+ accessToken: account.accessToken ?? existing?.accessToken ?? "",
30
+ clientId: account.clientId ?? existing?.clientId ?? "",
31
+ channel: account.channel ?? existing?.channel ?? "",
32
+ enabled: account.enabled ?? existing?.enabled ?? true,
33
+ allowFrom: account.allowFrom ?? existing?.allowFrom,
34
+ allowedRoles: account.allowedRoles ?? existing?.allowedRoles,
35
+ requireMention: account.requireMention ?? existing?.requireMention,
36
+ clientSecret: account.clientSecret ?? existing?.clientSecret,
37
+ refreshToken: account.refreshToken ?? existing?.refreshToken,
38
+ expiresIn: account.expiresIn ?? existing?.expiresIn,
39
+ obtainmentTimestamp: account.obtainmentTimestamp ?? existing?.obtainmentTimestamp,
40
+ };
41
+
42
+ return {
43
+ ...cfg,
44
+ channels: {
45
+ ...cfg.channels,
46
+ twitch: {
47
+ ...((cfg.channels as Record<string, unknown>)?.twitch as
48
+ | Record<string, unknown>
49
+ | undefined),
50
+ enabled: true,
51
+ accounts: {
52
+ ...((
53
+ (cfg.channels as Record<string, unknown>)?.twitch as Record<string, unknown> | undefined
54
+ )?.accounts as Record<string, unknown> | undefined),
55
+ [DEFAULT_ACCOUNT_ID]: merged,
56
+ },
57
+ },
58
+ },
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Note about Twitch setup
64
+ */
65
+ async function noteTwitchSetupHelp(prompter: WizardPrompter): Promise<void> {
66
+ await prompter.note(
67
+ [
68
+ "Twitch requires a bot account with OAuth token.",
69
+ "1. Create a Twitch application at https://dev.twitch.tv/console",
70
+ "2. Generate a token with scopes: chat:read and chat:write",
71
+ " Use https://twitchtokengenerator.com/ or https://twitchapps.com/tmi/",
72
+ "3. Copy the token (starts with 'oauth:') and Client ID",
73
+ "Env vars supported: OPENCLAW_TWITCH_ACCESS_TOKEN",
74
+ `Docs: ${formatDocsLink("/channels/twitch", "channels/twitch")}`,
75
+ ].join("\n"),
76
+ "Twitch setup",
77
+ );
78
+ }
79
+
80
+ /**
81
+ * Prompt for Twitch OAuth token with early returns.
82
+ */
83
+ async function promptToken(
84
+ prompter: WizardPrompter,
85
+ account: TwitchAccountConfig | null,
86
+ envToken: string | undefined,
87
+ ): Promise<string> {
88
+ const existingToken = account?.accessToken ?? "";
89
+
90
+ // If we have an existing token and no env var, ask if we should keep it
91
+ if (existingToken && !envToken) {
92
+ const keepToken = await prompter.confirm({
93
+ message: "Access token already configured. Keep it?",
94
+ initialValue: true,
95
+ });
96
+ if (keepToken) {
97
+ return existingToken;
98
+ }
99
+ }
100
+
101
+ // Prompt for new token
102
+ return String(
103
+ await prompter.text({
104
+ message: "Twitch OAuth token (oauth:...)",
105
+ initialValue: envToken ?? "",
106
+ validate: (value) => {
107
+ const raw = String(value ?? "").trim();
108
+ if (!raw) {
109
+ return "Required";
110
+ }
111
+ if (!raw.startsWith("oauth:")) {
112
+ return "Token should start with 'oauth:'";
113
+ }
114
+ return undefined;
115
+ },
116
+ }),
117
+ ).trim();
118
+ }
119
+
120
+ /**
121
+ * Prompt for Twitch username.
122
+ */
123
+ async function promptUsername(
124
+ prompter: WizardPrompter,
125
+ account: TwitchAccountConfig | null,
126
+ ): Promise<string> {
127
+ return String(
128
+ await prompter.text({
129
+ message: "Twitch bot username",
130
+ initialValue: account?.username ?? "",
131
+ validate: (value) => (value?.trim() ? undefined : "Required"),
132
+ }),
133
+ ).trim();
134
+ }
135
+
136
+ /**
137
+ * Prompt for Twitch Client ID.
138
+ */
139
+ async function promptClientId(
140
+ prompter: WizardPrompter,
141
+ account: TwitchAccountConfig | null,
142
+ ): Promise<string> {
143
+ return String(
144
+ await prompter.text({
145
+ message: "Twitch Client ID",
146
+ initialValue: account?.clientId ?? "",
147
+ validate: (value) => (value?.trim() ? undefined : "Required"),
148
+ }),
149
+ ).trim();
150
+ }
151
+
152
+ /**
153
+ * Prompt for optional channel name.
154
+ */
155
+ async function promptChannelName(
156
+ prompter: WizardPrompter,
157
+ account: TwitchAccountConfig | null,
158
+ ): Promise<string> {
159
+ const channelName = String(
160
+ await prompter.text({
161
+ message: "Channel to join",
162
+ initialValue: account?.channel ?? "",
163
+ validate: (value) => (value?.trim() ? undefined : "Required"),
164
+ }),
165
+ ).trim();
166
+ return channelName;
167
+ }
168
+
169
+ /**
170
+ * Prompt for token refresh credentials (client secret and refresh token).
171
+ */
172
+ async function promptRefreshTokenSetup(
173
+ prompter: WizardPrompter,
174
+ account: TwitchAccountConfig | null,
175
+ ): Promise<{ clientSecret?: string; refreshToken?: string }> {
176
+ const useRefresh = await prompter.confirm({
177
+ message: "Enable automatic token refresh (requires client secret and refresh token)?",
178
+ initialValue: Boolean(account?.clientSecret && account?.refreshToken),
179
+ });
180
+
181
+ if (!useRefresh) {
182
+ return {};
183
+ }
184
+
185
+ const clientSecret =
186
+ String(
187
+ await prompter.text({
188
+ message: "Twitch Client Secret (for token refresh)",
189
+ initialValue: account?.clientSecret ?? "",
190
+ validate: (value) => (value?.trim() ? undefined : "Required"),
191
+ }),
192
+ ).trim() || undefined;
193
+
194
+ const refreshToken =
195
+ String(
196
+ await prompter.text({
197
+ message: "Twitch Refresh Token",
198
+ initialValue: account?.refreshToken ?? "",
199
+ validate: (value) => (value?.trim() ? undefined : "Required"),
200
+ }),
201
+ ).trim() || undefined;
202
+
203
+ return { clientSecret, refreshToken };
204
+ }
205
+
206
+ /**
207
+ * Configure with env token path (returns early if user chooses env token).
208
+ */
209
+ async function configureWithEnvToken(
210
+ cfg: OpenClawConfig,
211
+ prompter: WizardPrompter,
212
+ account: TwitchAccountConfig | null,
213
+ envToken: string,
214
+ forceAllowFrom: boolean,
215
+ dmPolicy: ChannelOnboardingDmPolicy,
216
+ ): Promise<{ cfg: OpenClawConfig } | null> {
217
+ const useEnv = await prompter.confirm({
218
+ message: "Twitch env var OPENCLAW_TWITCH_ACCESS_TOKEN detected. Use env token?",
219
+ initialValue: true,
220
+ });
221
+ if (!useEnv) {
222
+ return null;
223
+ }
224
+
225
+ const username = await promptUsername(prompter, account);
226
+ const clientId = await promptClientId(prompter, account);
227
+
228
+ const cfgWithAccount = setTwitchAccount(cfg, {
229
+ username,
230
+ clientId,
231
+ accessToken: "", // Will use env var
232
+ enabled: true,
233
+ });
234
+
235
+ if (forceAllowFrom && dmPolicy.promptAllowFrom) {
236
+ return { cfg: await dmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter }) };
237
+ }
238
+
239
+ return { cfg: cfgWithAccount };
240
+ }
241
+
242
+ /**
243
+ * Set Twitch access control (role-based)
244
+ */
245
+ function setTwitchAccessControl(
246
+ cfg: OpenClawConfig,
247
+ allowedRoles: TwitchRole[],
248
+ requireMention: boolean,
249
+ ): OpenClawConfig {
250
+ const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
251
+ if (!account) {
252
+ return cfg;
253
+ }
254
+
255
+ return setTwitchAccount(cfg, {
256
+ ...account,
257
+ allowedRoles,
258
+ requireMention,
259
+ });
260
+ }
261
+
262
+ const dmPolicy: ChannelOnboardingDmPolicy = {
263
+ label: "Twitch",
264
+ channel,
265
+ policyKey: "channels.twitch.allowedRoles", // Twitch uses roles instead of DM policy
266
+ allowFromKey: "channels.twitch.accounts.default.allowFrom",
267
+ getCurrent: (cfg) => {
268
+ const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
269
+ // Map allowedRoles to policy equivalent
270
+ if (account?.allowedRoles?.includes("all")) {
271
+ return "open";
272
+ }
273
+ if (account?.allowFrom && account.allowFrom.length > 0) {
274
+ return "allowlist";
275
+ }
276
+ return "disabled";
277
+ },
278
+ setPolicy: (cfg, policy) => {
279
+ const allowedRoles: TwitchRole[] =
280
+ policy === "open" ? ["all"] : policy === "allowlist" ? [] : ["moderator"];
281
+ return setTwitchAccessControl(cfg, allowedRoles, true);
282
+ },
283
+ promptAllowFrom: async ({ cfg, prompter }) => {
284
+ const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
285
+ const existingAllowFrom = account?.allowFrom ?? [];
286
+
287
+ const entry = await prompter.text({
288
+ message: "Twitch allowFrom (user IDs, one per line, recommended for security)",
289
+ placeholder: "123456789",
290
+ initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
291
+ });
292
+
293
+ const allowFrom = String(entry ?? "")
294
+ .split(/[\n,;]+/g)
295
+ .map((s) => s.trim())
296
+ .filter(Boolean);
297
+
298
+ return setTwitchAccount(cfg, {
299
+ ...(account ?? undefined),
300
+ allowFrom,
301
+ });
302
+ },
303
+ };
304
+
305
+ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = {
306
+ channel,
307
+ getStatus: async ({ cfg }) => {
308
+ const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
309
+ const configured = account ? isAccountConfigured(account) : false;
310
+
311
+ return {
312
+ channel,
313
+ configured,
314
+ statusLines: [`Twitch: ${configured ? "configured" : "needs username, token, and clientId"}`],
315
+ selectionHint: configured ? "configured" : "needs setup",
316
+ };
317
+ },
318
+ configure: async ({ cfg, prompter, forceAllowFrom }) => {
319
+ const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
320
+
321
+ if (!account || !isAccountConfigured(account)) {
322
+ await noteTwitchSetupHelp(prompter);
323
+ }
324
+
325
+ const envToken = process.env.OPENCLAW_TWITCH_ACCESS_TOKEN?.trim();
326
+
327
+ // Check if env var is set and config is empty
328
+ if (envToken && !account?.accessToken) {
329
+ const envResult = await configureWithEnvToken(
330
+ cfg,
331
+ prompter,
332
+ account,
333
+ envToken,
334
+ forceAllowFrom,
335
+ dmPolicy,
336
+ );
337
+ if (envResult) {
338
+ return envResult;
339
+ }
340
+ }
341
+
342
+ // Prompt for credentials
343
+ const username = await promptUsername(prompter, account);
344
+ const token = await promptToken(prompter, account, envToken);
345
+ const clientId = await promptClientId(prompter, account);
346
+ const channelName = await promptChannelName(prompter, account);
347
+ const { clientSecret, refreshToken } = await promptRefreshTokenSetup(prompter, account);
348
+
349
+ const cfgWithAccount = setTwitchAccount(cfg, {
350
+ username,
351
+ accessToken: token,
352
+ clientId,
353
+ channel: channelName,
354
+ clientSecret,
355
+ refreshToken,
356
+ enabled: true,
357
+ });
358
+
359
+ const cfgWithAllowFrom =
360
+ forceAllowFrom && dmPolicy.promptAllowFrom
361
+ ? await dmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter })
362
+ : cfgWithAccount;
363
+
364
+ // Prompt for access control if allowFrom not set
365
+ if (!account?.allowFrom || account.allowFrom.length === 0) {
366
+ const accessConfig = await promptChannelAccessConfig({
367
+ prompter,
368
+ label: "Twitch chat",
369
+ currentPolicy: account?.allowedRoles?.includes("all")
370
+ ? "open"
371
+ : account?.allowedRoles?.includes("moderator")
372
+ ? "allowlist"
373
+ : "disabled",
374
+ currentEntries: [],
375
+ placeholder: "",
376
+ updatePrompt: false,
377
+ });
378
+
379
+ if (accessConfig) {
380
+ const allowedRoles: TwitchRole[] =
381
+ accessConfig.policy === "open"
382
+ ? ["all"]
383
+ : accessConfig.policy === "allowlist"
384
+ ? ["moderator", "vip"]
385
+ : [];
386
+
387
+ const cfgWithAccessControl = setTwitchAccessControl(cfgWithAllowFrom, allowedRoles, true);
388
+ return { cfg: cfgWithAccessControl };
389
+ }
390
+ }
391
+
392
+ return { cfg: cfgWithAllowFrom };
393
+ },
394
+ dmPolicy,
395
+ disable: (cfg) => {
396
+ const twitch = (cfg.channels as Record<string, unknown>)?.twitch as
397
+ | Record<string, unknown>
398
+ | undefined;
399
+ return {
400
+ ...cfg,
401
+ channels: {
402
+ ...cfg.channels,
403
+ twitch: { ...twitch, enabled: false },
404
+ },
405
+ };
406
+ },
407
+ };
408
+
409
+ // Export helper functions for testing
410
+ export {
411
+ promptToken,
412
+ promptUsername,
413
+ promptClientId,
414
+ promptChannelName,
415
+ promptRefreshTokenSetup,
416
+ configureWithEnvToken,
417
+ };