@openclaw/twitch 2026.5.2 → 2026.5.3-beta.1

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
package/dist/api.js ADDED
@@ -0,0 +1,3 @@
1
+ import { t as twitchPlugin } from "./plugin-BQX9GiIn.js";
2
+ import { n as setTwitchRuntime } from "./runtime-QZ5I3GlI.js";
3
+ export { setTwitchRuntime, twitchPlugin };
@@ -0,0 +1,2 @@
1
+ import { t as twitchPlugin } from "./plugin-BQX9GiIn.js";
2
+ export { twitchPlugin };
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
2
+ //#region extensions/twitch/index.ts
3
+ var twitch_default = defineBundledChannelEntry({
4
+ id: "twitch",
5
+ name: "Twitch",
6
+ description: "Twitch IRC chat channel plugin",
7
+ importMetaUrl: import.meta.url,
8
+ plugin: {
9
+ specifier: "./channel-plugin-api.js",
10
+ exportName: "twitchPlugin"
11
+ },
12
+ runtime: {
13
+ specifier: "./api.js",
14
+ exportName: "setTwitchRuntime"
15
+ }
16
+ });
17
+ //#endregion
18
+ export { twitch_default as default };
@@ -0,0 +1,306 @@
1
+ import { i as normalizeToken, o as resolveTwitchToken } from "./twitch-CklAMZL5.js";
2
+ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
3
+ import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth";
4
+ import { ChatClient, LogLevel } from "@twurple/chat";
5
+ //#region extensions/twitch/src/twitch-client.ts
6
+ /**
7
+ * Manages Twitch chat client connections
8
+ */
9
+ var TwitchClientManager = class {
10
+ constructor(logger) {
11
+ this.logger = logger;
12
+ this.clients = /* @__PURE__ */ new Map();
13
+ this.messageHandlers = /* @__PURE__ */ new Map();
14
+ }
15
+ /**
16
+ * Create an auth provider for the account.
17
+ */
18
+ async createAuthProvider(account, normalizedToken) {
19
+ if (!account.clientId) throw new Error("Missing Twitch client ID");
20
+ if (account.clientSecret) {
21
+ const authProvider = new RefreshingAuthProvider({
22
+ clientId: account.clientId,
23
+ clientSecret: account.clientSecret
24
+ });
25
+ await authProvider.addUserForToken({
26
+ accessToken: normalizedToken,
27
+ refreshToken: account.refreshToken ?? null,
28
+ expiresIn: account.expiresIn ?? null,
29
+ obtainmentTimestamp: account.obtainmentTimestamp ?? Date.now()
30
+ }).then((userId) => {
31
+ this.logger.info(`Added user ${userId} to RefreshingAuthProvider for ${account.username}`);
32
+ }).catch((err) => {
33
+ this.logger.error(`Failed to add user to RefreshingAuthProvider: ${formatErrorMessage(err)}`);
34
+ });
35
+ authProvider.onRefresh((userId, token) => {
36
+ this.logger.info(`Access token refreshed for user ${userId} (expires in ${token.expiresIn ? `${token.expiresIn}s` : "unknown"})`);
37
+ });
38
+ authProvider.onRefreshFailure((userId, error) => {
39
+ this.logger.error(`Failed to refresh access token for user ${userId}: ${error.message}`);
40
+ });
41
+ const refreshStatus = account.refreshToken ? "automatic token refresh enabled" : "token refresh disabled (no refresh token)";
42
+ this.logger.info(`Using RefreshingAuthProvider for ${account.username} (${refreshStatus})`);
43
+ return authProvider;
44
+ }
45
+ this.logger.info(`Using StaticAuthProvider for ${account.username} (no clientSecret provided)`);
46
+ return new StaticAuthProvider(account.clientId, normalizedToken);
47
+ }
48
+ /**
49
+ * Get or create a chat client for an account
50
+ */
51
+ async getClient(account, cfg, accountId) {
52
+ const key = this.getAccountKey(account);
53
+ const existing = this.clients.get(key);
54
+ if (existing) return existing;
55
+ const tokenResolution = resolveTwitchToken(cfg, { accountId });
56
+ if (!tokenResolution.token) {
57
+ this.logger.error(`Missing Twitch token for account ${account.username} (set channels.twitch.accounts.${account.username}.token or OPENCLAW_TWITCH_ACCESS_TOKEN for default)`);
58
+ throw new Error("Missing Twitch token");
59
+ }
60
+ this.logger.debug?.(`Using ${tokenResolution.source} token source for ${account.username}`);
61
+ if (!account.clientId) {
62
+ this.logger.error(`Missing Twitch client ID for account ${account.username}`);
63
+ throw new Error("Missing Twitch client ID");
64
+ }
65
+ const normalizedToken = normalizeToken(tokenResolution.token);
66
+ const client = new ChatClient({
67
+ authProvider: await this.createAuthProvider(account, normalizedToken),
68
+ channels: [account.channel],
69
+ rejoinChannelsOnReconnect: true,
70
+ requestMembershipEvents: true,
71
+ logger: {
72
+ minLevel: LogLevel.WARNING,
73
+ custom: { log: (level, message) => {
74
+ switch (level) {
75
+ case LogLevel.CRITICAL:
76
+ this.logger.error(message);
77
+ break;
78
+ case LogLevel.ERROR:
79
+ this.logger.error(message);
80
+ break;
81
+ case LogLevel.WARNING:
82
+ this.logger.warn(message);
83
+ break;
84
+ case LogLevel.INFO:
85
+ this.logger.info(message);
86
+ break;
87
+ case LogLevel.DEBUG:
88
+ this.logger.debug?.(message);
89
+ break;
90
+ case LogLevel.TRACE:
91
+ this.logger.debug?.(message);
92
+ break;
93
+ }
94
+ } }
95
+ }
96
+ });
97
+ this.setupClientHandlers(client, account);
98
+ client.connect();
99
+ this.clients.set(key, client);
100
+ this.logger.info(`Connected to Twitch as ${account.username}`);
101
+ return client;
102
+ }
103
+ /**
104
+ * Set up message and event handlers for a client
105
+ */
106
+ setupClientHandlers(client, account) {
107
+ const key = this.getAccountKey(account);
108
+ client.onMessage((channelName, _user, messageText, msg) => {
109
+ const handler = this.messageHandlers.get(key);
110
+ if (handler) {
111
+ const normalizedChannel = channelName.startsWith("#") ? channelName.slice(1) : channelName;
112
+ const from = `twitch:${msg.userInfo.userName}`;
113
+ const preview = messageText.slice(0, 100).replace(/\n/g, "\\n");
114
+ this.logger.debug?.(`twitch inbound: channel=${normalizedChannel} from=${from} len=${messageText.length} preview="${preview}"`);
115
+ handler({
116
+ username: msg.userInfo.userName,
117
+ displayName: msg.userInfo.displayName,
118
+ userId: msg.userInfo.userId,
119
+ message: messageText,
120
+ channel: normalizedChannel,
121
+ id: msg.id,
122
+ timestamp: /* @__PURE__ */ new Date(),
123
+ isMod: msg.userInfo.isMod,
124
+ isOwner: msg.userInfo.isBroadcaster,
125
+ isVip: msg.userInfo.isVip,
126
+ isSub: msg.userInfo.isSubscriber,
127
+ chatType: "group"
128
+ });
129
+ }
130
+ });
131
+ this.logger.info(`Set up handlers for ${key}`);
132
+ }
133
+ /**
134
+ * Set a message handler for an account
135
+ * @returns A function that removes the handler when called
136
+ */
137
+ onMessage(account, handler) {
138
+ const key = this.getAccountKey(account);
139
+ this.messageHandlers.set(key, handler);
140
+ return () => {
141
+ this.messageHandlers.delete(key);
142
+ };
143
+ }
144
+ /**
145
+ * Disconnect a client
146
+ */
147
+ async disconnect(account) {
148
+ const key = this.getAccountKey(account);
149
+ const client = this.clients.get(key);
150
+ if (client) {
151
+ client.quit();
152
+ this.clients.delete(key);
153
+ this.messageHandlers.delete(key);
154
+ this.logger.info(`Disconnected ${key}`);
155
+ }
156
+ }
157
+ /**
158
+ * Disconnect all clients
159
+ */
160
+ async disconnectAll() {
161
+ this.clients.forEach((client) => client.quit());
162
+ this.clients.clear();
163
+ this.messageHandlers.clear();
164
+ this.logger.info(" Disconnected all clients");
165
+ }
166
+ /**
167
+ * Send a message to a channel
168
+ */
169
+ async sendMessage(account, channel, message, cfg, accountId) {
170
+ try {
171
+ const client = await this.getClient(account, cfg, accountId);
172
+ const messageId = crypto.randomUUID();
173
+ await client.say(channel, message);
174
+ return {
175
+ ok: true,
176
+ messageId
177
+ };
178
+ } catch (error) {
179
+ this.logger.error(`Failed to send message: ${formatErrorMessage(error)}`);
180
+ return {
181
+ ok: false,
182
+ error: formatErrorMessage(error)
183
+ };
184
+ }
185
+ }
186
+ /**
187
+ * Generate a unique key for an account
188
+ */
189
+ getAccountKey(account) {
190
+ return `${account.username}:${account.channel}`;
191
+ }
192
+ /**
193
+ * Clear all clients and handlers (for testing)
194
+ */
195
+ _clearForTest() {
196
+ this.clients.clear();
197
+ this.messageHandlers.clear();
198
+ }
199
+ };
200
+ //#endregion
201
+ //#region extensions/twitch/src/client-manager-registry.ts
202
+ /**
203
+ * Client manager registry for Twitch plugin.
204
+ *
205
+ * Manages the lifecycle of TwitchClientManager instances across the plugin,
206
+ * ensuring proper cleanup when accounts are stopped or reconfigured.
207
+ */
208
+ /**
209
+ * Global registry of client managers.
210
+ * Keyed by account ID.
211
+ */
212
+ const registry = /* @__PURE__ */ new Map();
213
+ /**
214
+ * Get or create a client manager for an account.
215
+ *
216
+ * @param accountId - The account ID
217
+ * @param logger - Logger instance
218
+ * @returns The client manager
219
+ */
220
+ function getOrCreateClientManager(accountId, logger) {
221
+ const existing = registry.get(accountId);
222
+ if (existing) return existing.manager;
223
+ const manager = new TwitchClientManager(logger);
224
+ registry.set(accountId, {
225
+ manager,
226
+ accountId,
227
+ logger,
228
+ createdAt: Date.now()
229
+ });
230
+ logger.info(`Registered client manager for account: ${accountId}`);
231
+ return manager;
232
+ }
233
+ /**
234
+ * Get an existing client manager for an account.
235
+ *
236
+ * @param accountId - The account ID
237
+ * @returns The client manager, or undefined if not registered
238
+ */
239
+ function getClientManager(accountId) {
240
+ return registry.get(accountId)?.manager;
241
+ }
242
+ /**
243
+ * Disconnect and remove a client manager from the registry.
244
+ *
245
+ * @param accountId - The account ID
246
+ * @returns Promise that resolves when cleanup is complete
247
+ */
248
+ async function removeClientManager(accountId) {
249
+ const entry = registry.get(accountId);
250
+ if (!entry) return;
251
+ await entry.manager.disconnectAll();
252
+ registry.delete(accountId);
253
+ entry.logger.info(`Unregistered client manager for account: ${accountId}`);
254
+ }
255
+ //#endregion
256
+ //#region extensions/twitch/src/utils/markdown.ts
257
+ /**
258
+ * Markdown utilities for Twitch chat
259
+ *
260
+ * Twitch chat doesn't support markdown formatting, so we strip it before sending.
261
+ * Based on OpenClaw's markdownToText in src/agents/tools/web-fetch-utils.ts.
262
+ */
263
+ /**
264
+ * Strip markdown formatting from text for Twitch compatibility.
265
+ *
266
+ * Removes images, links, bold, italic, strikethrough, code blocks, inline code,
267
+ * headers, and list formatting. Replaces newlines with spaces since Twitch
268
+ * is a single-line chat medium.
269
+ *
270
+ * @param markdown - The markdown text to strip
271
+ * @returns Plain text with markdown removed
272
+ */
273
+ function stripMarkdownForTwitch(markdown) {
274
+ 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();
275
+ }
276
+ /**
277
+ * Simple word-boundary chunker for Twitch (500 char limit).
278
+ * Strips markdown before chunking to avoid breaking markdown patterns.
279
+ *
280
+ * @param text - The text to chunk
281
+ * @param limit - Maximum characters per chunk (Twitch limit is 500)
282
+ * @returns Array of text chunks
283
+ */
284
+ function chunkTextForTwitch(text, limit) {
285
+ const cleaned = stripMarkdownForTwitch(text);
286
+ if (!cleaned) return [];
287
+ if (limit <= 0) return [cleaned];
288
+ if (cleaned.length <= limit) return [cleaned];
289
+ const chunks = [];
290
+ let remaining = cleaned;
291
+ while (remaining.length > limit) {
292
+ const window = remaining.slice(0, limit);
293
+ const lastSpaceIndex = window.lastIndexOf(" ");
294
+ if (lastSpaceIndex === -1) {
295
+ chunks.push(window);
296
+ remaining = remaining.slice(limit);
297
+ } else {
298
+ chunks.push(window.slice(0, lastSpaceIndex));
299
+ remaining = remaining.slice(lastSpaceIndex + 1);
300
+ }
301
+ }
302
+ if (remaining) chunks.push(remaining);
303
+ return chunks;
304
+ }
305
+ //#endregion
306
+ export { removeClientManager as a, getOrCreateClientManager as i, stripMarkdownForTwitch as n, getClientManager as r, chunkTextForTwitch as t };
@@ -0,0 +1,333 @@
1
+ import { i as getOrCreateClientManager, n as stripMarkdownForTwitch } from "./markdown-MRdI1sR7.js";
2
+ import { t as getTwitchRuntime } from "./runtime-QZ5I3GlI.js";
3
+ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
4
+ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
5
+ import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
6
+ //#region extensions/twitch/src/access-control.ts
7
+ /**
8
+ * Check if a Twitch message should be allowed based on account configuration
9
+ *
10
+ * This function implements the access control logic for incoming Twitch messages,
11
+ * checking allowlists, role-based restrictions, and mention requirements.
12
+ *
13
+ * Priority order:
14
+ * 1. If `requireMention` is true, message must mention the bot
15
+ * 2. If `allowFrom` is set, sender must be in the allowlist (by user ID)
16
+ * 3. If `allowedRoles` is set (and `allowFrom` is not), sender must have at least one role
17
+ *
18
+ * Note: `allowFrom` is a hard allowlist. When set, only those user IDs are allowed.
19
+ * Use `allowedRoles` as an alternative when you don't want to maintain an allowlist.
20
+ *
21
+ * Available roles:
22
+ * - "moderator": Moderators
23
+ * - "owner": Channel owner/broadcaster
24
+ * - "vip": VIPs
25
+ * - "subscriber": Subscribers
26
+ * - "all": Anyone in the chat
27
+ */
28
+ function checkTwitchAccessControl(params) {
29
+ const { message, account, botUsername } = params;
30
+ if (account.requireMention ?? true) {
31
+ if (!extractMentions(message.message).includes(normalizeLowercaseStringOrEmpty(botUsername))) return {
32
+ allowed: false,
33
+ reason: "message does not mention the bot (requireMention is enabled)"
34
+ };
35
+ }
36
+ if (account.allowFrom !== void 0) {
37
+ const allowFrom = account.allowFrom;
38
+ if (allowFrom.length === 0) return {
39
+ allowed: false,
40
+ reason: "sender is not in allowFrom allowlist"
41
+ };
42
+ const senderId = message.userId;
43
+ if (!senderId) return {
44
+ allowed: false,
45
+ reason: "sender user ID not available for allowlist check"
46
+ };
47
+ if (allowFrom.includes(senderId)) return {
48
+ allowed: true,
49
+ matchKey: senderId,
50
+ matchSource: "allowlist"
51
+ };
52
+ return {
53
+ allowed: false,
54
+ reason: "sender is not in allowFrom allowlist"
55
+ };
56
+ }
57
+ if (account.allowedRoles && account.allowedRoles.length > 0) {
58
+ const allowedRoles = account.allowedRoles;
59
+ if (allowedRoles.includes("all")) return {
60
+ allowed: true,
61
+ matchKey: "all",
62
+ matchSource: "role"
63
+ };
64
+ if (!checkSenderRoles({
65
+ message,
66
+ allowedRoles
67
+ })) return {
68
+ allowed: false,
69
+ reason: `sender does not have any of the required roles: ${allowedRoles.join(", ")}`
70
+ };
71
+ return {
72
+ allowed: true,
73
+ matchKey: allowedRoles.join(","),
74
+ matchSource: "role"
75
+ };
76
+ }
77
+ return { allowed: true };
78
+ }
79
+ /**
80
+ * Check if the sender has any of the allowed roles
81
+ */
82
+ function checkSenderRoles(params) {
83
+ const { message, allowedRoles } = params;
84
+ const { isMod, isOwner, isVip, isSub } = message;
85
+ for (const role of allowedRoles) switch (role) {
86
+ case "moderator":
87
+ if (isMod) return true;
88
+ break;
89
+ case "owner":
90
+ if (isOwner) return true;
91
+ break;
92
+ case "vip":
93
+ if (isVip) return true;
94
+ break;
95
+ case "subscriber":
96
+ if (isSub) return true;
97
+ break;
98
+ }
99
+ return false;
100
+ }
101
+ /**
102
+ * Extract @mentions from a Twitch chat message
103
+ *
104
+ * Returns a list of lowercase usernames that were mentioned in the message.
105
+ * Twitch mentions are in the format @username.
106
+ */
107
+ function extractMentions(message) {
108
+ const mentionRegex = /@(\w+)/g;
109
+ const mentions = [];
110
+ let match;
111
+ while ((match = mentionRegex.exec(message)) !== null) {
112
+ const username = match[1];
113
+ if (username) mentions.push(normalizeLowercaseStringOrEmpty(username));
114
+ }
115
+ return mentions;
116
+ }
117
+ //#endregion
118
+ //#region extensions/twitch/src/monitor.ts
119
+ /**
120
+ * Twitch message monitor - processes incoming messages and routes to agents.
121
+ *
122
+ * This monitor connects to the Twitch client manager, processes incoming messages,
123
+ * resolves agent routes, and handles replies.
124
+ */
125
+ /**
126
+ * Process an incoming Twitch message and dispatch to agent.
127
+ */
128
+ async function processTwitchMessage(params) {
129
+ const { message, account, accountId, config, runtime, core, statusSink } = params;
130
+ const cfg = config;
131
+ await core.channel.turn.run({
132
+ channel: "twitch",
133
+ accountId,
134
+ raw: message,
135
+ adapter: {
136
+ ingest: (incoming) => ({
137
+ id: incoming.id ?? `${incoming.channel}:${incoming.timestamp?.getTime() ?? Date.now()}`,
138
+ timestamp: incoming.timestamp?.getTime(),
139
+ rawText: incoming.message,
140
+ textForAgent: incoming.message,
141
+ textForCommands: incoming.message,
142
+ raw: incoming
143
+ }),
144
+ resolveTurn: (input) => {
145
+ const route = core.channel.routing.resolveAgentRoute({
146
+ cfg,
147
+ channel: "twitch",
148
+ accountId,
149
+ peer: {
150
+ kind: "group",
151
+ id: message.channel
152
+ }
153
+ });
154
+ const senderId = message.userId ?? message.username;
155
+ const fromLabel = message.displayName ?? message.username;
156
+ const body = core.channel.reply.formatAgentEnvelope({
157
+ channel: "Twitch",
158
+ from: fromLabel,
159
+ timestamp: input.timestamp,
160
+ envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
161
+ body: input.rawText
162
+ });
163
+ const ctxPayload = core.channel.turn.buildContext({
164
+ channel: "twitch",
165
+ accountId,
166
+ messageId: input.id,
167
+ timestamp: input.timestamp,
168
+ from: `twitch:user:${senderId}`,
169
+ sender: {
170
+ id: senderId,
171
+ name: fromLabel,
172
+ username: message.username
173
+ },
174
+ conversation: {
175
+ kind: "group",
176
+ id: message.channel,
177
+ label: message.channel,
178
+ routePeer: {
179
+ kind: "group",
180
+ id: message.channel
181
+ }
182
+ },
183
+ route: {
184
+ agentId: route.agentId,
185
+ accountId: route.accountId,
186
+ routeSessionKey: route.sessionKey
187
+ },
188
+ reply: {
189
+ to: `twitch:channel:${message.channel}`,
190
+ originatingTo: `twitch:channel:${message.channel}`
191
+ },
192
+ message: {
193
+ body,
194
+ rawBody: input.rawText,
195
+ bodyForAgent: input.textForAgent,
196
+ commandBody: input.textForCommands,
197
+ envelopeFrom: fromLabel
198
+ }
199
+ });
200
+ const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId: route.agentId });
201
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
202
+ cfg,
203
+ channel: "twitch",
204
+ accountId
205
+ });
206
+ const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
207
+ cfg,
208
+ agentId: route.agentId,
209
+ channel: "twitch",
210
+ accountId
211
+ });
212
+ return {
213
+ cfg,
214
+ channel: "twitch",
215
+ accountId,
216
+ agentId: route.agentId,
217
+ routeSessionKey: route.sessionKey,
218
+ storePath,
219
+ ctxPayload,
220
+ recordInboundSession: core.channel.session.recordInboundSession,
221
+ dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
222
+ delivery: {
223
+ deliver: async (payload) => {
224
+ await deliverTwitchReply({
225
+ payload,
226
+ channel: message.channel,
227
+ account,
228
+ accountId,
229
+ config,
230
+ tableMode,
231
+ runtime,
232
+ statusSink
233
+ });
234
+ },
235
+ onError: (err, info) => {
236
+ runtime.error?.(`Twitch ${info.kind} reply failed: ${String(err)}`);
237
+ }
238
+ },
239
+ dispatcherOptions: replyPipeline,
240
+ replyOptions: { onModelSelected },
241
+ record: { onRecordError: (err) => {
242
+ runtime.error?.(`Failed updating session meta: ${String(err)}`);
243
+ } }
244
+ };
245
+ }
246
+ }
247
+ });
248
+ }
249
+ /**
250
+ * Deliver a reply to Twitch chat.
251
+ */
252
+ async function deliverTwitchReply(params) {
253
+ const { payload, channel, account, accountId, config, runtime, statusSink } = params;
254
+ try {
255
+ const client = await getOrCreateClientManager(accountId, {
256
+ info: (msg) => runtime.log?.(msg),
257
+ warn: (msg) => runtime.log?.(msg),
258
+ error: (msg) => runtime.error?.(msg),
259
+ debug: (msg) => runtime.log?.(msg)
260
+ }).getClient(account, config, accountId);
261
+ if (!client) {
262
+ runtime.error?.(`No client available for sending reply`);
263
+ return;
264
+ }
265
+ if (!payload.text) {
266
+ runtime.error?.(`No text to send in reply payload`);
267
+ return;
268
+ }
269
+ const textToSend = stripMarkdownForTwitch(payload.text);
270
+ await client.say(channel, textToSend);
271
+ statusSink?.({ lastOutboundAt: Date.now() });
272
+ } catch (err) {
273
+ runtime.error?.(`Failed to send reply: ${String(err)}`);
274
+ }
275
+ }
276
+ /**
277
+ * Main monitor provider for Twitch.
278
+ *
279
+ * Sets up message handlers and processes incoming messages.
280
+ */
281
+ async function monitorTwitchProvider(options) {
282
+ const { account, accountId, config, runtime, abortSignal, statusSink } = options;
283
+ const core = getTwitchRuntime();
284
+ let stopped = false;
285
+ const coreLogger = core.logging.getChildLogger({ module: "twitch" });
286
+ const logVerboseMessage = (message) => {
287
+ if (!core.logging.shouldLogVerbose()) return;
288
+ coreLogger.debug?.(message);
289
+ };
290
+ const clientManager = getOrCreateClientManager(accountId, {
291
+ info: (msg) => coreLogger.info(msg),
292
+ warn: (msg) => coreLogger.warn(msg),
293
+ error: (msg) => coreLogger.error(msg),
294
+ debug: logVerboseMessage
295
+ });
296
+ try {
297
+ await clientManager.getClient(account, config, accountId);
298
+ } catch (error) {
299
+ const errorMsg = formatErrorMessage(error);
300
+ runtime.error?.(`Failed to connect: ${errorMsg}`);
301
+ throw error;
302
+ }
303
+ const unregisterHandler = clientManager.onMessage(account, (message) => {
304
+ if (stopped) return;
305
+ const botUsername = normalizeLowercaseStringOrEmpty(account.username);
306
+ if (normalizeLowercaseStringOrEmpty(message.username) === botUsername) return;
307
+ if (!checkTwitchAccessControl({
308
+ message,
309
+ account,
310
+ botUsername
311
+ }).allowed) return;
312
+ statusSink?.({ lastInboundAt: Date.now() });
313
+ processTwitchMessage({
314
+ message,
315
+ account,
316
+ accountId,
317
+ config,
318
+ runtime,
319
+ core,
320
+ statusSink
321
+ }).catch((err) => {
322
+ runtime.error?.(`Message processing failed: ${String(err)}`);
323
+ });
324
+ });
325
+ const stop = () => {
326
+ stopped = true;
327
+ unregisterHandler();
328
+ };
329
+ abortSignal.addEventListener("abort", stop, { once: true });
330
+ return { stop };
331
+ }
332
+ //#endregion
333
+ export { monitorTwitchProvider };