@kodelyth/twitch 2026.5.39 → 2026.5.42

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 (62) hide show
  1. package/README.md +89 -0
  2. package/api.ts +21 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/dist/api.js +3 -0
  5. package/dist/channel-plugin-api.js +2 -0
  6. package/dist/index.js +18 -0
  7. package/dist/monitor-j1GtQVBd.js +337 -0
  8. package/dist/plugin-BMzrFFQR.js +1285 -0
  9. package/dist/runtime-CwXHrWo3.js +8 -0
  10. package/dist/runtime-api.js +1 -0
  11. package/dist/setup-entry.js +11 -0
  12. package/dist/setup-plugin-api.js +2 -0
  13. package/dist/setup-surface-CovnRl9R.js +527 -0
  14. package/index.test.ts +13 -0
  15. package/index.ts +16 -0
  16. package/klaw.plugin.json +2 -219
  17. package/package.json +3 -3
  18. package/runtime-api.ts +22 -0
  19. package/setup-entry.ts +9 -0
  20. package/setup-plugin-api.ts +3 -0
  21. package/src/access-control.test.ts +373 -0
  22. package/src/access-control.ts +195 -0
  23. package/src/actions.test.ts +75 -0
  24. package/src/actions.ts +175 -0
  25. package/src/client-manager-registry.ts +87 -0
  26. package/src/config-schema.test.ts +46 -0
  27. package/src/config-schema.ts +88 -0
  28. package/src/config.test.ts +233 -0
  29. package/src/config.ts +177 -0
  30. package/src/monitor.ts +311 -0
  31. package/src/outbound.test.ts +572 -0
  32. package/src/outbound.ts +242 -0
  33. package/src/plugin.lifecycle.test.ts +86 -0
  34. package/src/plugin.live.test.ts +120 -0
  35. package/src/plugin.test.ts +77 -0
  36. package/src/plugin.ts +220 -0
  37. package/src/probe.test.ts +196 -0
  38. package/src/probe.ts +130 -0
  39. package/src/resolver.ts +139 -0
  40. package/src/runtime.ts +9 -0
  41. package/src/send.test.ts +342 -0
  42. package/src/send.ts +191 -0
  43. package/src/setup-surface.test.ts +529 -0
  44. package/src/setup-surface.ts +526 -0
  45. package/src/status.test.ts +298 -0
  46. package/src/status.ts +179 -0
  47. package/src/test-fixtures.ts +30 -0
  48. package/src/token.test.ts +198 -0
  49. package/src/token.ts +93 -0
  50. package/src/twitch-client.test.ts +574 -0
  51. package/src/twitch-client.ts +276 -0
  52. package/src/types.ts +104 -0
  53. package/src/utils/markdown.ts +98 -0
  54. package/src/utils/twitch.ts +81 -0
  55. package/test/setup.ts +7 -0
  56. package/tsconfig.json +16 -0
  57. package/api.js +0 -7
  58. package/channel-plugin-api.js +0 -7
  59. package/index.js +0 -7
  60. package/runtime-api.js +0 -7
  61. package/setup-entry.js +0 -7
  62. package/setup-plugin-api.js +0 -7
