@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.
- package/dist/api.js +3 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/index.js +18 -0
- package/dist/markdown-MRdI1sR7.js +306 -0
- package/dist/monitor-DS0YTAPB.js +333 -0
- package/dist/plugin-BQX9GiIn.js +878 -0
- package/dist/runtime-QZ5I3GlI.js +8 -0
- package/dist/runtime-api.js +1 -0
- package/dist/setup-entry.js +11 -0
- package/dist/setup-plugin-api.js +2 -0
- package/dist/setup-surface-yArVgckI.js +400 -0
- package/dist/twitch-CklAMZL5.js +131 -0
- package/package.json +20 -3
- package/api.ts +0 -21
- package/channel-plugin-api.ts +0 -1
- package/index.test.ts +0 -13
- package/index.ts +0 -16
- package/runtime-api.ts +0 -22
- package/setup-entry.ts +0 -9
- package/setup-plugin-api.ts +0 -3
- package/src/access-control.test.ts +0 -388
- package/src/access-control.ts +0 -173
- package/src/actions.test.ts +0 -74
- package/src/actions.ts +0 -175
- package/src/client-manager-registry.ts +0 -87
- package/src/config-schema.test.ts +0 -46
- package/src/config-schema.ts +0 -88
- package/src/config.test.ts +0 -233
- package/src/config.ts +0 -177
- package/src/monitor.ts +0 -314
- package/src/outbound.test.ts +0 -468
- package/src/outbound.ts +0 -186
- package/src/plugin.test.ts +0 -77
- package/src/plugin.ts +0 -208
- package/src/probe.test.ts +0 -196
- package/src/probe.ts +0 -130
- package/src/resolver.ts +0 -139
- package/src/runtime.ts +0 -9
- package/src/send.test.ts +0 -309
- package/src/send.ts +0 -139
- package/src/setup-surface.test.ts +0 -511
- package/src/setup-surface.ts +0 -520
- package/src/status.test.ts +0 -237
- package/src/status.ts +0 -179
- package/src/test-fixtures.ts +0 -30
- package/src/token.test.ts +0 -192
- package/src/token.ts +0 -93
- package/src/twitch-client.test.ts +0 -582
- package/src/twitch-client.ts +0 -276
- package/src/types.ts +0 -104
- package/src/utils/markdown.ts +0 -98
- package/src/utils/twitch.ts +0 -84
- package/test/setup.ts +0 -7
- package/tsconfig.json +0 -16
package/dist/api.js
ADDED
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 };
|