@elizaos/plugin-twitch 2.0.0-alpha.3

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/src/service.ts ADDED
@@ -0,0 +1,488 @@
1
+ /**
2
+ * Twitch service implementation for ElizaOS.
3
+ *
4
+ * This service provides Twitch chat integration using the @twurple library.
5
+ */
6
+
7
+ import {
8
+ type EventPayload,
9
+ type IAgentRuntime,
10
+ logger,
11
+ Service,
12
+ } from "@elizaos/core";
13
+ import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth";
14
+ import { ChatClient, type ChatMessage } from "@twurple/chat";
15
+ import {
16
+ type ITwitchService,
17
+ normalizeChannel,
18
+ splitMessageForTwitch,
19
+ stripMarkdownForTwitch,
20
+ TWITCH_SERVICE_NAME,
21
+ TwitchConfigurationError,
22
+ TwitchEventTypes,
23
+ type TwitchMessage,
24
+ type TwitchMessageSendOptions,
25
+ TwitchNotConnectedError,
26
+ type TwitchRole,
27
+ type TwitchSendResult,
28
+ type TwitchSettings,
29
+ type TwitchUserInfo,
30
+ } from "./types.js";
31
+
32
+ /**
33
+ * Twitch chat service for ElizaOS agents.
34
+ */
35
+ export class TwitchService extends Service implements ITwitchService {
36
+ static serviceType: string = TWITCH_SERVICE_NAME;
37
+ capabilityDescription =
38
+ "Provides Twitch chat integration for sending and receiving messages";
39
+
40
+ private settings!: TwitchSettings;
41
+ private client!: ChatClient;
42
+ private connected: boolean = false;
43
+ private joinedChannels: Set<string> = new Set();
44
+
45
+ /**
46
+ * Start the Twitch service.
47
+ */
48
+ static async start(runtime: IAgentRuntime): Promise<TwitchService> {
49
+ const service = new TwitchService();
50
+ await service.initialize(runtime);
51
+ return service;
52
+ }
53
+
54
+ /**
55
+ * Stop the Twitch service.
56
+ */
57
+ static async stopRuntime(runtime: IAgentRuntime): Promise<void> {
58
+ const service = runtime.getService<TwitchService>(TWITCH_SERVICE_NAME);
59
+ if (service) {
60
+ await service.stop();
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Initialize the Twitch service.
66
+ */
67
+ private async initialize(runtime: IAgentRuntime): Promise<void> {
68
+ this.runtime = runtime;
69
+
70
+ // Load configuration
71
+ this.settings = this.loadSettings();
72
+
73
+ // Validate configuration
74
+ this.validateSettings();
75
+
76
+ // Create auth provider
77
+ const authProvider = await this.createAuthProvider();
78
+
79
+ // Create chat client
80
+ const allChannels = [
81
+ this.settings.channel,
82
+ ...this.settings.additionalChannels,
83
+ ].map(normalizeChannel);
84
+
85
+ this.client = new ChatClient({
86
+ authProvider,
87
+ channels: allChannels,
88
+ rejoinChannelsOnReconnect: true,
89
+ });
90
+
91
+ // Set up event handlers
92
+ this.setupEventHandlers();
93
+
94
+ // Connect
95
+ await this.connect();
96
+
97
+ logger.info(
98
+ `Twitch service initialized for ${this.settings.username}, joined channels: ${allChannels.join(", ")}`,
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Load settings from runtime.
104
+ */
105
+ private loadSettings(): TwitchSettings {
106
+ const username = this.runtime.getSetting("TWITCH_USERNAME");
107
+ const clientId = this.runtime.getSetting("TWITCH_CLIENT_ID");
108
+ const accessToken = this.runtime.getSetting("TWITCH_ACCESS_TOKEN");
109
+ const clientSecret = this.runtime.getSetting("TWITCH_CLIENT_SECRET");
110
+ const refreshToken = this.runtime.getSetting("TWITCH_REFRESH_TOKEN");
111
+ const channel = this.runtime.getSetting("TWITCH_CHANNEL");
112
+ const additionalChannelsStr = this.runtime.getSetting("TWITCH_CHANNELS");
113
+ const requireMentionStr = this.runtime.getSetting("TWITCH_REQUIRE_MENTION");
114
+ const allowedRolesStr = this.runtime.getSetting("TWITCH_ALLOWED_ROLES");
115
+
116
+ const additionalChannels =
117
+ typeof additionalChannelsStr === "string" && additionalChannelsStr
118
+ ? additionalChannelsStr
119
+ .split(",")
120
+ .map((c: string) => c.trim())
121
+ .filter(Boolean)
122
+ : [];
123
+
124
+ const allowedRoles: TwitchRole[] =
125
+ typeof allowedRolesStr === "string" && allowedRolesStr
126
+ ? (allowedRolesStr
127
+ .split(",")
128
+ .map((r: string) => r.trim().toLowerCase()) as TwitchRole[])
129
+ : ["all"];
130
+
131
+ return {
132
+ username: typeof username === "string" ? username : "",
133
+ clientId: typeof clientId === "string" ? clientId : "",
134
+ accessToken: typeof accessToken === "string" ? accessToken : "",
135
+ clientSecret: typeof clientSecret === "string" ? clientSecret : undefined,
136
+ refreshToken: typeof refreshToken === "string" ? refreshToken : undefined,
137
+ channel: typeof channel === "string" ? channel : "",
138
+ additionalChannels,
139
+ requireMention: requireMentionStr === "true",
140
+ allowedRoles,
141
+ allowedUserIds: [],
142
+ enabled: true,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Validate the settings.
148
+ */
149
+ private validateSettings(): void {
150
+ if (!this.settings.username) {
151
+ throw new TwitchConfigurationError(
152
+ "TWITCH_USERNAME is required",
153
+ "TWITCH_USERNAME",
154
+ );
155
+ }
156
+
157
+ if (!this.settings.clientId) {
158
+ throw new TwitchConfigurationError(
159
+ "TWITCH_CLIENT_ID is required",
160
+ "TWITCH_CLIENT_ID",
161
+ );
162
+ }
163
+
164
+ if (!this.settings.accessToken) {
165
+ throw new TwitchConfigurationError(
166
+ "TWITCH_ACCESS_TOKEN is required",
167
+ "TWITCH_ACCESS_TOKEN",
168
+ );
169
+ }
170
+
171
+ if (!this.settings.channel) {
172
+ throw new TwitchConfigurationError(
173
+ "TWITCH_CHANNEL is required",
174
+ "TWITCH_CHANNEL",
175
+ );
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Create the authentication provider.
181
+ */
182
+ private async createAuthProvider(): Promise<
183
+ StaticAuthProvider | RefreshingAuthProvider
184
+ > {
185
+ const token = this.normalizeToken(this.settings.accessToken);
186
+
187
+ if (this.settings.clientSecret) {
188
+ const authProvider = new RefreshingAuthProvider({
189
+ clientId: this.settings.clientId,
190
+ clientSecret: this.settings.clientSecret,
191
+ });
192
+
193
+ await authProvider.addUserForToken({
194
+ accessToken: token,
195
+ refreshToken: this.settings.refreshToken || null,
196
+ expiresIn: null,
197
+ obtainmentTimestamp: Date.now(),
198
+ });
199
+
200
+ authProvider.onRefresh((userId, newToken) => {
201
+ logger.info(
202
+ `Twitch token refreshed for user ${userId}, expires in ${newToken.expiresIn}s`,
203
+ );
204
+ });
205
+
206
+ authProvider.onRefreshFailure((userId, error) => {
207
+ logger.error(
208
+ `Twitch token refresh failed for user ${userId}: ${error.message}`,
209
+ );
210
+ });
211
+
212
+ logger.info(`Using RefreshingAuthProvider for ${this.settings.username}`);
213
+ return authProvider;
214
+ }
215
+
216
+ logger.info(`Using StaticAuthProvider for ${this.settings.username}`);
217
+ return new StaticAuthProvider(this.settings.clientId, token);
218
+ }
219
+
220
+ /**
221
+ * Normalize an OAuth token (remove oauth: prefix if present).
222
+ */
223
+ private normalizeToken(token: string): string {
224
+ return token.startsWith("oauth:") ? token.slice(6) : token;
225
+ }
226
+
227
+ /**
228
+ * Set up event handlers for the chat client.
229
+ */
230
+ private setupEventHandlers(): void {
231
+ // Connection events
232
+ this.client.onConnect(() => {
233
+ this.connected = true;
234
+ logger.info("Twitch chat connected");
235
+ this.runtime.emitEvent(TwitchEventTypes.CONNECTION_READY, {
236
+ runtime: this.runtime,
237
+ } as EventPayload);
238
+ });
239
+
240
+ this.client.onDisconnect((_manually, reason) => {
241
+ this.connected = false;
242
+ logger.warn(`Twitch chat disconnected: ${reason || "unknown reason"}`);
243
+ this.runtime.emitEvent(TwitchEventTypes.CONNECTION_LOST, {
244
+ runtime: this.runtime,
245
+ reason,
246
+ } as EventPayload);
247
+ });
248
+
249
+ // Channel events
250
+ this.client.onJoin((channel, user) => {
251
+ const normalized = normalizeChannel(channel);
252
+ if (user.toLowerCase() === this.settings.username.toLowerCase()) {
253
+ this.joinedChannels.add(normalized);
254
+ logger.info(`Joined Twitch channel: ${normalized}`);
255
+ this.runtime.emitEvent(TwitchEventTypes.JOIN_CHANNEL, {
256
+ runtime: this.runtime,
257
+ channel: normalized,
258
+ } as EventPayload);
259
+ }
260
+ });
261
+
262
+ this.client.onPart((channel, user) => {
263
+ const normalized = normalizeChannel(channel);
264
+ if (user.toLowerCase() === this.settings.username.toLowerCase()) {
265
+ this.joinedChannels.delete(normalized);
266
+ logger.info(`Left Twitch channel: ${normalized}`);
267
+ this.runtime.emitEvent(TwitchEventTypes.LEAVE_CHANNEL, {
268
+ runtime: this.runtime,
269
+ channel: normalized,
270
+ } as EventPayload);
271
+ }
272
+ });
273
+
274
+ // Message events
275
+ this.client.onMessage(
276
+ (channel: string, user: string, text: string, msg: ChatMessage) => {
277
+ this.handleMessage(channel, user, text, msg);
278
+ },
279
+ );
280
+ }
281
+
282
+ /**
283
+ * Handle an incoming chat message.
284
+ */
285
+ private handleMessage(
286
+ channel: string,
287
+ _user: string,
288
+ text: string,
289
+ msg: ChatMessage,
290
+ ): void {
291
+ const normalizedChannel = normalizeChannel(channel);
292
+
293
+ // Ignore own messages
294
+ if (
295
+ msg.userInfo.userName.toLowerCase() ===
296
+ this.settings.username.toLowerCase()
297
+ ) {
298
+ return;
299
+ }
300
+
301
+ const userInfo: TwitchUserInfo = {
302
+ userId: msg.userInfo.userId,
303
+ username: msg.userInfo.userName,
304
+ displayName: msg.userInfo.displayName,
305
+ isModerator: msg.userInfo.isMod,
306
+ isBroadcaster: msg.userInfo.isBroadcaster,
307
+ isVip: msg.userInfo.isVip,
308
+ isSubscriber: msg.userInfo.isSubscriber,
309
+ color: msg.userInfo.color,
310
+ badges: msg.userInfo.badges,
311
+ };
312
+
313
+ // Check access control
314
+ if (!this.isUserAllowed(userInfo)) {
315
+ return;
316
+ }
317
+
318
+ // Check mention requirement
319
+ if (this.settings.requireMention) {
320
+ const mentionPattern = new RegExp(`@${this.settings.username}\\b`, "i");
321
+ if (!mentionPattern.test(text)) {
322
+ return;
323
+ }
324
+ }
325
+
326
+ const message: TwitchMessage = {
327
+ id: msg.id,
328
+ channel: normalizedChannel,
329
+ text,
330
+ user: userInfo,
331
+ timestamp: new Date(),
332
+ isAction: msg.isCheer,
333
+ isHighlighted: msg.isHighlight,
334
+ replyTo: msg.parentMessageId
335
+ ? {
336
+ messageId: msg.parentMessageId,
337
+ userId: msg.parentMessageUserId || "",
338
+ username: msg.parentMessageUserName || "",
339
+ text: msg.parentMessageText || "",
340
+ }
341
+ : undefined,
342
+ };
343
+
344
+ logger.debug(
345
+ `Twitch message from ${userInfo.displayName} in #${normalizedChannel}: ${text.slice(0, 50)}...`,
346
+ );
347
+
348
+ this.runtime.emitEvent(TwitchEventTypes.MESSAGE_RECEIVED, {
349
+ runtime: this.runtime,
350
+ message,
351
+ } as EventPayload);
352
+ }
353
+
354
+ /**
355
+ * Connect to Twitch.
356
+ */
357
+ private async connect(): Promise<void> {
358
+ await this.client.connect();
359
+ this.connected = true;
360
+ }
361
+
362
+ /**
363
+ * Stop the service.
364
+ */
365
+ async stop(): Promise<void> {
366
+ if (this.client) {
367
+ this.client.quit();
368
+ }
369
+ this.connected = false;
370
+ this.joinedChannels.clear();
371
+ logger.info("Twitch service stopped");
372
+ }
373
+
374
+ // ============================================================================
375
+ // Public Interface
376
+ // ============================================================================
377
+
378
+ isConnected(): boolean {
379
+ return this.connected;
380
+ }
381
+
382
+ getBotUsername(): string {
383
+ return this.settings.username;
384
+ }
385
+
386
+ getPrimaryChannel(): string {
387
+ return this.settings.channel;
388
+ }
389
+
390
+ getJoinedChannels(): string[] {
391
+ return Array.from(this.joinedChannels);
392
+ }
393
+
394
+ isUserAllowed(user: TwitchUserInfo): boolean {
395
+ // Check allowlist first
396
+ if (
397
+ this.settings.allowedUserIds.length > 0 &&
398
+ !this.settings.allowedUserIds.includes(user.userId)
399
+ ) {
400
+ return false;
401
+ }
402
+
403
+ // Check roles
404
+ if (this.settings.allowedRoles.includes("all")) {
405
+ return true;
406
+ }
407
+
408
+ if (this.settings.allowedRoles.includes("owner") && user.isBroadcaster) {
409
+ return true;
410
+ }
411
+
412
+ if (this.settings.allowedRoles.includes("moderator") && user.isModerator) {
413
+ return true;
414
+ }
415
+
416
+ if (this.settings.allowedRoles.includes("vip") && user.isVip) {
417
+ return true;
418
+ }
419
+
420
+ if (
421
+ this.settings.allowedRoles.includes("subscriber") &&
422
+ user.isSubscriber
423
+ ) {
424
+ return true;
425
+ }
426
+
427
+ return false;
428
+ }
429
+
430
+ async sendMessage(
431
+ text: string,
432
+ options?: TwitchMessageSendOptions,
433
+ ): Promise<TwitchSendResult> {
434
+ if (!this.connected) {
435
+ throw new TwitchNotConnectedError();
436
+ }
437
+
438
+ const channel = normalizeChannel(options?.channel || this.settings.channel);
439
+
440
+ // Strip markdown for Twitch
441
+ const cleanedText = stripMarkdownForTwitch(text);
442
+ if (!cleanedText) {
443
+ return { success: true, messageId: "skipped-empty" };
444
+ }
445
+
446
+ // Split long messages
447
+ const chunks = splitMessageForTwitch(cleanedText);
448
+
449
+ let lastMessageId: string | undefined;
450
+
451
+ for (const chunk of chunks) {
452
+ if (options?.replyTo) {
453
+ await this.client.say(channel, chunk, { replyTo: options.replyTo });
454
+ } else {
455
+ await this.client.say(channel, chunk);
456
+ }
457
+
458
+ // Generate a message ID since Twurple doesn't return one
459
+ lastMessageId = crypto.randomUUID();
460
+
461
+ // Small delay between chunks
462
+ if (chunks.length > 1) {
463
+ await new Promise((resolve) => setTimeout(resolve, 300));
464
+ }
465
+ }
466
+
467
+ this.runtime.emitEvent(TwitchEventTypes.MESSAGE_SENT, {
468
+ runtime: this.runtime,
469
+ channel,
470
+ text: cleanedText,
471
+ messageId: lastMessageId,
472
+ } as EventPayload);
473
+
474
+ return { success: true, messageId: lastMessageId };
475
+ }
476
+
477
+ async joinChannel(channel: string): Promise<void> {
478
+ const normalized = normalizeChannel(channel);
479
+ await this.client.join(normalized);
480
+ this.joinedChannels.add(normalized);
481
+ }
482
+
483
+ async leaveChannel(channel: string): Promise<void> {
484
+ const normalized = normalizeChannel(channel);
485
+ await this.client.part(normalized);
486
+ this.joinedChannels.delete(normalized);
487
+ }
488
+ }