@openclaw/twitch 2026.5.2 → 2026.5.3-beta.2

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.
Files changed (54) hide show
  1. package/dist/api.js +3 -0
  2. package/dist/channel-plugin-api.js +2 -0
  3. package/dist/index.js +18 -0
  4. package/dist/markdown-MRdI1sR7.js +306 -0
  5. package/dist/monitor-DS0YTAPB.js +333 -0
  6. package/dist/plugin-BQX9GiIn.js +878 -0
  7. package/dist/runtime-QZ5I3GlI.js +8 -0
  8. package/dist/runtime-api.js +1 -0
  9. package/dist/setup-entry.js +11 -0
  10. package/dist/setup-plugin-api.js +2 -0
  11. package/dist/setup-surface-yArVgckI.js +400 -0
  12. package/dist/twitch-CklAMZL5.js +131 -0
  13. package/package.json +20 -3
  14. package/api.ts +0 -21
  15. package/channel-plugin-api.ts +0 -1
  16. package/index.test.ts +0 -13
  17. package/index.ts +0 -16
  18. package/runtime-api.ts +0 -22
  19. package/setup-entry.ts +0 -9
  20. package/setup-plugin-api.ts +0 -3
  21. package/src/access-control.test.ts +0 -388
  22. package/src/access-control.ts +0 -173
  23. package/src/actions.test.ts +0 -74
  24. package/src/actions.ts +0 -175
  25. package/src/client-manager-registry.ts +0 -87
  26. package/src/config-schema.test.ts +0 -46
  27. package/src/config-schema.ts +0 -88
  28. package/src/config.test.ts +0 -233
  29. package/src/config.ts +0 -177
  30. package/src/monitor.ts +0 -314
  31. package/src/outbound.test.ts +0 -468
  32. package/src/outbound.ts +0 -186
  33. package/src/plugin.test.ts +0 -77
  34. package/src/plugin.ts +0 -208
  35. package/src/probe.test.ts +0 -196
  36. package/src/probe.ts +0 -130
  37. package/src/resolver.ts +0 -139
  38. package/src/runtime.ts +0 -9
  39. package/src/send.test.ts +0 -309
  40. package/src/send.ts +0 -139
  41. package/src/setup-surface.test.ts +0 -511
  42. package/src/setup-surface.ts +0 -520
  43. package/src/status.test.ts +0 -237
  44. package/src/status.ts +0 -179
  45. package/src/test-fixtures.ts +0 -30
  46. package/src/token.test.ts +0 -192
  47. package/src/token.ts +0 -93
  48. package/src/twitch-client.test.ts +0 -582
  49. package/src/twitch-client.ts +0 -276
  50. package/src/types.ts +0 -104
  51. package/src/utils/markdown.ts +0 -98
  52. package/src/utils/twitch.ts +0 -84
  53. package/test/setup.ts +0 -7
  54. package/tsconfig.json +0 -16