@@ -0,0 +1,1285 @@
1
+ import { a as getAccountConfig, c as resolveTwitchAccountContext, d as isAccountConfigured, f as missingTargetError, h as resolveTwitchToken, i as DEFAULT_ACCOUNT_ID, l as resolveTwitchSnapshotAccountId, m as normalizeTwitchChannel, o as listAccountIds, p as normalizeToken, r as twitchSetupWizard, s as resolveDefaultTwitchAccountId, t as twitchSetupAdapter, u as generateMessageId } from "./setup-surface-CovnRl9R.js";
2
+ import { describeAccountSnapshot } from "klaw/plugin-sdk/account-helpers";
3
+ import { buildChannelConfigSchema } from "klaw/plugin-sdk/channel-config-schema";
4
+ import { createChatChannelPlugin } from "klaw/plugin-sdk/channel-core";
5
+ import { createLoggedPairingApprovalNotifier, createPairingPrefixStripper } from "klaw/plugin-sdk/channel-pairing";
6
+ import { buildPassiveProbedChannelStatusSummary, runStoppablePassiveMonitor } from "klaw/plugin-sdk/extension-shared";
7
+ import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState } from "klaw/plugin-sdk/status-helpers";
8
+ import { formatErrorMessage } from "klaw/plugin-sdk/error-runtime";
9
+ import { normalizeLowercaseStringOrEmpty } from "klaw/plugin-sdk/string-coerce-runtime";
10
+ import { createMessageReceiptFromOutboundResults, defineChannelMessageAdapter } from "klaw/plugin-sdk/channel-message";
11
+ import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth";
12
+ import { ChatClient, LogLevel } from "@twurple/chat";
13
+ import { MarkdownConfigSchema } from "klaw/plugin-sdk/channel-config-primitives";
14
+ import { z } from "zod";
15
+ import { ApiClient } from "@twurple/api";
16
+ //#region extensions/twitch/src/twitch-client.ts
17
+ /**
18
+ * Manages Twitch chat client connections
19
+ */
20
+ var TwitchClientManager = class {
21
+ constructor(logger) {
22
+ this.logger = logger;
23
+ this.clients = /* @__PURE__ */ new Map();
24
+ this.messageHandlers = /* @__PURE__ */ new Map();
25
+ }
26
+ /**
27
+ * Create an auth provider for the account.
28
+ */
29
+ async createAuthProvider(account, normalizedToken) {
30
+ if (!account.clientId) throw new Error("Missing Twitch client ID");
31
+ if (account.clientSecret) {
32
+ const authProvider = new RefreshingAuthProvider({
33
+ clientId: account.clientId,
34
+ clientSecret: account.clientSecret
35
+ });
36
+ await authProvider.addUserForToken({
37
+ accessToken: normalizedToken,
38
+ refreshToken: account.refreshToken ?? null,
39
+ expiresIn: account.expiresIn ?? null,
40
+ obtainmentTimestamp: account.obtainmentTimestamp ?? Date.now()
41
+ }).then((userId) => {
42
+ this.logger.info(`Added user ${userId} to RefreshingAuthProvider for ${account.username}`);
43
+ }).catch((err) => {
44
+ this.logger.error(`Failed to add user to RefreshingAuthProvider: ${formatErrorMessage(err)}`);
45
+ });
46
+ authProvider.onRefresh((userId, token) => {
47
+ this.logger.info(`Access token refreshed for user ${userId} (expires in ${token.expiresIn ? `${token.expiresIn}s` : "unknown"})`);
48
+ });
49
+ authProvider.onRefreshFailure((userId, error) => {
50
+ this.logger.error(`Failed to refresh access token for user ${userId}: ${error.message}`);
51
+ });
52
+ const refreshStatus = account.refreshToken ? "automatic token refresh enabled" : "token refresh disabled (no refresh token)";
53
+ this.logger.info(`Using RefreshingAuthProvider for ${account.username} (${refreshStatus})`);
54
+ return authProvider;
55
+ }
56
+ this.logger.info(`Using StaticAuthProvider for ${account.username} (no clientSecret provided)`);
57
+ return new StaticAuthProvider(account.clientId, normalizedToken);
58
+ }
59
+ /**
60
+ * Get or create a chat client for an account
61
+ */
62
+ async getClient(account, cfg, accountId) {
63
+ const key = this.getAccountKey(account);
64
+ const existing = this.clients.get(key);
65
+ if (existing) return existing;
66
+ const tokenResolution = resolveTwitchToken(cfg, { accountId });
67
+ if (!tokenResolution.token) {
68
+ this.logger.error(`Missing Twitch token for account ${account.username} (set channels.twitch.accounts.${account.username}.token or KLAW_TWITCH_ACCESS_TOKEN for default)`);
69
+ throw new Error("Missing Twitch token");
70
+ }
71
+ this.logger.debug?.(`Using ${tokenResolution.source} token source for ${account.username}`);
72
+ if (!account.clientId) {
73
+ this.logger.error(`Missing Twitch client ID for account ${account.username}`);
74
+ throw new Error("Missing Twitch client ID");
75
+ }
76
+ const normalizedToken = normalizeToken(tokenResolution.token);
77
+ const client = new ChatClient({
78
+ authProvider: await this.createAuthProvider(account, normalizedToken),
79
+ channels: [account.channel],
80
+ rejoinChannelsOnReconnect: true,
81
+ requestMembershipEvents: true,
82
+ logger: {
83
+ minLevel: LogLevel.WARNING,
84
+ custom: { log: (level, message) => {
85
+ switch (level) {
86
+ case LogLevel.CRITICAL:
87
+ this.logger.error(message);
88
+ break;
89
+ case LogLevel.ERROR:
90
+ this.logger.error(message);
91
+ break;
92
+ case LogLevel.WARNING:
93
+ this.logger.warn(message);
94
+ break;
95
+ case LogLevel.INFO:
96
+ this.logger.info(message);
97
+ break;
98
+ case LogLevel.DEBUG:
99
+ this.logger.debug?.(message);
100
+ break;
101
+ case LogLevel.TRACE:
102
+ this.logger.debug?.(message);
103
+ break;
104
+ }
105
+ } }
106
+ }
107
+ });
108
+ this.setupClientHandlers(client, account);
109
+ client.connect();
110
+ this.clients.set(key, client);
111
+ this.logger.info(`Connected to Twitch as ${account.username}`);
112
+ return client;
113
+ }
114
+ /**
115
+ * Set up message and event handlers for a client
116
+ */
117
+ setupClientHandlers(client, account) {
118
+ const key = this.getAccountKey(account);
119
+ client.onMessage((channelName, _user, messageText, msg) => {
120
+ const handler = this.messageHandlers.get(key);
121
+ if (handler) {
122
+ const normalizedChannel = channelName.startsWith("#") ? channelName.slice(1) : channelName;
123
+ const from = `twitch:${msg.userInfo.userName}`;
124
+ const preview = messageText.slice(0, 100).replace(/\n/g, "\\n");
125
+ this.logger.debug?.(`twitch inbound: channel=${normalizedChannel} from=${from} len=${messageText.length} preview="${preview}"`);
126
+ handler({
127
+ username: msg.userInfo.userName,
128
+ displayName: msg.userInfo.displayName,
129
+ userId: msg.userInfo.userId,
130
+ message: messageText,
131
+ channel: normalizedChannel,
132
+ id: msg.id,
133
+ timestamp: /* @__PURE__ */ new Date(),
134
+ isMod: msg.userInfo.isMod,
135
+ isOwner: msg.userInfo.isBroadcaster,
136
+ isVip: msg.userInfo.isVip,
137
+ isSub: msg.userInfo.isSubscriber,
138
+ chatType: "group"
139
+ });
140
+ }
141
+ });
142
+ this.logger.info(`Set up handlers for ${key}`);
143
+ }
144
+ /**
145
+ * Set a message handler for an account
146
+ * @returns A function that removes the handler when called
147
+ */
148
+ onMessage(account, handler) {
149
+ const key = this.getAccountKey(account);
150
+ this.messageHandlers.set(key, handler);
151
+ return () => {
152
+ this.messageHandlers.delete(key);
153
+ };
154
+ }
155
+ /**
156
+ * Disconnect a client
157
+ */
158
+ async disconnect(account) {
159
+ const key = this.getAccountKey(account);
160
+ const client = this.clients.get(key);
161
+ if (client) {
162
+ client.quit();
163
+ this.clients.delete(key);
164
+ this.messageHandlers.delete(key);
165
+ this.logger.info(`Disconnected ${key}`);
166
+ }
167
+ }
168
+ /**
169
+ * Disconnect all clients
170
+ */
171
+ async disconnectAll() {
172
+ this.clients.forEach((client) => client.quit());
173
+ this.clients.clear();
174
+ this.messageHandlers.clear();
175
+ this.logger.info(" Disconnected all clients");
176
+ }
177
+ /**
178
+ * Send a message to a channel
179
+ */
180
+ async sendMessage(account, channel, message, cfg, accountId) {
181
+ try {
182
+ const client = await this.getClient(account, cfg, accountId);
183
+ const messageId = crypto.randomUUID();
184
+ await client.say(channel, message);
185
+ return {
186
+ ok: true,
187
+ messageId
188
+ };
189
+ } catch (error) {
190
+ this.logger.error(`Failed to send message: ${formatErrorMessage(error)}`);
191
+ return {
192
+ ok: false,
193
+ error: formatErrorMessage(error)
194
+ };
195
+ }
196
+ }
197
+ /**
198
+ * Generate a unique key for an account
199
+ */
200
+ getAccountKey(account) {
201
+ return `${account.username}:${account.channel}`;
202
+ }
203
+ /**
204
+ * Clear all clients and handlers (for testing)
205
+ */
206
+ clearForTest() {
207
+ this.clients.clear();
208
+ this.messageHandlers.clear();
209
+ }
210
+ };
211
+ //#endregion
212
+ //#region extensions/twitch/src/client-manager-registry.ts
213
+ /**
214
+ * Client manager registry for Twitch plugin.
215
+ *
216
+ * Manages the lifecycle of TwitchClientManager instances across the plugin,
217
+ * ensuring proper cleanup when accounts are stopped or reconfigured.
218
+ */
219
+ /**
220
+ * Global registry of client managers.
221
+ * Keyed by account ID.
222
+ */
223
+ const registry = /* @__PURE__ */ new Map();
224
+ /**
225
+ * Get or create a client manager for an account.
226
+ *
227
+ * @param accountId - The account ID
228
+ * @param logger - Logger instance
229
+ * @returns The client manager
230
+ */
231
+ function getOrCreateClientManager(accountId, logger) {
232
+ const existing = registry.get(accountId);
233
+ if (existing) return existing.manager;
234
+ const manager = new TwitchClientManager(logger);
235
+ registry.set(accountId, {
236
+ manager,
237
+ accountId,
238
+ logger,
239
+ createdAt: Date.now()
240
+ });
241
+ logger.info(`Registered client manager for account: ${accountId}`);
242
+ return manager;
243
+ }
244
+ /**
245
+ * Get an existing client manager for an account.
246
+ *
247
+ * @param accountId - The account ID
248
+ * @returns The client manager, or undefined if not registered
249
+ */
250
+ function getClientManager(accountId) {
251
+ return registry.get(accountId)?.manager;
252
+ }
253
+ /**
254
+ * Disconnect and remove a client manager from the registry.
255
+ *
256
+ * @param accountId - The account ID
257
+ * @returns Promise that resolves when cleanup is complete
258
+ */
259
+ async function removeClientManager(accountId) {
260
+ const entry = registry.get(accountId);
261
+ if (!entry) return;
262
+ await entry.manager.disconnectAll();
263
+ registry.delete(accountId);
264
+ entry.logger.info(`Unregistered client manager for account: ${accountId}`);
265
+ }
266
+ //#endregion
267
+ //#region extensions/twitch/src/utils/markdown.ts
268
+ /**
269
+ * Markdown utilities for Twitch chat
270
+ *
271
+ * Twitch chat doesn't support markdown formatting, so we strip it before sending.
272
+ * Based on Klaw's markdownToText in src/agents/tools/web-fetch-utils.ts.
273
+ */
274
+ /**
275
+ * Strip markdown formatting from text for Twitch compatibility.
276
+ *
277
+ * Removes images, links, bold, italic, strikethrough, code blocks, inline code,
278
+ * headers, and list formatting. Replaces newlines with spaces since Twitch
279
+ * is a single-line chat medium.
280
+ *
281
+ * @param markdown - The markdown text to strip
282
+ * @returns Plain text with markdown removed
283
+ */
284
+ function stripMarkdownForTwitch(markdown) {
285
+ return markdown.replace(/!\[[^\]]*]\([^)]+\)/g, "").replace(/\[([^\]]+)]\([^)]+\)/g, "$1").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/__([^_]+)__/g, "$1").replace(/\*([^*]+)\*/g, "$1").replace(/_([^_]+)_/g, "$1").replace(/~~([^~]+)~~/g, "$1").replace(/```[\s\S]*?```/g, (block) => block.replace(/```[^\n]*\n?/g, "").replace(/```/g, "")).replace(/`([^`]+)`/g, "$1").replace(/^#{1,6}\s+/gm, "").replace(/^\s*[-*+]\s+/gm, "").replace(/^\s*\d+\.\s+/gm, "").replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\n/g, " ").replace(/[ \t]{2,}/g, " ").trim();
286
+ }
287
+ /**
288
+ * Simple word-boundary chunker for Twitch (500 char limit).
289
+ * Strips markdown before chunking to avoid breaking markdown patterns.
290
+ *
291
+ * @param text - The text to chunk
292
+ * @param limit - Maximum characters per chunk (Twitch limit is 500)
293
+ * @returns Array of text chunks
294
+ */
295
+ function chunkTextForTwitch(text, limit) {
296
+ const cleaned = stripMarkdownForTwitch(text);
297
+ if (!cleaned) return [];
298
+ if (limit <= 0) return [cleaned];
299
+ if (cleaned.length <= limit) return [cleaned];
300
+ const chunks = [];
301
+ let remaining = cleaned;
302
+ while (remaining.length > limit) {
303
+ const window = remaining.slice(0, limit);
304
+ const lastSpaceIndex = window.lastIndexOf(" ");
305
+ if (lastSpaceIndex === -1) {
306
+ chunks.push(window);
307
+ remaining = remaining.slice(limit);
308
+ } else {
309
+ chunks.push(window.slice(0, lastSpaceIndex));
310
+ remaining = remaining.slice(lastSpaceIndex + 1);
311
+ }
312
+ }
313
+ if (remaining) chunks.push(remaining);
314
+ return chunks;
315
+ }
316
+ //#endregion
317
+ //#region extensions/twitch/src/send.ts
318
+ /**
319
+ * Twitch message sending functions with dependency injection support.
320
+ *
321
+ * These functions are the primary interface for sending messages to Twitch.
322
+ * They support dependency injection via the `deps` parameter for testability.
323
+ */
324
+ function createTwitchSendReceipt(params) {
325
+ const messageId = params.messageId.trim();
326
+ const conversationId = params.channel?.trim();
327
+ return createMessageReceiptFromOutboundResults({
328
+ results: params.visible === true && messageId && messageId !== "skipped" ? [{
329
+ channel: "twitch",
330
+ messageId,
331
+ ...conversationId ? { conversationId } : {}
332
+ }] : [],
333
+ kind: "text"
334
+ });
335
+ }
336
+ /**
337
+ * Internal send function used by the outbound adapter.
338
+ *
339
+ * This function has access to the full Klaw config and handles
340
+ * account resolution, markdown stripping, and actual message sending.
341
+ *
342
+ * @param channel - The channel name
343
+ * @param text - The message text
344
+ * @param cfg - Full Klaw configuration
345
+ * @param accountId - Account ID to use
346
+ * @param stripMarkdown - Whether to strip markdown (default: true)
347
+ * @param logger - Logger instance
348
+ * @returns Result with message ID and status
349
+ *
350
+ * @example
351
+ * const result = await sendMessageTwitchInternal(
352
+ * "#mychannel",
353
+ * "Hello Twitch!",
354
+ * klawConfig,
355
+ * "default",
356
+ * true,
357
+ * console,
358
+ * );
359
+ */
360
+ async function sendMessageTwitchInternal(channel, text, cfg, accountId, stripMarkdown = true, logger = console) {
361
+ const { account, configured, availableAccountIds, accountId: resolvedAccountId } = resolveTwitchAccountContext(cfg, accountId);
362
+ if (!account) return {
363
+ ok: false,
364
+ messageId: generateMessageId(),
365
+ receipt: createTwitchSendReceipt({
366
+ messageId: "",
367
+ channel,
368
+ visible: false
369
+ }),
370
+ error: `Account not found: ${accountId ?? "(default)"}. Available accounts: ${availableAccountIds.join(", ") || "none"}`
371
+ };
372
+ if (!configured) return {
373
+ ok: false,
374
+ messageId: generateMessageId(),
375
+ receipt: createTwitchSendReceipt({
376
+ messageId: "",
377
+ channel,
378
+ visible: false
379
+ }),
380
+ error: `Account ${resolvedAccountId} is not properly configured. Required: username, clientId, and token (config or env for default account).`
381
+ };
382
+ const normalizedChannel = channel || account.channel;
383
+ if (!normalizedChannel) return {
384
+ ok: false,
385
+ messageId: generateMessageId(),
386
+ receipt: createTwitchSendReceipt({
387
+ messageId: "",
388
+ channel: normalizedChannel,
389
+ visible: false
390
+ }),
391
+ error: "No channel specified and no default channel in account config"
392
+ };
393
+ const deliveryChannel = normalizeTwitchChannel(normalizedChannel);
394
+ const cleanedText = stripMarkdown ? stripMarkdownForTwitch(text) : text;
395
+ if (!cleanedText) return {
396
+ ok: true,
397
+ messageId: "skipped",
398
+ receipt: createTwitchSendReceipt({
399
+ messageId: "skipped",
400
+ channel: deliveryChannel,
401
+ visible: false
402
+ })
403
+ };
404
+ const clientManager = getClientManager(resolvedAccountId);
405
+ if (!clientManager) return {
406
+ ok: false,
407
+ messageId: generateMessageId(),
408
+ receipt: createTwitchSendReceipt({
409
+ messageId: "",
410
+ channel: deliveryChannel,
411
+ visible: false
412
+ }),
413
+ error: `Client manager not found for account: ${resolvedAccountId}. Please start the Twitch gateway first.`
414
+ };
415
+ try {
416
+ const result = await clientManager.sendMessage(account, deliveryChannel, cleanedText, cfg, resolvedAccountId);
417
+ if (!result.ok) {
418
+ const messageId = result.messageId ?? generateMessageId();
419
+ return {
420
+ ok: false,
421
+ messageId,
422
+ receipt: createTwitchSendReceipt({
423
+ messageId,
424
+ channel: deliveryChannel,
425
+ visible: false
426
+ }),
427
+ error: result.error ?? "Send failed"
428
+ };
429
+ }
430
+ const messageId = result.messageId ?? generateMessageId();
431
+ return {
432
+ ok: true,
433
+ messageId,
434
+ receipt: createTwitchSendReceipt({
435
+ messageId,
436
+ channel: deliveryChannel,
437
+ visible: true
438
+ })
439
+ };
440
+ } catch (error) {
441
+ const errorMsg = formatErrorMessage(error);
442
+ const messageId = generateMessageId();
443
+ logger.error(`Failed to send message: ${errorMsg}`);
444
+ return {
445
+ ok: false,
446
+ messageId,
447
+ receipt: createTwitchSendReceipt({
448
+ messageId,
449
+ channel: deliveryChannel,
450
+ visible: false
451
+ }),
452
+ error: errorMsg
453
+ };
454
+ }
455
+ }
456
+ //#endregion
457
+ //#region extensions/twitch/src/outbound.ts
458
+ /**
459
+ * Twitch outbound adapter for sending messages.
460
+ *
461
+ * Implements the ChannelOutboundAdapter interface for Twitch chat.
462
+ * Supports text and media (URL) sending with markdown stripping and chunking.
463
+ */
464
+ /**
465
+ * Twitch outbound adapter.
466
+ *
467
+ * Handles sending text and media to Twitch channels with automatic
468
+ * markdown stripping and message chunking.
469
+ */
470
+ const twitchOutbound = {
471
+ /** Direct delivery mode - messages are sent immediately */
472
+ deliveryMode: "direct",
473
+ deliveryCapabilities: { durableFinal: {
474
+ text: true,
475
+ media: true,
476
+ messageSendingHooks: true
477
+ } },
478
+ /** Twitch chat message limit is 500 characters */
479
+ textChunkLimit: 500,
480
+ /** Word-boundary chunker with markdown stripping */
481
+ chunker: chunkTextForTwitch,
482
+ /**
483
+ * Resolve target from context.
484
+ *
485
+ * Handles target resolution with allowlist support for implicit/heartbeat modes.
486
+ * For explicit mode, accepts any valid channel name.
487
+ *
488
+ * @param params - Resolution parameters
489
+ * @returns Resolved target or error
490
+ */
491
+ resolveTarget: ({ to, allowFrom, mode }) => {
492
+ const trimmed = to?.trim() ?? "";
493
+ const allowListRaw = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean);
494
+ const hasWildcard = allowListRaw.includes("*");
495
+ const allowList = allowListRaw.filter((entry) => entry !== "*").map((entry) => normalizeTwitchChannel(entry)).filter((entry) => entry.length > 0);
496
+ if (trimmed) {
497
+ const normalizedTo = normalizeTwitchChannel(trimmed);
498
+ if (!normalizedTo) return {
499
+ ok: false,
500
+ error: missingTargetError("Twitch", "<channel-name>")
501
+ };
502
+ if (mode === "implicit" || mode === "heartbeat") {
503
+ if (hasWildcard || allowList.length === 0) return {
504
+ ok: true,
505
+ to: normalizedTo
506
+ };
507
+ if (allowList.includes(normalizedTo)) return {
508
+ ok: true,
509
+ to: normalizedTo
510
+ };
511
+ return {
512
+ ok: false,
513
+ error: missingTargetError("Twitch", "<channel-name>")
514
+ };
515
+ }
516
+ return {
517
+ ok: true,
518
+ to: normalizedTo
519
+ };
520
+ }
521
+ return {
522
+ ok: false,
523
+ error: missingTargetError("Twitch", "<channel-name>")
524
+ };
525
+ },
526
+ /**
527
+ * Send a text message to a Twitch channel.
528
+ *
529
+ * Strips markdown if enabled, validates account configuration,
530
+ * and sends the message via the Twitch client.
531
+ *
532
+ * @param params - Send parameters including target, text, and config
533
+ * @returns Delivery result with message ID and status
534
+ *
535
+ * @example
536
+ * const result = await twitchOutbound.sendText({
537
+ * cfg: klawConfig,
538
+ * to: "#mychannel",
539
+ * text: "Hello Twitch!",
540
+ * accountId: "default",
541
+ * });
542
+ */
543
+ sendText: async (params) => {
544
+ const { cfg, to, text, accountId } = params;
545
+ if (params.signal?.aborted) throw new Error("Outbound delivery aborted");
546
+ const resolvedAccountId = accountId ?? resolveTwitchAccountContext(cfg).accountId;
547
+ const { account, availableAccountIds } = resolveTwitchAccountContext(cfg, resolvedAccountId);
548
+ if (!account) throw new Error(`Twitch account not found: ${resolvedAccountId}. Available accounts: ${availableAccountIds.join(", ") || "none"}`);
549
+ const channel = to || account.channel;
550
+ if (!channel) throw new Error("No channel specified and no default channel in account config");
551
+ const result = await sendMessageTwitchInternal(normalizeTwitchChannel(channel), text, cfg, resolvedAccountId, true, console);
552
+ if (!result.ok) throw new Error(result.error ?? "Send failed");
553
+ return {
554
+ channel: "twitch",
555
+ messageId: result.messageId,
556
+ receipt: result.receipt,
557
+ timestamp: Date.now()
558
+ };
559
+ },
560
+ /**
561
+ * Send media to a Twitch channel.
562
+ *
563
+ * Note: Twitch chat doesn't support direct media uploads.
564
+ * This sends the media URL as text instead.
565
+ *
566
+ * @param params - Send parameters including media URL
567
+ * @returns Delivery result with message ID and status
568
+ *
569
+ * @example
570
+ * const result = await twitchOutbound.sendMedia({
571
+ * cfg: klawConfig,
572
+ * to: "#mychannel",
573
+ * text: "Check this out!",
574
+ * mediaUrl: "https://example.com/image.png",
575
+ * accountId: "default",
576
+ * });
577
+ */
578
+ sendMedia: async (params) => {
579
+ const { text, mediaUrl } = params;
580
+ if (params.signal?.aborted) throw new Error("Outbound delivery aborted");
581
+ const message = mediaUrl ? `${text || ""} ${mediaUrl}`.trim() : text;
582
+ if (!twitchOutbound.sendText) throw new Error("sendText not implemented");
583
+ return twitchOutbound.sendText({
584
+ ...params,
585
+ text: message
586
+ });
587
+ }
588
+ };
589
+ function toTwitchMessageSendResult(result, kind) {
590
+ const receipt = result.receipt ?? createMessageReceiptFromOutboundResults({
591
+ results: result.messageId ? [{
592
+ channel: "twitch",
593
+ messageId: result.messageId
594
+ }] : [],
595
+ kind
596
+ });
597
+ return {
598
+ messageId: result.messageId || receipt.primaryPlatformMessageId,
599
+ receipt
600
+ };
601
+ }
602
+ const twitchMessageAdapter = defineChannelMessageAdapter({
603
+ id: "twitch",
604
+ durableFinal: { capabilities: {
605
+ text: true,
606
+ media: true,
607
+ messageSendingHooks: true
608
+ } },
609
+ send: {
610
+ text: async (ctx) => {
611
+ if (!twitchOutbound.sendText) throw new Error("Twitch text sending is not available.");
612
+ return toTwitchMessageSendResult(await twitchOutbound.sendText(ctx), "text");
613
+ },
614
+ media: async (ctx) => {
615
+ if (!twitchOutbound.sendMedia) throw new Error("Twitch media sending is not available.");
616
+ return toTwitchMessageSendResult(await twitchOutbound.sendMedia(ctx), "media");
617
+ }
618
+ }
619
+ });
620
+ //#endregion
621
+ //#region extensions/twitch/src/actions.ts
622
+ /**
623
+ * Twitch message actions adapter.
624
+ *
625
+ * Handles tool-based actions for Twitch, such as sending messages.
626
+ */
627
+ /**
628
+ * Create a tool result with error content.
629
+ */
630
+ function errorResponse(error) {
631
+ return {
632
+ content: [{
633
+ type: "text",
634
+ text: JSON.stringify({
635
+ ok: false,
636
+ error
637
+ })
638
+ }],
639
+ details: { ok: false }
640
+ };
641
+ }
642
+ /**
643
+ * Read a string parameter from action arguments.
644
+ *
645
+ * @param args - Action arguments
646
+ * @param key - Parameter key
647
+ * @param options - Options for reading the parameter
648
+ * @returns The parameter value or undefined if not found
649
+ */
650
+ function readStringParam(args, key, options = {}) {
651
+ const value = args[key];
652
+ if (value === void 0 || value === null) {
653
+ if (options.required) throw new Error(`Missing required parameter: ${key}`);
654
+ return;
655
+ }
656
+ if (typeof value === "string") return options.trim !== false ? value.trim() : value;
657
+ if (typeof value === "number" || typeof value === "boolean") {
658
+ const str = String(value);
659
+ return options.trim !== false ? str.trim() : str;
660
+ }
661
+ throw new Error(`Parameter ${key} must be a string, number, or boolean`);
662
+ }
663
+ /** Supported Twitch actions */
664
+ const TWITCH_ACTIONS = new Set(["send"]);
665
+ /**
666
+ * Twitch message actions adapter.
667
+ */
668
+ const twitchMessageActions = {
669
+ /**
670
+ * List available actions for this channel.
671
+ */
672
+ describeMessageTool: () => ({ actions: [...TWITCH_ACTIONS] }),
673
+ /**
674
+ * Check if an action is supported.
675
+ */
676
+ supportsAction: ({ action }) => TWITCH_ACTIONS.has(action),
677
+ /**
678
+ * Extract tool send parameters from action arguments.
679
+ *
680
+ * Parses and validates the "to" and "message" parameters for sending.
681
+ *
682
+ * @param params - Arguments from the tool call
683
+ * @returns Parsed send parameters or null if invalid
684
+ *
685
+ * @example
686
+ * const result = twitchMessageActions.extractToolSend!({
687
+ * args: { to: "#mychannel", message: "Hello!" }
688
+ * });
689
+ * // Returns: { to: "#mychannel", message: "Hello!" }
690
+ */
691
+ extractToolSend: ({ args }) => {
692
+ try {
693
+ const to = readStringParam(args, "to", { required: true });
694
+ const message = readStringParam(args, "message", { required: true });
695
+ if (!to || !message) return null;
696
+ return {
697
+ to,
698
+ message
699
+ };
700
+ } catch {
701
+ return null;
702
+ }
703
+ },
704
+ /**
705
+ * Handle an action execution.
706
+ *
707
+ * Processes the "send" action to send messages to Twitch.
708
+ *
709
+ * @param ctx - Action context including action type, parameters, and config
710
+ * @returns Tool result with content or null if action not supported
711
+ *
712
+ * @example
713
+ * const result = await twitchMessageActions.handleAction!({
714
+ * action: "send",
715
+ * params: { message: "Hello Twitch!", to: "#mychannel" },
716
+ * cfg: klawConfig,
717
+ * accountId: "default",
718
+ * });
719
+ */
720
+ handleAction: async (ctx) => {
721
+ if (ctx.action !== "send") return {
722
+ content: [{
723
+ type: "text",
724
+ text: "Unsupported action"
725
+ }],
726
+ details: {
727
+ ok: false,
728
+ error: "Unsupported action"
729
+ }
730
+ };
731
+ const message = readStringParam(ctx.params, "message", { required: true });
732
+ const to = readStringParam(ctx.params, "to", { required: false });
733
+ const accountId = ctx.accountId ?? resolveTwitchAccountContext(ctx.cfg).accountId;
734
+ const { account, availableAccountIds } = resolveTwitchAccountContext(ctx.cfg, accountId);
735
+ if (!account) return errorResponse(`Account not found: ${accountId}. Available accounts: ${availableAccountIds.join(", ") || "none"}`);
736
+ const targetChannel = to || account.channel;
737
+ if (!targetChannel) return errorResponse("No channel specified and no default channel in account config");
738
+ if (!twitchOutbound.sendText) return errorResponse("sendText not implemented");
739
+ try {
740
+ const result = await twitchOutbound.sendText({
741
+ cfg: ctx.cfg,
742
+ to: targetChannel,
743
+ text: message ?? "",
744
+ accountId
745
+ });
746
+ return {
747
+ content: [{
748
+ type: "text",
749
+ text: JSON.stringify(result)
750
+ }],
751
+ details: { ok: true }
752
+ };
753
+ } catch (error) {
754
+ return errorResponse(formatErrorMessage(error));
755
+ }
756
+ }
757
+ };
758
+ //#endregion
759
+ //#region extensions/twitch/src/config-schema.ts
760
+ /**
761
+ * Twitch user roles that can be allowed to interact with the bot
762
+ */
763
+ const TwitchRoleSchema = z.enum([
764
+ "moderator",
765
+ "owner",
766
+ "vip",
767
+ "subscriber",
768
+ "all"
769
+ ]);
770
+ const TwitchAccountShape = {
771
+ /** Twitch username */
772
+ username: z.string(),
773
+ /** Twitch OAuth access token (requires chat:read and chat:write scopes) */
774
+ accessToken: z.string(),
775
+ /** Twitch client ID (from Twitch Developer Portal or twitchtokengenerator.com) */
776
+ clientId: z.string().optional(),
777
+ /** Channel name to join */
778
+ channel: z.string().min(1),
779
+ /** Enable this account */
780
+ enabled: z.boolean().optional(),
781
+ /** Allowlist of Twitch user IDs who can interact with the bot (use IDs for safety, not usernames) */
782
+ allowFrom: z.array(z.string()).optional(),
783
+ /** Roles allowed to interact with the bot (e.g., ["moderator", "vip", "subscriber"]) */
784
+ allowedRoles: z.array(TwitchRoleSchema).optional(),
785
+ /** Require @mention to trigger bot responses */
786
+ requireMention: z.boolean().optional(),
787
+ /** Outbound response prefix override for this channel/account. */
788
+ responsePrefix: z.string().optional(),
789
+ /** Twitch client secret (required for token refresh via RefreshingAuthProvider) */
790
+ clientSecret: z.string().optional(),
791
+ /** Refresh token (required for automatic token refresh) */
792
+ refreshToken: z.string().optional(),
793
+ /** Token expiry time in seconds (optional, for token refresh tracking) */
794
+ expiresIn: z.number().nullable().optional(),
795
+ /** Timestamp when token was obtained (optional, for token refresh tracking) */
796
+ obtainmentTimestamp: z.number().optional()
797
+ };
798
+ /**
799
+ * Twitch account configuration schema
800
+ */
801
+ const TwitchAccountSchema = z.object(TwitchAccountShape);
802
+ /**
803
+ * Base configuration properties shared by both single and multi-account modes
804
+ */
805
+ const TwitchConfigBaseShape = {
806
+ name: z.string().optional(),
807
+ enabled: z.boolean().optional(),
808
+ markdown: MarkdownConfigSchema.optional(),
809
+ defaultAccount: z.string().optional()
810
+ };
811
+ /**
812
+ * Simplified single-account configuration schema
813
+ *
814
+ * Use this for single-account setups. Properties are at the top level,
815
+ * creating an implicit "default" account.
816
+ */
817
+ const SimplifiedSchema = z.object({
818
+ ...TwitchConfigBaseShape,
819
+ ...TwitchAccountShape
820
+ });
821
+ /**
822
+ * Multi-account configuration schema
823
+ *
824
+ * Use this for multi-account setups. Each key is an account ID (e.g., "default", "secondary").
825
+ */
826
+ const MultiAccountSchema = z.object({
827
+ ...TwitchConfigBaseShape,
828
+ /** Per-account configuration (for multi-account setups) */
829
+ accounts: z.record(z.string(), TwitchAccountSchema)
830
+ }).refine((val) => Object.keys(val.accounts || {}).length > 0, { message: "accounts must contain at least one entry" });
831
+ /**
832
+ * Twitch plugin configuration schema
833
+ *
834
+ * Supports two mutually exclusive patterns:
835
+ * 1. Simplified single-account: username, accessToken, clientId, channel at top level
836
+ * 2. Multi-account: accounts object with named account configs
837
+ *
838
+ * The union ensures clear discrimination between the two modes.
839
+ */
840
+ const TwitchConfigSchema = z.union([SimplifiedSchema, MultiAccountSchema]);
841
+ //#endregion
842
+ //#region extensions/twitch/src/probe.ts
843
+ /**
844
+ * Probe a Twitch account to verify the connection is working
845
+ *
846
+ * This tests the Twitch OAuth token by attempting to connect
847
+ * to the chat server and verify the bot's username.
848
+ */
849
+ async function probeTwitch(account, timeoutMs) {
850
+ const started = Date.now();
851
+ if (!account.accessToken || !account.username) return {
852
+ ok: false,
853
+ error: "missing credentials (accessToken, username)",
854
+ username: account.username,
855
+ elapsedMs: Date.now() - started
856
+ };
857
+ const rawToken = normalizeToken(account.accessToken.trim());
858
+ let client;
859
+ try {
860
+ client = new ChatClient({ authProvider: new StaticAuthProvider(account.clientId ?? "", rawToken) });
861
+ const connectionPromise = new Promise((resolve, reject) => {
862
+ let settled = false;
863
+ let connectListener;
864
+ let disconnectListener;
865
+ let authFailListener;
866
+ const cleanup = () => {
867
+ if (settled) return;
868
+ settled = true;
869
+ connectListener?.unbind();
870
+ disconnectListener?.unbind();
871
+ authFailListener?.unbind();
872
+ };
873
+ connectListener = client?.onConnect(() => {
874
+ cleanup();
875
+ resolve();
876
+ });
877
+ disconnectListener = client?.onDisconnect((_manually, reason) => {
878
+ cleanup();
879
+ reject(reason || /* @__PURE__ */ new Error("Disconnected"));
880
+ });
881
+ authFailListener = client?.onAuthenticationFailure(() => {
882
+ cleanup();
883
+ reject(/* @__PURE__ */ new Error("Authentication failed"));
884
+ });
885
+ });
886
+ let timeoutHandle;
887
+ const timeout = new Promise((_, reject) => {
888
+ timeoutHandle = setTimeout(() => reject(/* @__PURE__ */ new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
889
+ });
890
+ client.connect();
891
+ try {
892
+ await Promise.race([connectionPromise, timeout]);
893
+ } finally {
894
+ if (timeoutHandle) clearTimeout(timeoutHandle);
895
+ }
896
+ client.quit();
897
+ client = void 0;
898
+ return {
899
+ ok: true,
900
+ connected: true,
901
+ username: account.username,
902
+ channel: account.channel,
903
+ elapsedMs: Date.now() - started
904
+ };
905
+ } catch (error) {
906
+ return {
907
+ ok: false,
908
+ error: formatErrorMessage(error),
909
+ username: account.username,
910
+ channel: account.channel,
911
+ elapsedMs: Date.now() - started
912
+ };
913
+ } finally {
914
+ if (client) try {
915
+ client.quit();
916
+ } catch {}
917
+ }
918
+ }
919
+ //#endregion
920
+ //#region extensions/twitch/src/resolver.ts
921
+ /**
922
+ * Twitch resolver adapter for channel/user name resolution.
923
+ *
924
+ * This module implements the ChannelResolverAdapter interface to resolve
925
+ * Twitch usernames to user IDs via the Twitch Helix API.
926
+ */
927
+ /**
928
+ * Normalize a Twitch username - strip @ prefix and convert to lowercase
929
+ */
930
+ function normalizeUsername(input) {
931
+ const trimmed = input.trim();
932
+ if (trimmed.startsWith("@")) return normalizeLowercaseStringOrEmpty(trimmed.slice(1));
933
+ return normalizeLowercaseStringOrEmpty(trimmed);
934
+ }
935
+ /**
936
+ * Create a logger that includes the Twitch prefix
937
+ */
938
+ function createLogger(logger) {
939
+ return {
940
+ info: (msg) => logger?.info(msg),
941
+ warn: (msg) => logger?.warn(msg),
942
+ error: (msg) => logger?.error(msg),
943
+ debug: (msg) => logger?.debug?.(msg) ?? (() => {})
944
+ };
945
+ }
946
+ /**
947
+ * Resolve Twitch usernames to user IDs via the Helix API
948
+ *
949
+ * @param inputs - Array of usernames or user IDs to resolve
950
+ * @param account - Twitch account configuration with auth credentials
951
+ * @param kind - Type of target to resolve ("user" or "group")
952
+ * @param logger - Optional logger
953
+ * @returns Promise resolving to array of ChannelResolveResult
954
+ */
955
+ async function resolveTwitchTargets(inputs, account, _kind, logger) {
956
+ const log = createLogger(logger);
957
+ if (!account.clientId || !account.accessToken) {
958
+ log.error("Missing Twitch client ID or accessToken");
959
+ return inputs.map((input) => ({
960
+ input,
961
+ resolved: false,
962
+ note: "missing Twitch credentials"
963
+ }));
964
+ }
965
+ const normalizedToken = normalizeToken(account.accessToken);
966
+ const apiClient = new ApiClient({ authProvider: new StaticAuthProvider(account.clientId, normalizedToken) });
967
+ const results = [];
968
+ for (const input of inputs) {
969
+ const normalized = normalizeUsername(input);
970
+ if (!normalized) {
971
+ results.push({
972
+ input,
973
+ resolved: false,
974
+ note: "empty input"
975
+ });
976
+ continue;
977
+ }
978
+ const looksLikeUserId = /^\d+$/.test(normalized);
979
+ try {
980
+ if (looksLikeUserId) {
981
+ const user = await apiClient.users.getUserById(normalized);
982
+ if (user) {
983
+ results.push({
984
+ input,
985
+ resolved: true,
986
+ id: user.id,
987
+ name: user.name
988
+ });
989
+ log.debug?.(`Resolved user ID ${normalized} -> ${user.name}`);
990
+ } else {
991
+ results.push({
992
+ input,
993
+ resolved: false,
994
+ note: "user ID not found"
995
+ });
996
+ log.warn(`User ID ${normalized} not found`);
997
+ }
998
+ } else {
999
+ const user = await apiClient.users.getUserByName(normalized);
1000
+ if (user) {
1001
+ results.push({
1002
+ input,
1003
+ resolved: true,
1004
+ id: user.id,
1005
+ name: user.name,
1006
+ note: user.displayName !== user.name ? `display: ${user.displayName}` : void 0
1007
+ });
1008
+ log.debug?.(`Resolved username ${normalized} -> ${user.id} (${user.name})`);
1009
+ } else {
1010
+ results.push({
1011
+ input,
1012
+ resolved: false,
1013
+ note: "username not found"
1014
+ });
1015
+ log.warn(`Username ${normalized} not found`);
1016
+ }
1017
+ }
1018
+ } catch (error) {
1019
+ const errorMessage = formatErrorMessage(error);
1020
+ results.push({
1021
+ input,
1022
+ resolved: false,
1023
+ note: `API error: ${errorMessage}`
1024
+ });
1025
+ log.error(`Failed to resolve ${input}: ${errorMessage}`);
1026
+ }
1027
+ }
1028
+ return results;
1029
+ }
1030
+ //#endregion
1031
+ //#region extensions/twitch/src/status.ts
1032
+ /**
1033
+ * Collect status issues for Twitch accounts.
1034
+ *
1035
+ * Analyzes account snapshots and detects configuration problems,
1036
+ * authentication issues, and other potential problems.
1037
+ *
1038
+ * @param accounts - Array of account snapshots to analyze
1039
+ * @param getCfg - Optional function to get full config for additional checks
1040
+ * @returns Array of detected status issues
1041
+ *
1042
+ * @example
1043
+ * const issues = collectTwitchStatusIssues(accountSnapshots);
1044
+ * if (issues.length > 0) {
1045
+ * console.warn("Twitch configuration issues detected:");
1046
+ * issues.forEach(issue => console.warn(`- ${issue.message}`));
1047
+ * }
1048
+ */
1049
+ function collectTwitchStatusIssues(accounts, getCfg) {
1050
+ const issues = [];
1051
+ for (const entry of accounts) {
1052
+ const accountId = entry.accountId;
1053
+ if (!accountId) continue;
1054
+ let account = null;
1055
+ let cfg;
1056
+ if (getCfg) try {
1057
+ cfg = getCfg();
1058
+ account = getAccountConfig(cfg, accountId);
1059
+ } catch {}
1060
+ if (!entry.configured) {
1061
+ issues.push({
1062
+ channel: "twitch",
1063
+ accountId,
1064
+ kind: "config",
1065
+ message: "Twitch account is not properly configured",
1066
+ fix: "Add required fields: username, accessToken, and clientId to your account configuration"
1067
+ });
1068
+ continue;
1069
+ }
1070
+ if (entry.enabled === false) {
1071
+ issues.push({
1072
+ channel: "twitch",
1073
+ accountId,
1074
+ kind: "config",
1075
+ message: "Twitch account is disabled",
1076
+ fix: "Set enabled: true in your account configuration to enable this account"
1077
+ });
1078
+ continue;
1079
+ }
1080
+ if (account && account.username && account.accessToken && !account.clientId) issues.push({
1081
+ channel: "twitch",
1082
+ accountId,
1083
+ kind: "config",
1084
+ message: "Twitch client ID is required",
1085
+ fix: "Add clientId to your Twitch account configuration (from Twitch Developer Portal)"
1086
+ });
1087
+ const tokenResolution = cfg ? resolveTwitchToken(cfg, { accountId }) : {
1088
+ token: "",
1089
+ source: "none"
1090
+ };
1091
+ if (account && isAccountConfigured(account, tokenResolution.token)) {
1092
+ if (account.accessToken?.startsWith("oauth:")) issues.push({
1093
+ channel: "twitch",
1094
+ accountId,
1095
+ kind: "config",
1096
+ message: "Token contains 'oauth:' prefix (will be stripped)",
1097
+ fix: "The 'oauth:' prefix is optional. You can use just the token value, or keep it as-is (it will be normalized automatically)."
1098
+ });
1099
+ if (account.clientSecret && !account.refreshToken) issues.push({
1100
+ channel: "twitch",
1101
+ accountId,
1102
+ kind: "config",
1103
+ message: "clientSecret provided without refreshToken",
1104
+ fix: "For automatic token refresh, provide both clientSecret and refreshToken. Otherwise, clientSecret is not needed."
1105
+ });
1106
+ if (account.allowFrom && account.allowFrom.length === 0) issues.push({
1107
+ channel: "twitch",
1108
+ accountId,
1109
+ kind: "config",
1110
+ message: "allowFrom is configured but empty",
1111
+ fix: "Either add user IDs to allowFrom, remove the allowFrom field, or use allowedRoles instead."
1112
+ });
1113
+ if (account.allowedRoles?.includes("all") && account.allowFrom && account.allowFrom.length > 0) issues.push({
1114
+ channel: "twitch",
1115
+ accountId,
1116
+ kind: "intent",
1117
+ message: "allowedRoles is set to 'all' but allowFrom is also configured",
1118
+ fix: "When allowedRoles is 'all', the allowFrom list is not needed. Remove allowFrom or set allowedRoles to specific roles."
1119
+ });
1120
+ }
1121
+ if (entry.lastError) issues.push({
1122
+ channel: "twitch",
1123
+ accountId,
1124
+ kind: "runtime",
1125
+ message: `Last error: ${entry.lastError}`,
1126
+ fix: "Check your token validity and network connection. Ensure the bot has the required OAuth scopes."
1127
+ });
1128
+ if (entry.configured && !entry.running && !entry.lastStartAt && !entry.lastInboundAt && !entry.lastOutboundAt) issues.push({
1129
+ channel: "twitch",
1130
+ accountId,
1131
+ kind: "runtime",
1132
+ message: "Account has never connected successfully",
1133
+ fix: "Start the Twitch gateway to begin receiving messages. Check logs for connection errors."
1134
+ });
1135
+ if (entry.running && entry.lastStartAt) {
1136
+ const daysSinceStart = (Date.now() - entry.lastStartAt) / (1e3 * 60 * 60 * 24);
1137
+ if (daysSinceStart > 7) issues.push({
1138
+ channel: "twitch",
1139
+ accountId,
1140
+ kind: "runtime",
1141
+ message: `Connection has been running for ${Math.floor(daysSinceStart)} days`,
1142
+ fix: "Consider restarting the connection periodically to refresh the connection. Twitch tokens may expire after long periods."
1143
+ });
1144
+ }
1145
+ }
1146
+ return issues;
1147
+ }
1148
+ //#endregion
1149
+ //#region extensions/twitch/src/plugin.ts
1150
+ /**
1151
+ * Twitch channel plugin for Klaw.
1152
+ *
1153
+ * Main plugin export combining all adapters (outbound, actions, status, gateway).
1154
+ * This is the primary entry point for the Twitch channel integration.
1155
+ */
1156
+ /**
1157
+ * Twitch channel plugin.
1158
+ *
1159
+ * Implements the ChannelPlugin interface to provide Twitch chat integration
1160
+ * for Klaw. Supports message sending, receiving, access control, and
1161
+ * status monitoring.
1162
+ */
1163
+ const twitchPlugin = createChatChannelPlugin({
1164
+ pairing: {
1165
+ idLabel: "twitchUserId",
1166
+ normalizeAllowEntry: createPairingPrefixStripper(/^(twitch:)?user:?/i),
1167
+ notifyApproval: createLoggedPairingApprovalNotifier(({ id }) => `Pairing approved for user ${id} (notification sent via chat if possible)`, console.warn)
1168
+ },
1169
+ outbound: twitchOutbound,
1170
+ base: {
1171
+ id: "twitch",
1172
+ meta: {
1173
+ id: "twitch",
1174
+ label: "Twitch",
1175
+ selectionLabel: "Twitch (Chat)",
1176
+ docsPath: "/channels/twitch",
1177
+ blurb: "Twitch chat integration",
1178
+ aliases: ["twitch-chat"]
1179
+ },
1180
+ setup: twitchSetupAdapter,
1181
+ setupWizard: twitchSetupWizard,
1182
+ capabilities: { chatTypes: ["group"] },
1183
+ message: twitchMessageAdapter,
1184
+ configSchema: buildChannelConfigSchema(TwitchConfigSchema),
1185
+ config: {
1186
+ listAccountIds: (cfg) => listAccountIds(cfg),
1187
+ resolveAccount: (cfg, accountId) => {
1188
+ const resolvedAccountId = accountId ?? resolveDefaultTwitchAccountId(cfg);
1189
+ const account = getAccountConfig(cfg, resolvedAccountId);
1190
+ if (!account) return {
1191
+ accountId: resolvedAccountId,
1192
+ channel: "",
1193
+ username: "",
1194
+ accessToken: "",
1195
+ clientId: "",
1196
+ enabled: false
1197
+ };
1198
+ return {
1199
+ accountId: resolvedAccountId,
1200
+ ...account
1201
+ };
1202
+ },
1203
+ defaultAccountId: (cfg) => resolveDefaultTwitchAccountId(cfg),
1204
+ isConfigured: (_account, cfg) => resolveTwitchAccountContext(cfg).configured,
1205
+ isEnabled: (account) => account?.enabled !== false,
1206
+ describeAccount: (account) => account ? describeAccountSnapshot({
1207
+ account,
1208
+ configured: isAccountConfigured(account, account.accessToken)
1209
+ }) : {
1210
+ accountId: DEFAULT_ACCOUNT_ID,
1211
+ enabled: false,
1212
+ configured: false
1213
+ }
1214
+ },
1215
+ actions: twitchMessageActions,
1216
+ resolver: { resolveTargets: async ({ cfg, accountId, inputs, kind, runtime }) => {
1217
+ const account = getAccountConfig(cfg, accountId ?? resolveDefaultTwitchAccountId(cfg));
1218
+ if (!account) return inputs.map((input) => ({
1219
+ input,
1220
+ resolved: false,
1221
+ note: "account not configured"
1222
+ }));
1223
+ return await resolveTwitchTargets(inputs, account, kind, {
1224
+ info: (msg) => runtime.log(msg),
1225
+ warn: (msg) => runtime.log(msg),
1226
+ error: (msg) => runtime.error(msg),
1227
+ debug: (msg) => runtime.log(msg)
1228
+ });
1229
+ } },
1230
+ status: createComputedAccountStatusAdapter({
1231
+ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
1232
+ buildChannelSummary: ({ snapshot }) => buildPassiveProbedChannelStatusSummary(snapshot),
1233
+ probeAccount: async ({ account, timeoutMs }) => await probeTwitch(account, timeoutMs),
1234
+ collectStatusIssues: collectTwitchStatusIssues,
1235
+ resolveAccountSnapshot: ({ account, cfg }) => {
1236
+ const resolvedAccountId = account.accountId || resolveTwitchSnapshotAccountId(cfg, account);
1237
+ const { configured } = resolveTwitchAccountContext(cfg, resolvedAccountId);
1238
+ return {
1239
+ accountId: resolvedAccountId,
1240
+ enabled: account.enabled !== false,
1241
+ configured
1242
+ };
1243
+ }
1244
+ }),
1245
+ gateway: {
1246
+ startAccount: async (ctx) => {
1247
+ const account = ctx.account;
1248
+ const accountId = ctx.accountId;
1249
+ ctx.setStatus?.({
1250
+ accountId,
1251
+ running: true,
1252
+ lastStartAt: Date.now(),
1253
+ lastError: null
1254
+ });
1255
+ ctx.log?.info(`Starting Twitch connection for ${account.username}`);
1256
+ await runStoppablePassiveMonitor({
1257
+ abortSignal: ctx.abortSignal,
1258
+ start: async () => {
1259
+ const { monitorTwitchProvider } = await import("./monitor-j1GtQVBd.js");
1260
+ return monitorTwitchProvider({
1261
+ account,
1262
+ accountId,
1263
+ config: ctx.cfg,
1264
+ runtime: ctx.runtime,
1265
+ abortSignal: ctx.abortSignal
1266
+ });
1267
+ }
1268
+ });
1269
+ },
1270
+ stopAccount: async (ctx) => {
1271
+ const account = ctx.account;
1272
+ const accountId = ctx.accountId;
1273
+ await removeClientManager(accountId);
1274
+ ctx.setStatus?.({
1275
+ accountId,
1276
+ running: false,
1277
+ lastStopAt: Date.now()
1278
+ });
1279
+ ctx.log?.info(`Stopped Twitch connection for ${account.username}`);
1280
+ }
1281
+ }
1282
+ }
1283
+ });
1284
+ //#endregion
1285
+ export { stripMarkdownForTwitch as n, getOrCreateClientManager as r, twitchPlugin as t };