@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.
- package/CHANGELOG.md +21 -0
- package/README.md +89 -0
- package/index.ts +20 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +20 -0
- package/src/access-control.test.ts +491 -0
- package/src/access-control.ts +166 -0
- package/src/actions.ts +174 -0
- package/src/client-manager-registry.ts +115 -0
- package/src/config-schema.ts +84 -0
- package/src/config.test.ts +87 -0
- package/src/config.ts +116 -0
- package/src/monitor.ts +273 -0
- package/src/onboarding.test.ts +316 -0
- package/src/onboarding.ts +417 -0
- package/src/outbound.test.ts +403 -0
- package/src/outbound.ts +187 -0
- package/src/plugin.test.ts +39 -0
- package/src/plugin.ts +274 -0
- package/src/probe.test.ts +196 -0
- package/src/probe.ts +119 -0
- package/src/resolver.ts +137 -0
- package/src/runtime.ts +14 -0
- package/src/send.test.ts +276 -0
- package/src/send.ts +136 -0
- package/src/status.test.ts +270 -0
- package/src/status.ts +179 -0
- package/src/test-fixtures.ts +30 -0
- package/src/token.test.ts +171 -0
- package/src/token.ts +91 -0
- package/src/twitch-client.test.ts +589 -0
- package/src/twitch-client.ts +277 -0
- package/src/types.ts +143 -0
- package/src/utils/markdown.ts +98 -0
- package/src/utils/twitch.ts +78 -0
- package/test/setup.ts +7 -0
|
@@ -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
|
+
};
|