@@ -0,0 +1,878 @@
1
+ import { a as normalizeTwitchChannel, i as normalizeToken, n as isAccountConfigured, o as resolveTwitchToken, r as missingTargetError, t as generateMessageId } from "./twitch-CklAMZL5.js";
2
+ import { a as getAccountConfig, c as resolveTwitchAccountContext, i as DEFAULT_ACCOUNT_ID, l as resolveTwitchSnapshotAccountId, o as listAccountIds, r as twitchSetupWizard, s as resolveDefaultTwitchAccountId, t as twitchSetupAdapter } from "./setup-surface-yArVgckI.js";
3
+ import { a as removeClientManager, n as stripMarkdownForTwitch, r as getClientManager, t as chunkTextForTwitch } from "./markdown-MRdI1sR7.js";
4
+ import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
5
+ import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
6
+ import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
7
+ import { createLoggedPairingApprovalNotifier, createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
8
+ import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
9
+ import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState } from "openclaw/plugin-sdk/status-helpers";
10
+ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
11
+ import { StaticAuthProvider } from "@twurple/auth";
12
+ import { ChatClient } from "@twurple/chat";
13
+ import { MarkdownConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
14
+ import { z } from "openclaw/plugin-sdk/zod";
15
+ import { ApiClient } from "@twurple/api";
16
+ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
17
+ //#region extensions/twitch/src/send.ts
18
+ /**
19
+ * Internal send function used by the outbound adapter.
20
+ *
21
+ * This function has access to the full OpenClaw config and handles
22
+ * account resolution, markdown stripping, and actual message sending.
23
+ *
24
+ * @param channel - The channel name
25
+ * @param text - The message text
26
+ * @param cfg - Full OpenClaw configuration
27
+ * @param accountId - Account ID to use
28
+ * @param stripMarkdown - Whether to strip markdown (default: true)
29
+ * @param logger - Logger instance
30
+ * @returns Result with message ID and status
31
+ *
32
+ * @example
33
+ * const result = await sendMessageTwitchInternal(
34
+ * "#mychannel",
35
+ * "Hello Twitch!",
36
+ * openclawConfig,
37
+ * "default",
38
+ * true,
39
+ * console,
40
+ * );
41
+ */
42
+ async function sendMessageTwitchInternal(channel, text, cfg, accountId, stripMarkdown = true, logger = console) {
43
+ const { account, configured, availableAccountIds, accountId: resolvedAccountId } = resolveTwitchAccountContext(cfg, accountId);
44
+ if (!account) return {
45
+ ok: false,
46
+ messageId: generateMessageId(),
47
+ error: `Account not found: ${accountId ?? "(default)"}. Available accounts: ${availableAccountIds.join(", ") || "none"}`
48
+ };
49
+ if (!configured) return {
50
+ ok: false,
51
+ messageId: generateMessageId(),
52
+ error: `Account ${resolvedAccountId} is not properly configured. Required: username, clientId, and token (config or env for default account).`
53
+ };
54
+ const normalizedChannel = channel || account.channel;
55
+ if (!normalizedChannel) return {
56
+ ok: false,
57
+ messageId: generateMessageId(),
58
+ error: "No channel specified and no default channel in account config"
59
+ };
60
+ const cleanedText = stripMarkdown ? stripMarkdownForTwitch(text) : text;
61
+ if (!cleanedText) return {
62
+ ok: true,
63
+ messageId: "skipped"
64
+ };
65
+ const clientManager = getClientManager(resolvedAccountId);
66
+ if (!clientManager) return {
67
+ ok: false,
68
+ messageId: generateMessageId(),
69
+ error: `Client manager not found for account: ${resolvedAccountId}. Please start the Twitch gateway first.`
70
+ };
71
+ try {
72
+ const result = await clientManager.sendMessage(account, normalizeTwitchChannel(normalizedChannel), cleanedText, cfg, resolvedAccountId);
73
+ if (!result.ok) return {
74
+ ok: false,
75
+ messageId: result.messageId ?? generateMessageId(),
76
+ error: result.error ?? "Send failed"
77
+ };
78
+ return {
79
+ ok: true,
80
+ messageId: result.messageId ?? generateMessageId()
81
+ };
82
+ } catch (error) {
83
+ const errorMsg = formatErrorMessage(error);
84
+ logger.error(`Failed to send message: ${errorMsg}`);
85
+ return {
86
+ ok: false,
87
+ messageId: generateMessageId(),
88
+ error: errorMsg
89
+ };
90
+ }
91
+ }
92
+ //#endregion
93
+ //#region extensions/twitch/src/outbound.ts
94
+ /**
95
+ * Twitch outbound adapter for sending messages.
96
+ *
97
+ * Implements the ChannelOutboundAdapter interface for Twitch chat.
98
+ * Supports text and media (URL) sending with markdown stripping and chunking.
99
+ */
100
+ /**
101
+ * Twitch outbound adapter.
102
+ *
103
+ * Handles sending text and media to Twitch channels with automatic
104
+ * markdown stripping and message chunking.
105
+ */
106
+ const twitchOutbound = {
107
+ /** Direct delivery mode - messages are sent immediately */
108
+ deliveryMode: "direct",
109
+ /** Twitch chat message limit is 500 characters */
110
+ textChunkLimit: 500,
111
+ /** Word-boundary chunker with markdown stripping */
112
+ chunker: chunkTextForTwitch,
113
+ /**
114
+ * Resolve target from context.
115
+ *
116
+ * Handles target resolution with allowlist support for implicit/heartbeat modes.
117
+ * For explicit mode, accepts any valid channel name.
118
+ *
119
+ * @param params - Resolution parameters
120
+ * @returns Resolved target or error
121
+ */
122
+ resolveTarget: ({ to, allowFrom, mode }) => {
123
+ const trimmed = to?.trim() ?? "";
124
+ const allowListRaw = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean);
125
+ const hasWildcard = allowListRaw.includes("*");
126
+ const allowList = allowListRaw.filter((entry) => entry !== "*").map((entry) => normalizeTwitchChannel(entry)).filter((entry) => entry.length > 0);
127
+ if (trimmed) {
128
+ const normalizedTo = normalizeTwitchChannel(trimmed);
129
+ if (!normalizedTo) return {
130
+ ok: false,
131
+ error: missingTargetError("Twitch", "<channel-name>")
132
+ };
133
+ if (mode === "implicit" || mode === "heartbeat") {
134
+ if (hasWildcard || allowList.length === 0) return {
135
+ ok: true,
136
+ to: normalizedTo
137
+ };
138
+ if (allowList.includes(normalizedTo)) return {
139
+ ok: true,
140
+ to: normalizedTo
141
+ };
142
+ return {
143
+ ok: false,
144
+ error: missingTargetError("Twitch", "<channel-name>")
145
+ };
146
+ }
147
+ return {
148
+ ok: true,
149
+ to: normalizedTo
150
+ };
151
+ }
152
+ return {
153
+ ok: false,
154
+ error: missingTargetError("Twitch", "<channel-name>")
155
+ };
156
+ },
157
+ /**
158
+ * Send a text message to a Twitch channel.
159
+ *
160
+ * Strips markdown if enabled, validates account configuration,
161
+ * and sends the message via the Twitch client.
162
+ *
163
+ * @param params - Send parameters including target, text, and config
164
+ * @returns Delivery result with message ID and status
165
+ *
166
+ * @example
167
+ * const result = await twitchOutbound.sendText({
168
+ * cfg: openclawConfig,
169
+ * to: "#mychannel",
170
+ * text: "Hello Twitch!",
171
+ * accountId: "default",
172
+ * });
173
+ */
174
+ sendText: async (params) => {
175
+ const { cfg, to, text, accountId } = params;
176
+ if (params.signal?.aborted) throw new Error("Outbound delivery aborted");
177
+ const resolvedAccountId = accountId ?? resolveTwitchAccountContext(cfg).accountId;
178
+ const { account, availableAccountIds } = resolveTwitchAccountContext(cfg, resolvedAccountId);
179
+ if (!account) throw new Error(`Twitch account not found: ${resolvedAccountId}. Available accounts: ${availableAccountIds.join(", ") || "none"}`);
180
+ const channel = to || account.channel;
181
+ if (!channel) throw new Error("No channel specified and no default channel in account config");
182
+ const result = await sendMessageTwitchInternal(normalizeTwitchChannel(channel), text, cfg, resolvedAccountId, true, console);
183
+ if (!result.ok) throw new Error(result.error ?? "Send failed");
184
+ return {
185
+ channel: "twitch",
186
+ messageId: result.messageId,
187
+ timestamp: Date.now()
188
+ };
189
+ },
190
+ /**
191
+ * Send media to a Twitch channel.
192
+ *
193
+ * Note: Twitch chat doesn't support direct media uploads.
194
+ * This sends the media URL as text instead.
195
+ *
196
+ * @param params - Send parameters including media URL
197
+ * @returns Delivery result with message ID and status
198
+ *
199
+ * @example
200
+ * const result = await twitchOutbound.sendMedia({
201
+ * cfg: openclawConfig,
202
+ * to: "#mychannel",
203
+ * text: "Check this out!",
204
+ * mediaUrl: "https://example.com/image.png",
205
+ * accountId: "default",
206
+ * });
207
+ */
208
+ sendMedia: async (params) => {
209
+ const { text, mediaUrl } = params;
210
+ if (params.signal?.aborted) throw new Error("Outbound delivery aborted");
211
+ const message = mediaUrl ? `${text || ""} ${mediaUrl}`.trim() : text;
212
+ if (!twitchOutbound.sendText) throw new Error("sendText not implemented");
213
+ return twitchOutbound.sendText({
214
+ ...params,
215
+ text: message
216
+ });
217
+ }
218
+ };
219
+ //#endregion
220
+ //#region extensions/twitch/src/actions.ts
221
+ /**
222
+ * Twitch message actions adapter.
223
+ *
224
+ * Handles tool-based actions for Twitch, such as sending messages.
225
+ */
226
+ /**
227
+ * Create a tool result with error content.
228
+ */
229
+ function errorResponse(error) {
230
+ return {
231
+ content: [{
232
+ type: "text",
233
+ text: JSON.stringify({
234
+ ok: false,
235
+ error
236
+ })
237
+ }],
238
+ details: { ok: false }
239
+ };
240
+ }
241
+ /**
242
+ * Read a string parameter from action arguments.
243
+ *
244
+ * @param args - Action arguments
245
+ * @param key - Parameter key
246
+ * @param options - Options for reading the parameter
247
+ * @returns The parameter value or undefined if not found
248
+ */
249
+ function readStringParam(args, key, options = {}) {
250
+ const value = args[key];
251
+ if (value === void 0 || value === null) {
252
+ if (options.required) throw new Error(`Missing required parameter: ${key}`);
253
+ return;
254
+ }
255
+ if (typeof value === "string") return options.trim !== false ? value.trim() : value;
256
+ if (typeof value === "number" || typeof value === "boolean") {
257
+ const str = String(value);
258
+ return options.trim !== false ? str.trim() : str;
259
+ }
260
+ throw new Error(`Parameter ${key} must be a string, number, or boolean`);
261
+ }
262
+ /** Supported Twitch actions */
263
+ const TWITCH_ACTIONS = new Set(["send"]);
264
+ /**
265
+ * Twitch message actions adapter.
266
+ */
267
+ const twitchMessageActions = {
268
+ /**
269
+ * List available actions for this channel.
270
+ */
271
+ describeMessageTool: () => ({ actions: [...TWITCH_ACTIONS] }),
272
+ /**
273
+ * Check if an action is supported.
274
+ */
275
+ supportsAction: ({ action }) => TWITCH_ACTIONS.has(action),
276
+ /**
277
+ * Extract tool send parameters from action arguments.
278
+ *
279
+ * Parses and validates the "to" and "message" parameters for sending.
280
+ *
281
+ * @param params - Arguments from the tool call
282
+ * @returns Parsed send parameters or null if invalid
283
+ *
284
+ * @example
285
+ * const result = twitchMessageActions.extractToolSend!({
286
+ * args: { to: "#mychannel", message: "Hello!" }
287
+ * });
288
+ * // Returns: { to: "#mychannel", message: "Hello!" }
289
+ */
290
+ extractToolSend: ({ args }) => {
291
+ try {
292
+ const to = readStringParam(args, "to", { required: true });
293
+ const message = readStringParam(args, "message", { required: true });
294
+ if (!to || !message) return null;
295
+ return {
296
+ to,
297
+ message
298
+ };
299
+ } catch {
300
+ return null;
301
+ }
302
+ },
303
+ /**
304
+ * Handle an action execution.
305
+ *
306
+ * Processes the "send" action to send messages to Twitch.
307
+ *
308
+ * @param ctx - Action context including action type, parameters, and config
309
+ * @returns Tool result with content or null if action not supported
310
+ *
311
+ * @example
312
+ * const result = await twitchMessageActions.handleAction!({
313
+ * action: "send",
314
+ * params: { message: "Hello Twitch!", to: "#mychannel" },
315
+ * cfg: openclawConfig,
316
+ * accountId: "default",
317
+ * });
318
+ */
319
+ handleAction: async (ctx) => {
320
+ if (ctx.action !== "send") return {
321
+ content: [{
322
+ type: "text",
323
+ text: "Unsupported action"
324
+ }],
325
+ details: {
326
+ ok: false,
327
+ error: "Unsupported action"
328
+ }
329
+ };
330
+ const message = readStringParam(ctx.params, "message", { required: true });
331
+ const to = readStringParam(ctx.params, "to", { required: false });
332
+ const accountId = ctx.accountId ?? resolveTwitchAccountContext(ctx.cfg).accountId;
333
+ const { account, availableAccountIds } = resolveTwitchAccountContext(ctx.cfg, accountId);
334
+ if (!account) return errorResponse(`Account not found: ${accountId}. Available accounts: ${availableAccountIds.join(", ") || "none"}`);
335
+ const targetChannel = to || account.channel;
336
+ if (!targetChannel) return errorResponse("No channel specified and no default channel in account config");
337
+ if (!twitchOutbound.sendText) return errorResponse("sendText not implemented");
338
+ try {
339
+ const result = await twitchOutbound.sendText({
340
+ cfg: ctx.cfg,
341
+ to: targetChannel,
342
+ text: message ?? "",
343
+ accountId
344
+ });
345
+ return {
346
+ content: [{
347
+ type: "text",
348
+ text: JSON.stringify(result)
349
+ }],
350
+ details: { ok: true }
351
+ };
352
+ } catch (error) {
353
+ return errorResponse(formatErrorMessage(error));
354
+ }
355
+ }
356
+ };
357
+ //#endregion
358
+ //#region extensions/twitch/src/config-schema.ts
359
+ /**
360
+ * Twitch user roles that can be allowed to interact with the bot
361
+ */
362
+ const TwitchRoleSchema = z.enum([
363
+ "moderator",
364
+ "owner",
365
+ "vip",
366
+ "subscriber",
367
+ "all"
368
+ ]);
369
+ const TwitchAccountShape = {
370
+ /** Twitch username */
371
+ username: z.string(),
372
+ /** Twitch OAuth access token (requires chat:read and chat:write scopes) */
373
+ accessToken: z.string(),
374
+ /** Twitch client ID (from Twitch Developer Portal or twitchtokengenerator.com) */
375
+ clientId: z.string().optional(),
376
+ /** Channel name to join */
377
+ channel: z.string().min(1),
378
+ /** Enable this account */
379
+ enabled: z.boolean().optional(),
380
+ /** Allowlist of Twitch user IDs who can interact with the bot (use IDs for safety, not usernames) */
381
+ allowFrom: z.array(z.string()).optional(),
382
+ /** Roles allowed to interact with the bot (e.g., ["moderator", "vip", "subscriber"]) */
383
+ allowedRoles: z.array(TwitchRoleSchema).optional(),
384
+ /** Require @mention to trigger bot responses */
385
+ requireMention: z.boolean().optional(),
386
+ /** Outbound response prefix override for this channel/account. */
387
+ responsePrefix: z.string().optional(),
388
+ /** Twitch client secret (required for token refresh via RefreshingAuthProvider) */
389
+ clientSecret: z.string().optional(),
390
+ /** Refresh token (required for automatic token refresh) */
391
+ refreshToken: z.string().optional(),
392
+ /** Token expiry time in seconds (optional, for token refresh tracking) */
393
+ expiresIn: z.number().nullable().optional(),
394
+ /** Timestamp when token was obtained (optional, for token refresh tracking) */
395
+ obtainmentTimestamp: z.number().optional()
396
+ };
397
+ /**
398
+ * Twitch account configuration schema
399
+ */
400
+ const TwitchAccountSchema = z.object(TwitchAccountShape);
401
+ /**
402
+ * Base configuration properties shared by both single and multi-account modes
403
+ */
404
+ const TwitchConfigBaseShape = {
405
+ name: z.string().optional(),
406
+ enabled: z.boolean().optional(),
407
+ markdown: MarkdownConfigSchema.optional(),
408
+ defaultAccount: z.string().optional()
409
+ };
410
+ /**
411
+ * Simplified single-account configuration schema
412
+ *
413
+ * Use this for single-account setups. Properties are at the top level,
414
+ * creating an implicit "default" account.
415
+ */
416
+ const SimplifiedSchema = z.object({
417
+ ...TwitchConfigBaseShape,
418
+ ...TwitchAccountShape
419
+ });
420
+ /**
421
+ * Multi-account configuration schema
422
+ *
423
+ * Use this for multi-account setups. Each key is an account ID (e.g., "default", "secondary").
424
+ */
425
+ const MultiAccountSchema = z.object({
426
+ ...TwitchConfigBaseShape,
427
+ /** Per-account configuration (for multi-account setups) */
428
+ accounts: z.record(z.string(), TwitchAccountSchema)
429
+ }).refine((val) => Object.keys(val.accounts || {}).length > 0, { message: "accounts must contain at least one entry" });
430
+ /**
431
+ * Twitch plugin configuration schema
432
+ *
433
+ * Supports two mutually exclusive patterns:
434
+ * 1. Simplified single-account: username, accessToken, clientId, channel at top level
435
+ * 2. Multi-account: accounts object with named account configs
436
+ *
437
+ * The union ensures clear discrimination between the two modes.
438
+ */
439
+ const TwitchConfigSchema = z.union([SimplifiedSchema, MultiAccountSchema]);
440
+ //#endregion
441
+ //#region extensions/twitch/src/probe.ts
442
+ /**
443
+ * Probe a Twitch account to verify the connection is working
444
+ *
445
+ * This tests the Twitch OAuth token by attempting to connect
446
+ * to the chat server and verify the bot's username.
447
+ */
448
+ async function probeTwitch(account, timeoutMs) {
449
+ const started = Date.now();
450
+ if (!account.accessToken || !account.username) return {
451
+ ok: false,
452
+ error: "missing credentials (accessToken, username)",
453
+ username: account.username,
454
+ elapsedMs: Date.now() - started
455
+ };
456
+ const rawToken = normalizeToken(account.accessToken.trim());
457
+ let client;
458
+ try {
459
+ client = new ChatClient({ authProvider: new StaticAuthProvider(account.clientId ?? "", rawToken) });
460
+ const connectionPromise = new Promise((resolve, reject) => {
461
+ let settled = false;
462
+ let connectListener;
463
+ let disconnectListener;
464
+ let authFailListener;
465
+ const cleanup = () => {
466
+ if (settled) return;
467
+ settled = true;
468
+ connectListener?.unbind();
469
+ disconnectListener?.unbind();
470
+ authFailListener?.unbind();
471
+ };
472
+ connectListener = client?.onConnect(() => {
473
+ cleanup();
474
+ resolve();
475
+ });
476
+ disconnectListener = client?.onDisconnect((_manually, reason) => {
477
+ cleanup();
478
+ reject(reason || /* @__PURE__ */ new Error("Disconnected"));
479
+ });
480
+ authFailListener = client?.onAuthenticationFailure(() => {
481
+ cleanup();
482
+ reject(/* @__PURE__ */ new Error("Authentication failed"));
483
+ });
484
+ });
485
+ let timeoutHandle;
486
+ const timeout = new Promise((_, reject) => {
487
+ timeoutHandle = setTimeout(() => reject(/* @__PURE__ */ new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
488
+ });
489
+ client.connect();
490
+ try {
491
+ await Promise.race([connectionPromise, timeout]);
492
+ } finally {
493
+ if (timeoutHandle) clearTimeout(timeoutHandle);
494
+ }
495
+ client.quit();
496
+ client = void 0;
497
+ return {
498
+ ok: true,
499
+ connected: true,
500
+ username: account.username,
501
+ channel: account.channel,
502
+ elapsedMs: Date.now() - started
503
+ };
504
+ } catch (error) {
505
+ return {
506
+ ok: false,
507
+ error: formatErrorMessage(error),
508
+ username: account.username,
509
+ channel: account.channel,
510
+ elapsedMs: Date.now() - started
511
+ };
512
+ } finally {
513
+ if (client) try {
514
+ client.quit();
515
+ } catch {}
516
+ }
517
+ }
518
+ //#endregion
519
+ //#region extensions/twitch/src/resolver.ts
520
+ /**
521
+ * Twitch resolver adapter for channel/user name resolution.
522
+ *
523
+ * This module implements the ChannelResolverAdapter interface to resolve
524
+ * Twitch usernames to user IDs via the Twitch Helix API.
525
+ */
526
+ /**
527
+ * Normalize a Twitch username - strip @ prefix and convert to lowercase
528
+ */
529
+ function normalizeUsername(input) {
530
+ const trimmed = input.trim();
531
+ if (trimmed.startsWith("@")) return normalizeLowercaseStringOrEmpty(trimmed.slice(1));
532
+ return normalizeLowercaseStringOrEmpty(trimmed);
533
+ }
534
+ /**
535
+ * Create a logger that includes the Twitch prefix
536
+ */
537
+ function createLogger(logger) {
538
+ return {
539
+ info: (msg) => logger?.info(msg),
540
+ warn: (msg) => logger?.warn(msg),
541
+ error: (msg) => logger?.error(msg),
542
+ debug: (msg) => logger?.debug?.(msg) ?? (() => {})
543
+ };
544
+ }
545
+ /**
546
+ * Resolve Twitch usernames to user IDs via the Helix API
547
+ *
548
+ * @param inputs - Array of usernames or user IDs to resolve
549
+ * @param account - Twitch account configuration with auth credentials
550
+ * @param kind - Type of target to resolve ("user" or "group")
551
+ * @param logger - Optional logger
552
+ * @returns Promise resolving to array of ChannelResolveResult
553
+ */
554
+ async function resolveTwitchTargets(inputs, account, kind, logger) {
555
+ const log = createLogger(logger);
556
+ if (!account.clientId || !account.accessToken) {
557
+ log.error("Missing Twitch client ID or accessToken");
558
+ return inputs.map((input) => ({
559
+ input,
560
+ resolved: false,
561
+ note: "missing Twitch credentials"
562
+ }));
563
+ }
564
+ const normalizedToken = normalizeToken(account.accessToken);
565
+ const apiClient = new ApiClient({ authProvider: new StaticAuthProvider(account.clientId, normalizedToken) });
566
+ const results = [];
567
+ for (const input of inputs) {
568
+ const normalized = normalizeUsername(input);
569
+ if (!normalized) {
570
+ results.push({
571
+ input,
572
+ resolved: false,
573
+ note: "empty input"
574
+ });
575
+ continue;
576
+ }
577
+ const looksLikeUserId = /^\d+$/.test(normalized);
578
+ try {
579
+ if (looksLikeUserId) {
580
+ const user = await apiClient.users.getUserById(normalized);
581
+ if (user) {
582
+ results.push({
583
+ input,
584
+ resolved: true,
585
+ id: user.id,
586
+ name: user.name
587
+ });
588
+ log.debug?.(`Resolved user ID ${normalized} -> ${user.name}`);
589
+ } else {
590
+ results.push({
591
+ input,
592
+ resolved: false,
593
+ note: "user ID not found"
594
+ });
595
+ log.warn(`User ID ${normalized} not found`);
596
+ }
597
+ } else {
598
+ const user = await apiClient.users.getUserByName(normalized);
599
+ if (user) {
600
+ results.push({
601
+ input,
602
+ resolved: true,
603
+ id: user.id,
604
+ name: user.name,
605
+ note: user.displayName !== user.name ? `display: ${user.displayName}` : void 0
606
+ });
607
+ log.debug?.(`Resolved username ${normalized} -> ${user.id} (${user.name})`);
608
+ } else {
609
+ results.push({
610
+ input,
611
+ resolved: false,
612
+ note: "username not found"
613
+ });
614
+ log.warn(`Username ${normalized} not found`);
615
+ }
616
+ }
617
+ } catch (error) {
618
+ const errorMessage = formatErrorMessage(error);
619
+ results.push({
620
+ input,
621
+ resolved: false,
622
+ note: `API error: ${errorMessage}`
623
+ });
624
+ log.error(`Failed to resolve ${input}: ${errorMessage}`);
625
+ }
626
+ }
627
+ return results;
628
+ }
629
+ //#endregion
630
+ //#region extensions/twitch/src/status.ts
631
+ /**
632
+ * Collect status issues for Twitch accounts.
633
+ *
634
+ * Analyzes account snapshots and detects configuration problems,
635
+ * authentication issues, and other potential problems.
636
+ *
637
+ * @param accounts - Array of account snapshots to analyze
638
+ * @param getCfg - Optional function to get full config for additional checks
639
+ * @returns Array of detected status issues
640
+ *
641
+ * @example
642
+ * const issues = collectTwitchStatusIssues(accountSnapshots);
643
+ * if (issues.length > 0) {
644
+ * console.warn("Twitch configuration issues detected:");
645
+ * issues.forEach(issue => console.warn(`- ${issue.message}`));
646
+ * }
647
+ */
648
+ function collectTwitchStatusIssues(accounts, getCfg) {
649
+ const issues = [];
650
+ for (const entry of accounts) {
651
+ const accountId = entry.accountId;
652
+ if (!accountId) continue;
653
+ let account = null;
654
+ let cfg;
655
+ if (getCfg) try {
656
+ cfg = getCfg();
657
+ account = getAccountConfig(cfg, accountId);
658
+ } catch {}
659
+ if (!entry.configured) {
660
+ issues.push({
661
+ channel: "twitch",
662
+ accountId,
663
+ kind: "config",
664
+ message: "Twitch account is not properly configured",
665
+ fix: "Add required fields: username, accessToken, and clientId to your account configuration"
666
+ });
667
+ continue;
668
+ }
669
+ if (entry.enabled === false) {
670
+ issues.push({
671
+ channel: "twitch",
672
+ accountId,
673
+ kind: "config",
674
+ message: "Twitch account is disabled",
675
+ fix: "Set enabled: true in your account configuration to enable this account"
676
+ });
677
+ continue;
678
+ }
679
+ if (account && account.username && account.accessToken && !account.clientId) issues.push({
680
+ channel: "twitch",
681
+ accountId,
682
+ kind: "config",
683
+ message: "Twitch client ID is required",
684
+ fix: "Add clientId to your Twitch account configuration (from Twitch Developer Portal)"
685
+ });
686
+ const tokenResolution = cfg ? resolveTwitchToken(cfg, { accountId }) : {
687
+ token: "",
688
+ source: "none"
689
+ };
690
+ if (account && isAccountConfigured(account, tokenResolution.token)) {
691
+ if (account.accessToken?.startsWith("oauth:")) issues.push({
692
+ channel: "twitch",
693
+ accountId,
694
+ kind: "config",
695
+ message: "Token contains 'oauth:' prefix (will be stripped)",
696
+ fix: "The 'oauth:' prefix is optional. You can use just the token value, or keep it as-is (it will be normalized automatically)."
697
+ });
698
+ if (account.clientSecret && !account.refreshToken) issues.push({
699
+ channel: "twitch",
700
+ accountId,
701
+ kind: "config",
702
+ message: "clientSecret provided without refreshToken",
703
+ fix: "For automatic token refresh, provide both clientSecret and refreshToken. Otherwise, clientSecret is not needed."
704
+ });
705
+ if (account.allowFrom && account.allowFrom.length === 0) issues.push({
706
+ channel: "twitch",
707
+ accountId,
708
+ kind: "config",
709
+ message: "allowFrom is configured but empty",
710
+ fix: "Either add user IDs to allowFrom, remove the allowFrom field, or use allowedRoles instead."
711
+ });
712
+ if (account.allowedRoles?.includes("all") && account.allowFrom && account.allowFrom.length > 0) issues.push({
713
+ channel: "twitch",
714
+ accountId,
715
+ kind: "intent",
716
+ message: "allowedRoles is set to 'all' but allowFrom is also configured",
717
+ fix: "When allowedRoles is 'all', the allowFrom list is not needed. Remove allowFrom or set allowedRoles to specific roles."
718
+ });
719
+ }
720
+ if (entry.lastError) issues.push({
721
+ channel: "twitch",
722
+ accountId,
723
+ kind: "runtime",
724
+ message: `Last error: ${entry.lastError}`,
725
+ fix: "Check your token validity and network connection. Ensure the bot has the required OAuth scopes."
726
+ });
727
+ if (entry.configured && !entry.running && !entry.lastStartAt && !entry.lastInboundAt && !entry.lastOutboundAt) issues.push({
728
+ channel: "twitch",
729
+ accountId,
730
+ kind: "runtime",
731
+ message: "Account has never connected successfully",
732
+ fix: "Start the Twitch gateway to begin receiving messages. Check logs for connection errors."
733
+ });
734
+ if (entry.running && entry.lastStartAt) {
735
+ const daysSinceStart = (Date.now() - entry.lastStartAt) / (1e3 * 60 * 60 * 24);
736
+ if (daysSinceStart > 7) issues.push({
737
+ channel: "twitch",
738
+ accountId,
739
+ kind: "runtime",
740
+ message: `Connection has been running for ${Math.floor(daysSinceStart)} days`,
741
+ fix: "Consider restarting the connection periodically to refresh the connection. Twitch tokens may expire after long periods."
742
+ });
743
+ }
744
+ }
745
+ return issues;
746
+ }
747
+ //#endregion
748
+ //#region extensions/twitch/src/plugin.ts
749
+ /**
750
+ * Twitch channel plugin for OpenClaw.
751
+ *
752
+ * Main plugin export combining all adapters (outbound, actions, status, gateway).
753
+ * This is the primary entry point for the Twitch channel integration.
754
+ */
755
+ /**
756
+ * Twitch channel plugin.
757
+ *
758
+ * Implements the ChannelPlugin interface to provide Twitch chat integration
759
+ * for OpenClaw. Supports message sending, receiving, access control, and
760
+ * status monitoring.
761
+ */
762
+ const twitchPlugin = createChatChannelPlugin({
763
+ pairing: {
764
+ idLabel: "twitchUserId",
765
+ normalizeAllowEntry: createPairingPrefixStripper(/^(twitch:)?user:?/i),
766
+ notifyApproval: createLoggedPairingApprovalNotifier(({ id }) => `Pairing approved for user ${id} (notification sent via chat if possible)`, console.warn)
767
+ },
768
+ outbound: twitchOutbound,
769
+ base: {
770
+ id: "twitch",
771
+ meta: {
772
+ id: "twitch",
773
+ label: "Twitch",
774
+ selectionLabel: "Twitch (Chat)",
775
+ docsPath: "/channels/twitch",
776
+ blurb: "Twitch chat integration",
777
+ aliases: ["twitch-chat"]
778
+ },
779
+ setup: twitchSetupAdapter,
780
+ setupWizard: twitchSetupWizard,
781
+ capabilities: { chatTypes: ["group"] },
782
+ configSchema: buildChannelConfigSchema(TwitchConfigSchema),
783
+ config: {
784
+ listAccountIds: (cfg) => listAccountIds(cfg),
785
+ resolveAccount: (cfg, accountId) => {
786
+ const resolvedAccountId = accountId ?? resolveDefaultTwitchAccountId(cfg);
787
+ const account = getAccountConfig(cfg, resolvedAccountId);
788
+ if (!account) return {
789
+ accountId: resolvedAccountId,
790
+ channel: "",
791
+ username: "",
792
+ accessToken: "",
793
+ clientId: "",
794
+ enabled: false
795
+ };
796
+ return {
797
+ accountId: resolvedAccountId,
798
+ ...account
799
+ };
800
+ },
801
+ defaultAccountId: (cfg) => resolveDefaultTwitchAccountId(cfg),
802
+ isConfigured: (_account, cfg) => resolveTwitchAccountContext(cfg).configured,
803
+ isEnabled: (account) => account?.enabled !== false,
804
+ describeAccount: (account) => account ? describeAccountSnapshot({
805
+ account,
806
+ configured: isAccountConfigured(account, account.accessToken)
807
+ }) : {
808
+ accountId: DEFAULT_ACCOUNT_ID,
809
+ enabled: false,
810
+ configured: false
811
+ }
812
+ },
813
+ actions: twitchMessageActions,
814
+ resolver: { resolveTargets: async ({ cfg, accountId, inputs, kind, runtime }) => {
815
+ const account = getAccountConfig(cfg, accountId ?? resolveDefaultTwitchAccountId(cfg));
816
+ if (!account) return inputs.map((input) => ({
817
+ input,
818
+ resolved: false,
819
+ note: "account not configured"
820
+ }));
821
+ return await resolveTwitchTargets(inputs, account, kind, {
822
+ info: (msg) => runtime.log(msg),
823
+ warn: (msg) => runtime.log(msg),
824
+ error: (msg) => runtime.error(msg),
825
+ debug: (msg) => runtime.log(msg)
826
+ });
827
+ } },
828
+ status: createComputedAccountStatusAdapter({
829
+ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
830
+ buildChannelSummary: ({ snapshot }) => buildPassiveProbedChannelStatusSummary(snapshot),
831
+ probeAccount: async ({ account, timeoutMs }) => await probeTwitch(account, timeoutMs),
832
+ collectStatusIssues: collectTwitchStatusIssues,
833
+ resolveAccountSnapshot: ({ account, cfg }) => {
834
+ const resolvedAccountId = account.accountId || resolveTwitchSnapshotAccountId(cfg, account);
835
+ const { configured } = resolveTwitchAccountContext(cfg, resolvedAccountId);
836
+ return {
837
+ accountId: resolvedAccountId,
838
+ enabled: account.enabled !== false,
839
+ configured
840
+ };
841
+ }
842
+ }),
843
+ gateway: {
844
+ startAccount: async (ctx) => {
845
+ const account = ctx.account;
846
+ const accountId = ctx.accountId;
847
+ ctx.setStatus?.({
848
+ accountId,
849
+ running: true,
850
+ lastStartAt: Date.now(),
851
+ lastError: null
852
+ });
853
+ ctx.log?.info(`Starting Twitch connection for ${account.username}`);
854
+ const { monitorTwitchProvider } = await import("./monitor-DS0YTAPB.js");
855
+ await monitorTwitchProvider({
856
+ account,
857
+ accountId,
858
+ config: ctx.cfg,
859
+ runtime: ctx.runtime,
860
+ abortSignal: ctx.abortSignal
861
+ });
862
+ },
863
+ stopAccount: async (ctx) => {
864
+ const account = ctx.account;
865
+ const accountId = ctx.accountId;
866
+ await removeClientManager(accountId);
867
+ ctx.setStatus?.({
868
+ accountId,
869
+ running: false,
870
+ lastStopAt: Date.now()
871
+ });
872
+ ctx.log?.info(`Stopped Twitch connection for ${account.username}`);
873
+ }
874
+ }
875
+ }
876
+ });
877
+ //#endregion
878
+ export { twitchPlugin as t };