@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/dist/index.js ADDED
@@ -0,0 +1,1021 @@
1
+ // src/index.ts
2
+ import { logger as logger2 } from "@elizaos/core";
3
+
4
+ // src/service.ts
5
+ import {
6
+ logger,
7
+ Service
8
+ } from "@elizaos/core";
9
+ import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth";
10
+ import { ChatClient } from "@twurple/chat";
11
+
12
+ // src/types.ts
13
+ var MAX_TWITCH_MESSAGE_LENGTH = 500;
14
+ var TWITCH_SERVICE_NAME = "twitch";
15
+ var TwitchEventTypes;
16
+ ((TwitchEventTypes2) => {
17
+ TwitchEventTypes2["MESSAGE_RECEIVED"] = "TWITCH_MESSAGE_RECEIVED";
18
+ TwitchEventTypes2["MESSAGE_SENT"] = "TWITCH_MESSAGE_SENT";
19
+ TwitchEventTypes2["JOIN_CHANNEL"] = "TWITCH_JOIN_CHANNEL";
20
+ TwitchEventTypes2["LEAVE_CHANNEL"] = "TWITCH_LEAVE_CHANNEL";
21
+ TwitchEventTypes2["CONNECTION_READY"] = "TWITCH_CONNECTION_READY";
22
+ TwitchEventTypes2["CONNECTION_LOST"] = "TWITCH_CONNECTION_LOST";
23
+ })(TwitchEventTypes ||= {});
24
+ function normalizeChannel(channel) {
25
+ return channel.startsWith("#") ? channel.slice(1) : channel;
26
+ }
27
+ function formatChannelForDisplay(channel) {
28
+ const normalized = normalizeChannel(channel);
29
+ return `#${normalized}`;
30
+ }
31
+ function getTwitchUserDisplayName(user) {
32
+ return user.displayName || user.username;
33
+ }
34
+ function stripMarkdownForTwitch(text) {
35
+ return text.replace(/\*\*([^*]+)\*\*/g, "$1").replace(/__([^_]+)__/g, "$1").replace(/\*([^*]+)\*/g, "$1").replace(/_([^_]+)_/g, "$1").replace(/~~([^~]+)~~/g, "$1").replace(/`([^`]+)`/g, "$1").replace(/```[\s\S]*?```/g, "[code block]").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/^#{1,6}\s+/gm, "").replace(/^>\s+/gm, "").replace(/^[-*+]\s+/gm, "• ").replace(/^\d+\.\s+/gm, "• ").replace(/\n{3,}/g, `
36
+
37
+ `).trim();
38
+ }
39
+ function splitMessageForTwitch(text, maxLength = MAX_TWITCH_MESSAGE_LENGTH) {
40
+ if (text.length <= maxLength) {
41
+ return [text];
42
+ }
43
+ const chunks = [];
44
+ let remaining = text;
45
+ while (remaining.length > 0) {
46
+ if (remaining.length <= maxLength) {
47
+ chunks.push(remaining);
48
+ break;
49
+ }
50
+ let splitIndex = remaining.lastIndexOf(". ", maxLength);
51
+ if (splitIndex === -1 || splitIndex < maxLength / 2) {
52
+ splitIndex = remaining.lastIndexOf(" ", maxLength);
53
+ }
54
+ if (splitIndex === -1 || splitIndex < maxLength / 2) {
55
+ splitIndex = maxLength;
56
+ }
57
+ chunks.push(remaining.slice(0, splitIndex).trim());
58
+ remaining = remaining.slice(splitIndex).trim();
59
+ }
60
+ return chunks;
61
+ }
62
+
63
+ class TwitchPluginError extends Error {
64
+ constructor(message) {
65
+ super(message);
66
+ this.name = "TwitchPluginError";
67
+ }
68
+ }
69
+
70
+ class TwitchServiceNotInitializedError extends TwitchPluginError {
71
+ constructor(message = "Twitch service is not initialized") {
72
+ super(message);
73
+ this.name = "TwitchServiceNotInitializedError";
74
+ }
75
+ }
76
+
77
+ class TwitchNotConnectedError extends TwitchPluginError {
78
+ constructor(message = "Twitch client is not connected") {
79
+ super(message);
80
+ this.name = "TwitchNotConnectedError";
81
+ }
82
+ }
83
+
84
+ class TwitchConfigurationError extends TwitchPluginError {
85
+ settingName;
86
+ constructor(message, settingName) {
87
+ super(message);
88
+ this.name = "TwitchConfigurationError";
89
+ this.settingName = settingName;
90
+ }
91
+ }
92
+
93
+ class TwitchApiError extends TwitchPluginError {
94
+ statusCode;
95
+ constructor(message, statusCode) {
96
+ super(message);
97
+ this.name = "TwitchApiError";
98
+ this.statusCode = statusCode;
99
+ }
100
+ }
101
+
102
+ // src/service.ts
103
+ class TwitchService extends Service {
104
+ static serviceType = TWITCH_SERVICE_NAME;
105
+ capabilityDescription = "Provides Twitch chat integration for sending and receiving messages";
106
+ settings;
107
+ client;
108
+ connected = false;
109
+ joinedChannels = new Set;
110
+ static async start(runtime) {
111
+ const service = new TwitchService;
112
+ await service.initialize(runtime);
113
+ return service;
114
+ }
115
+ static async stopRuntime(runtime) {
116
+ const service = runtime.getService(TWITCH_SERVICE_NAME);
117
+ if (service) {
118
+ await service.stop();
119
+ }
120
+ }
121
+ async initialize(runtime) {
122
+ this.runtime = runtime;
123
+ this.settings = this.loadSettings();
124
+ this.validateSettings();
125
+ const authProvider = await this.createAuthProvider();
126
+ const allChannels = [
127
+ this.settings.channel,
128
+ ...this.settings.additionalChannels
129
+ ].map(normalizeChannel);
130
+ this.client = new ChatClient({
131
+ authProvider,
132
+ channels: allChannels,
133
+ rejoinChannelsOnReconnect: true
134
+ });
135
+ this.setupEventHandlers();
136
+ await this.connect();
137
+ logger.info(`Twitch service initialized for ${this.settings.username}, joined channels: ${allChannels.join(", ")}`);
138
+ }
139
+ loadSettings() {
140
+ const username = this.runtime.getSetting("TWITCH_USERNAME");
141
+ const clientId = this.runtime.getSetting("TWITCH_CLIENT_ID");
142
+ const accessToken = this.runtime.getSetting("TWITCH_ACCESS_TOKEN");
143
+ const clientSecret = this.runtime.getSetting("TWITCH_CLIENT_SECRET");
144
+ const refreshToken = this.runtime.getSetting("TWITCH_REFRESH_TOKEN");
145
+ const channel = this.runtime.getSetting("TWITCH_CHANNEL");
146
+ const additionalChannelsStr = this.runtime.getSetting("TWITCH_CHANNELS");
147
+ const requireMentionStr = this.runtime.getSetting("TWITCH_REQUIRE_MENTION");
148
+ const allowedRolesStr = this.runtime.getSetting("TWITCH_ALLOWED_ROLES");
149
+ const additionalChannels = typeof additionalChannelsStr === "string" && additionalChannelsStr ? additionalChannelsStr.split(",").map((c) => c.trim()).filter(Boolean) : [];
150
+ const allowedRoles = typeof allowedRolesStr === "string" && allowedRolesStr ? allowedRolesStr.split(",").map((r) => r.trim().toLowerCase()) : ["all"];
151
+ return {
152
+ username: typeof username === "string" ? username : "",
153
+ clientId: typeof clientId === "string" ? clientId : "",
154
+ accessToken: typeof accessToken === "string" ? accessToken : "",
155
+ clientSecret: typeof clientSecret === "string" ? clientSecret : undefined,
156
+ refreshToken: typeof refreshToken === "string" ? refreshToken : undefined,
157
+ channel: typeof channel === "string" ? channel : "",
158
+ additionalChannels,
159
+ requireMention: requireMentionStr === "true",
160
+ allowedRoles,
161
+ allowedUserIds: [],
162
+ enabled: true
163
+ };
164
+ }
165
+ validateSettings() {
166
+ if (!this.settings.username) {
167
+ throw new TwitchConfigurationError("TWITCH_USERNAME is required", "TWITCH_USERNAME");
168
+ }
169
+ if (!this.settings.clientId) {
170
+ throw new TwitchConfigurationError("TWITCH_CLIENT_ID is required", "TWITCH_CLIENT_ID");
171
+ }
172
+ if (!this.settings.accessToken) {
173
+ throw new TwitchConfigurationError("TWITCH_ACCESS_TOKEN is required", "TWITCH_ACCESS_TOKEN");
174
+ }
175
+ if (!this.settings.channel) {
176
+ throw new TwitchConfigurationError("TWITCH_CHANNEL is required", "TWITCH_CHANNEL");
177
+ }
178
+ }
179
+ async createAuthProvider() {
180
+ const token = this.normalizeToken(this.settings.accessToken);
181
+ if (this.settings.clientSecret) {
182
+ const authProvider = new RefreshingAuthProvider({
183
+ clientId: this.settings.clientId,
184
+ clientSecret: this.settings.clientSecret
185
+ });
186
+ await authProvider.addUserForToken({
187
+ accessToken: token,
188
+ refreshToken: this.settings.refreshToken || null,
189
+ expiresIn: null,
190
+ obtainmentTimestamp: Date.now()
191
+ });
192
+ authProvider.onRefresh((userId, newToken) => {
193
+ logger.info(`Twitch token refreshed for user ${userId}, expires in ${newToken.expiresIn}s`);
194
+ });
195
+ authProvider.onRefreshFailure((userId, error) => {
196
+ logger.error(`Twitch token refresh failed for user ${userId}: ${error.message}`);
197
+ });
198
+ logger.info(`Using RefreshingAuthProvider for ${this.settings.username}`);
199
+ return authProvider;
200
+ }
201
+ logger.info(`Using StaticAuthProvider for ${this.settings.username}`);
202
+ return new StaticAuthProvider(this.settings.clientId, token);
203
+ }
204
+ normalizeToken(token) {
205
+ return token.startsWith("oauth:") ? token.slice(6) : token;
206
+ }
207
+ setupEventHandlers() {
208
+ this.client.onConnect(() => {
209
+ this.connected = true;
210
+ logger.info("Twitch chat connected");
211
+ this.runtime.emitEvent("TWITCH_CONNECTION_READY" /* CONNECTION_READY */, {
212
+ runtime: this.runtime
213
+ });
214
+ });
215
+ this.client.onDisconnect((_manually, reason) => {
216
+ this.connected = false;
217
+ logger.warn(`Twitch chat disconnected: ${reason || "unknown reason"}`);
218
+ this.runtime.emitEvent("TWITCH_CONNECTION_LOST" /* CONNECTION_LOST */, {
219
+ runtime: this.runtime,
220
+ reason
221
+ });
222
+ });
223
+ this.client.onJoin((channel, user) => {
224
+ const normalized = normalizeChannel(channel);
225
+ if (user.toLowerCase() === this.settings.username.toLowerCase()) {
226
+ this.joinedChannels.add(normalized);
227
+ logger.info(`Joined Twitch channel: ${normalized}`);
228
+ this.runtime.emitEvent("TWITCH_JOIN_CHANNEL" /* JOIN_CHANNEL */, {
229
+ runtime: this.runtime,
230
+ channel: normalized
231
+ });
232
+ }
233
+ });
234
+ this.client.onPart((channel, user) => {
235
+ const normalized = normalizeChannel(channel);
236
+ if (user.toLowerCase() === this.settings.username.toLowerCase()) {
237
+ this.joinedChannels.delete(normalized);
238
+ logger.info(`Left Twitch channel: ${normalized}`);
239
+ this.runtime.emitEvent("TWITCH_LEAVE_CHANNEL" /* LEAVE_CHANNEL */, {
240
+ runtime: this.runtime,
241
+ channel: normalized
242
+ });
243
+ }
244
+ });
245
+ this.client.onMessage((channel, user, text, msg) => {
246
+ this.handleMessage(channel, user, text, msg);
247
+ });
248
+ }
249
+ handleMessage(channel, _user, text, msg) {
250
+ const normalizedChannel = normalizeChannel(channel);
251
+ if (msg.userInfo.userName.toLowerCase() === this.settings.username.toLowerCase()) {
252
+ return;
253
+ }
254
+ const userInfo = {
255
+ userId: msg.userInfo.userId,
256
+ username: msg.userInfo.userName,
257
+ displayName: msg.userInfo.displayName,
258
+ isModerator: msg.userInfo.isMod,
259
+ isBroadcaster: msg.userInfo.isBroadcaster,
260
+ isVip: msg.userInfo.isVip,
261
+ isSubscriber: msg.userInfo.isSubscriber,
262
+ color: msg.userInfo.color,
263
+ badges: msg.userInfo.badges
264
+ };
265
+ if (!this.isUserAllowed(userInfo)) {
266
+ return;
267
+ }
268
+ if (this.settings.requireMention) {
269
+ const mentionPattern = new RegExp(`@${this.settings.username}\\b`, "i");
270
+ if (!mentionPattern.test(text)) {
271
+ return;
272
+ }
273
+ }
274
+ const message = {
275
+ id: msg.id,
276
+ channel: normalizedChannel,
277
+ text,
278
+ user: userInfo,
279
+ timestamp: new Date,
280
+ isAction: msg.isCheer,
281
+ isHighlighted: msg.isHighlight,
282
+ replyTo: msg.parentMessageId ? {
283
+ messageId: msg.parentMessageId,
284
+ userId: msg.parentMessageUserId || "",
285
+ username: msg.parentMessageUserName || "",
286
+ text: msg.parentMessageText || ""
287
+ } : undefined
288
+ };
289
+ logger.debug(`Twitch message from ${userInfo.displayName} in #${normalizedChannel}: ${text.slice(0, 50)}...`);
290
+ this.runtime.emitEvent("TWITCH_MESSAGE_RECEIVED" /* MESSAGE_RECEIVED */, {
291
+ runtime: this.runtime,
292
+ message
293
+ });
294
+ }
295
+ async connect() {
296
+ await this.client.connect();
297
+ this.connected = true;
298
+ }
299
+ async stop() {
300
+ if (this.client) {
301
+ this.client.quit();
302
+ }
303
+ this.connected = false;
304
+ this.joinedChannels.clear();
305
+ logger.info("Twitch service stopped");
306
+ }
307
+ isConnected() {
308
+ return this.connected;
309
+ }
310
+ getBotUsername() {
311
+ return this.settings.username;
312
+ }
313
+ getPrimaryChannel() {
314
+ return this.settings.channel;
315
+ }
316
+ getJoinedChannels() {
317
+ return Array.from(this.joinedChannels);
318
+ }
319
+ isUserAllowed(user) {
320
+ if (this.settings.allowedUserIds.length > 0 && !this.settings.allowedUserIds.includes(user.userId)) {
321
+ return false;
322
+ }
323
+ if (this.settings.allowedRoles.includes("all")) {
324
+ return true;
325
+ }
326
+ if (this.settings.allowedRoles.includes("owner") && user.isBroadcaster) {
327
+ return true;
328
+ }
329
+ if (this.settings.allowedRoles.includes("moderator") && user.isModerator) {
330
+ return true;
331
+ }
332
+ if (this.settings.allowedRoles.includes("vip") && user.isVip) {
333
+ return true;
334
+ }
335
+ if (this.settings.allowedRoles.includes("subscriber") && user.isSubscriber) {
336
+ return true;
337
+ }
338
+ return false;
339
+ }
340
+ async sendMessage(text, options) {
341
+ if (!this.connected) {
342
+ throw new TwitchNotConnectedError;
343
+ }
344
+ const channel = normalizeChannel(options?.channel || this.settings.channel);
345
+ const cleanedText = stripMarkdownForTwitch(text);
346
+ if (!cleanedText) {
347
+ return { success: true, messageId: "skipped-empty" };
348
+ }
349
+ const chunks = splitMessageForTwitch(cleanedText);
350
+ let lastMessageId;
351
+ for (const chunk of chunks) {
352
+ if (options?.replyTo) {
353
+ await this.client.say(channel, chunk, { replyTo: options.replyTo });
354
+ } else {
355
+ await this.client.say(channel, chunk);
356
+ }
357
+ lastMessageId = crypto.randomUUID();
358
+ if (chunks.length > 1) {
359
+ await new Promise((resolve) => setTimeout(resolve, 300));
360
+ }
361
+ }
362
+ this.runtime.emitEvent("TWITCH_MESSAGE_SENT" /* MESSAGE_SENT */, {
363
+ runtime: this.runtime,
364
+ channel,
365
+ text: cleanedText,
366
+ messageId: lastMessageId
367
+ });
368
+ return { success: true, messageId: lastMessageId };
369
+ }
370
+ async joinChannel(channel) {
371
+ const normalized = normalizeChannel(channel);
372
+ await this.client.join(normalized);
373
+ this.joinedChannels.add(normalized);
374
+ }
375
+ async leaveChannel(channel) {
376
+ const normalized = normalizeChannel(channel);
377
+ await this.client.part(normalized);
378
+ this.joinedChannels.delete(normalized);
379
+ }
380
+ }
381
+ // src/actions/joinChannel.ts
382
+ import {
383
+ composePromptFromState,
384
+ ModelType,
385
+ parseJSONObjectFromText
386
+ } from "@elizaos/core";
387
+ var JOIN_CHANNEL_TEMPLATE = `You are helping to extract a Twitch channel name.
388
+
389
+ The user wants to join a Twitch channel.
390
+
391
+ Recent conversation:
392
+ {{recentMessages}}
393
+
394
+ Extract the channel name to join (without the # prefix).
395
+
396
+ Respond with a JSON object like:
397
+ {
398
+ "channel": "channelname"
399
+ }
400
+
401
+ Only respond with the JSON object, no other text.`;
402
+ var joinChannel = {
403
+ name: "TWITCH_JOIN_CHANNEL",
404
+ similes: ["JOIN_TWITCH_CHANNEL", "ENTER_CHANNEL", "CONNECT_CHANNEL"],
405
+ description: "Join a Twitch channel to listen and send messages",
406
+ validate: async (_runtime, message, _state) => {
407
+ return message.content.source === "twitch";
408
+ },
409
+ handler: async (runtime, message, state, _options, callback) => {
410
+ const twitchService = runtime.getService(TWITCH_SERVICE_NAME);
411
+ if (!twitchService || !twitchService.isConnected()) {
412
+ if (callback) {
413
+ callback({
414
+ text: "Twitch service is not available.",
415
+ source: "twitch"
416
+ });
417
+ }
418
+ return { success: false, error: "Twitch service not available" };
419
+ }
420
+ const currentState = state ?? await runtime.composeState(message);
421
+ const prompt = await composePromptFromState({
422
+ template: JOIN_CHANNEL_TEMPLATE,
423
+ state: currentState
424
+ });
425
+ let channelName = null;
426
+ for (let attempt = 0;attempt < 3; attempt++) {
427
+ const response = await runtime.useModel(ModelType.TEXT_SMALL, {
428
+ prompt
429
+ });
430
+ const parsed = parseJSONObjectFromText(String(response));
431
+ if (parsed?.channel) {
432
+ channelName = normalizeChannel(String(parsed.channel));
433
+ break;
434
+ }
435
+ }
436
+ if (!channelName) {
437
+ if (callback) {
438
+ callback({
439
+ text: "I couldn't understand which channel you want me to join. Please specify the channel name.",
440
+ source: "twitch"
441
+ });
442
+ }
443
+ return { success: false, error: "Could not extract channel name" };
444
+ }
445
+ if (twitchService.getJoinedChannels().includes(channelName)) {
446
+ if (callback) {
447
+ callback({
448
+ text: `Already in channel #${channelName}.`,
449
+ source: "twitch"
450
+ });
451
+ }
452
+ return {
453
+ success: true,
454
+ data: { channel: channelName, alreadyJoined: true }
455
+ };
456
+ }
457
+ await twitchService.joinChannel(channelName);
458
+ if (callback) {
459
+ callback({
460
+ text: `Joined channel #${channelName}.`,
461
+ source: message.content.source
462
+ });
463
+ }
464
+ return {
465
+ success: true,
466
+ data: {
467
+ channel: channelName
468
+ }
469
+ };
470
+ },
471
+ examples: [
472
+ [
473
+ {
474
+ name: "{{user1}}",
475
+ content: { text: "Join the channel shroud" }
476
+ },
477
+ {
478
+ name: "{{agent}}",
479
+ content: {
480
+ text: "I'll join that channel.",
481
+ actions: ["TWITCH_JOIN_CHANNEL"]
482
+ }
483
+ }
484
+ ]
485
+ ]
486
+ };
487
+
488
+ // src/actions/leaveChannel.ts
489
+ import {
490
+ composePromptFromState as composePromptFromState2,
491
+ ModelType as ModelType2,
492
+ parseJSONObjectFromText as parseJSONObjectFromText2
493
+ } from "@elizaos/core";
494
+ var LEAVE_CHANNEL_TEMPLATE = `You are helping to extract a Twitch channel name.
495
+
496
+ The user wants to leave a Twitch channel.
497
+
498
+ Recent conversation:
499
+ {{recentMessages}}
500
+
501
+ Currently joined channels: {{joinedChannels}}
502
+
503
+ Extract the channel name to leave (without the # prefix).
504
+
505
+ Respond with a JSON object like:
506
+ {
507
+ "channel": "channelname"
508
+ }
509
+
510
+ Only respond with the JSON object, no other text.`;
511
+ var leaveChannel = {
512
+ name: "TWITCH_LEAVE_CHANNEL",
513
+ similes: [
514
+ "LEAVE_TWITCH_CHANNEL",
515
+ "EXIT_CHANNEL",
516
+ "PART_CHANNEL",
517
+ "DISCONNECT_CHANNEL"
518
+ ],
519
+ description: "Leave a Twitch channel",
520
+ validate: async (_runtime, message, _state) => {
521
+ return message.content.source === "twitch";
522
+ },
523
+ handler: async (runtime, message, state, _options, callback) => {
524
+ const twitchService = runtime.getService(TWITCH_SERVICE_NAME);
525
+ if (!twitchService || !twitchService.isConnected()) {
526
+ if (callback) {
527
+ callback({
528
+ text: "Twitch service is not available.",
529
+ source: "twitch"
530
+ });
531
+ }
532
+ return { success: false, error: "Twitch service not available" };
533
+ }
534
+ const joinedChannels = twitchService.getJoinedChannels();
535
+ const currentState = state ?? await runtime.composeState(message);
536
+ const enrichedState = {
537
+ ...currentState,
538
+ joinedChannels: joinedChannels.join(", ")
539
+ };
540
+ const prompt = await composePromptFromState2({
541
+ template: LEAVE_CHANNEL_TEMPLATE,
542
+ state: enrichedState
543
+ });
544
+ let channelName = null;
545
+ for (let attempt = 0;attempt < 3; attempt++) {
546
+ const response = await runtime.useModel(ModelType2.TEXT_SMALL, {
547
+ prompt
548
+ });
549
+ const parsed = parseJSONObjectFromText2(String(response));
550
+ if (parsed?.channel) {
551
+ channelName = normalizeChannel(String(parsed.channel));
552
+ break;
553
+ }
554
+ }
555
+ if (!channelName) {
556
+ if (callback) {
557
+ callback({
558
+ text: "I couldn't understand which channel you want me to leave. Please specify the channel name.",
559
+ source: "twitch"
560
+ });
561
+ }
562
+ return { success: false, error: "Could not extract channel name" };
563
+ }
564
+ if (!joinedChannels.includes(channelName)) {
565
+ if (callback) {
566
+ callback({
567
+ text: `Not currently in channel #${channelName}.`,
568
+ source: "twitch"
569
+ });
570
+ }
571
+ return { success: false, error: "Not in that channel" };
572
+ }
573
+ if (channelName === twitchService.getPrimaryChannel()) {
574
+ if (callback) {
575
+ callback({
576
+ text: `Cannot leave the primary channel #${channelName}.`,
577
+ source: "twitch"
578
+ });
579
+ }
580
+ return { success: false, error: "Cannot leave primary channel" };
581
+ }
582
+ await twitchService.leaveChannel(channelName);
583
+ if (callback) {
584
+ callback({
585
+ text: `Left channel #${channelName}.`,
586
+ source: message.content.source
587
+ });
588
+ }
589
+ return {
590
+ success: true,
591
+ data: {
592
+ channel: channelName
593
+ }
594
+ };
595
+ },
596
+ examples: [
597
+ [
598
+ {
599
+ name: "{{user1}}",
600
+ content: { text: "Leave the channel shroud" }
601
+ },
602
+ {
603
+ name: "{{agent}}",
604
+ content: {
605
+ text: "I'll leave that channel.",
606
+ actions: ["TWITCH_LEAVE_CHANNEL"]
607
+ }
608
+ }
609
+ ]
610
+ ]
611
+ };
612
+
613
+ // src/actions/listChannels.ts
614
+ var listChannels = {
615
+ name: "TWITCH_LIST_CHANNELS",
616
+ similes: [
617
+ "LIST_TWITCH_CHANNELS",
618
+ "SHOW_CHANNELS",
619
+ "GET_CHANNELS",
620
+ "CURRENT_CHANNELS"
621
+ ],
622
+ description: "List all Twitch channels the bot is currently in",
623
+ validate: async (_runtime, message, _state) => {
624
+ return message.content.source === "twitch";
625
+ },
626
+ handler: async (runtime, message, _state, _options, callback) => {
627
+ const twitchService = runtime.getService(TWITCH_SERVICE_NAME);
628
+ if (!twitchService || !twitchService.isConnected()) {
629
+ if (callback) {
630
+ callback({
631
+ text: "Twitch service is not available.",
632
+ source: "twitch"
633
+ });
634
+ }
635
+ return { success: false, error: "Twitch service not available" };
636
+ }
637
+ const joinedChannels = twitchService.getJoinedChannels();
638
+ const primaryChannel = twitchService.getPrimaryChannel();
639
+ const channelList = joinedChannels.map((channel) => {
640
+ const displayName = formatChannelForDisplay(channel);
641
+ const isPrimary = channel === primaryChannel;
642
+ return isPrimary ? `${displayName} (primary)` : displayName;
643
+ });
644
+ const responseText = joinedChannels.length > 0 ? `Currently in ${joinedChannels.length} channel(s):
645
+ ${channelList.map((c) => `• ${c}`).join(`
646
+ `)}` : "Not currently in any channels.";
647
+ if (callback) {
648
+ callback({
649
+ text: responseText,
650
+ source: message.content.source
651
+ });
652
+ }
653
+ return {
654
+ success: true,
655
+ data: {
656
+ channelCount: joinedChannels.length,
657
+ channels: joinedChannels,
658
+ primaryChannel
659
+ }
660
+ };
661
+ },
662
+ examples: [
663
+ [
664
+ {
665
+ name: "{{user1}}",
666
+ content: { text: "What channels are you in?" }
667
+ },
668
+ {
669
+ name: "{{agent}}",
670
+ content: {
671
+ text: "I'll list the channels I'm currently in.",
672
+ actions: ["TWITCH_LIST_CHANNELS"]
673
+ }
674
+ }
675
+ ]
676
+ ]
677
+ };
678
+
679
+ // src/actions/sendMessage.ts
680
+ import {
681
+ composePromptFromState as composePromptFromState3,
682
+ ModelType as ModelType3,
683
+ parseJSONObjectFromText as parseJSONObjectFromText3
684
+ } from "@elizaos/core";
685
+ var SEND_MESSAGE_TEMPLATE = `You are helping to extract send message parameters for Twitch chat.
686
+
687
+ The user wants to send a message to a Twitch channel.
688
+
689
+ Recent conversation:
690
+ {{recentMessages}}
691
+
692
+ Extract the following:
693
+ 1. text: The message text to send
694
+ 2. channel: The channel name to send to (without # prefix), or "current" for the current channel
695
+
696
+ Respond with a JSON object like:
697
+ {
698
+ "text": "The message to send",
699
+ "channel": "current"
700
+ }
701
+
702
+ Only respond with the JSON object, no other text.`;
703
+ var sendMessage = {
704
+ name: "TWITCH_SEND_MESSAGE",
705
+ similes: [
706
+ "SEND_TWITCH_MESSAGE",
707
+ "TWITCH_CHAT",
708
+ "CHAT_TWITCH",
709
+ "SAY_IN_TWITCH"
710
+ ],
711
+ description: "Send a message to a Twitch channel",
712
+ validate: async (_runtime, message, _state) => {
713
+ return message.content.source === "twitch";
714
+ },
715
+ handler: async (runtime, message, state, _options, callback) => {
716
+ const twitchService = runtime.getService(TWITCH_SERVICE_NAME);
717
+ if (!twitchService || !twitchService.isConnected()) {
718
+ if (callback) {
719
+ callback({
720
+ text: "Twitch service is not available.",
721
+ source: "twitch"
722
+ });
723
+ }
724
+ return { success: false, error: "Twitch service not available" };
725
+ }
726
+ const currentState = state ?? await runtime.composeState(message);
727
+ const prompt = await composePromptFromState3({
728
+ template: SEND_MESSAGE_TEMPLATE,
729
+ state: currentState
730
+ });
731
+ let messageInfo = null;
732
+ for (let attempt = 0;attempt < 3; attempt++) {
733
+ const response = await runtime.useModel(ModelType3.TEXT_SMALL, {
734
+ prompt
735
+ });
736
+ const parsed = parseJSONObjectFromText3(String(response));
737
+ if (parsed?.text) {
738
+ messageInfo = {
739
+ text: String(parsed.text),
740
+ channel: String(parsed.channel || "current")
741
+ };
742
+ break;
743
+ }
744
+ }
745
+ if (!messageInfo || !messageInfo.text) {
746
+ if (callback) {
747
+ callback({
748
+ text: "I couldn't understand what message you want me to send. Please try again.",
749
+ source: "twitch"
750
+ });
751
+ }
752
+ return { success: false, error: "Could not extract message parameters" };
753
+ }
754
+ let targetChannel = twitchService.getPrimaryChannel();
755
+ if (messageInfo.channel && messageInfo.channel !== "current") {
756
+ targetChannel = normalizeChannel(messageInfo.channel);
757
+ }
758
+ if (currentState?.data?.room?.channelId) {
759
+ targetChannel = normalizeChannel(currentState.data.room.channelId);
760
+ }
761
+ const result = await twitchService.sendMessage(messageInfo.text, {
762
+ channel: targetChannel
763
+ });
764
+ if (!result.success) {
765
+ if (callback) {
766
+ callback({
767
+ text: `Failed to send message: ${result.error}`,
768
+ source: "twitch"
769
+ });
770
+ }
771
+ return { success: false, error: result.error };
772
+ }
773
+ if (callback) {
774
+ callback({
775
+ text: "Message sent successfully.",
776
+ source: message.content.source
777
+ });
778
+ }
779
+ return {
780
+ success: true,
781
+ data: {
782
+ channel: targetChannel,
783
+ messageId: result.messageId
784
+ }
785
+ };
786
+ },
787
+ examples: [
788
+ [
789
+ {
790
+ name: "{{user1}}",
791
+ content: { text: "Send a message to chat saying 'Hello everyone!'" }
792
+ },
793
+ {
794
+ name: "{{agent}}",
795
+ content: {
796
+ text: "I'll send that message to the chat.",
797
+ actions: ["TWITCH_SEND_MESSAGE"]
798
+ }
799
+ }
800
+ ]
801
+ ]
802
+ };
803
+
804
+ // src/providers/channelState.ts
805
+ var channelStateProvider = {
806
+ name: "twitchChannelState",
807
+ description: "Provides information about the current Twitch channel context",
808
+ get: async (runtime, message, state) => {
809
+ if (message.content.source !== "twitch") {
810
+ return {
811
+ data: {},
812
+ values: {},
813
+ text: ""
814
+ };
815
+ }
816
+ const twitchService = runtime.getService(TWITCH_SERVICE_NAME);
817
+ if (!twitchService || !twitchService.isConnected()) {
818
+ return {
819
+ data: {
820
+ connected: false
821
+ },
822
+ values: {
823
+ connected: false
824
+ },
825
+ text: ""
826
+ };
827
+ }
828
+ const agentName = state?.agentName || "The agent";
829
+ const room = state?.data?.room;
830
+ const channelId = room?.channelId;
831
+ const channel = channelId ? normalizeChannel(channelId) : twitchService.getPrimaryChannel();
832
+ const joinedChannels = twitchService.getJoinedChannels();
833
+ const isPrimaryChannel = channel === twitchService.getPrimaryChannel();
834
+ const botUsername = twitchService.getBotUsername();
835
+ let responseText = `${agentName} is currently in Twitch channel ${formatChannelForDisplay(channel)}.`;
836
+ if (isPrimaryChannel) {
837
+ responseText += " This is the primary channel.";
838
+ }
839
+ responseText += `
840
+
841
+ Twitch is a live streaming platform. Chat messages are public and visible to all viewers.`;
842
+ responseText += ` ${agentName} is logged in as @${botUsername}.`;
843
+ responseText += ` Currently connected to ${joinedChannels.length} channel(s).`;
844
+ return {
845
+ data: {
846
+ channel,
847
+ displayChannel: formatChannelForDisplay(channel),
848
+ isPrimaryChannel,
849
+ botUsername,
850
+ joinedChannels,
851
+ channelCount: joinedChannels.length,
852
+ connected: true
853
+ },
854
+ values: {
855
+ channel,
856
+ displayChannel: formatChannelForDisplay(channel),
857
+ isPrimaryChannel,
858
+ botUsername,
859
+ channelCount: joinedChannels.length
860
+ },
861
+ text: responseText
862
+ };
863
+ }
864
+ };
865
+
866
+ // src/providers/userContext.ts
867
+ var userContextProvider = {
868
+ name: "twitchUserContext",
869
+ description: "Provides information about the Twitch user in the current conversation",
870
+ get: async (runtime, message, state) => {
871
+ if (message.content.source !== "twitch") {
872
+ return {
873
+ data: {},
874
+ values: {},
875
+ text: ""
876
+ };
877
+ }
878
+ const twitchService = runtime.getService(TWITCH_SERVICE_NAME);
879
+ if (!twitchService || !twitchService.isConnected()) {
880
+ return {
881
+ data: {},
882
+ values: {},
883
+ text: ""
884
+ };
885
+ }
886
+ const agentName = state?.agentName || "The agent";
887
+ const metadata = message.content.metadata;
888
+ const userInfo = metadata?.user;
889
+ if (!userInfo) {
890
+ return {
891
+ data: {},
892
+ values: {},
893
+ text: ""
894
+ };
895
+ }
896
+ const displayName = getTwitchUserDisplayName(userInfo);
897
+ const roles = [];
898
+ if (userInfo.isBroadcaster) {
899
+ roles.push("broadcaster");
900
+ }
901
+ if (userInfo.isModerator) {
902
+ roles.push("moderator");
903
+ }
904
+ if (userInfo.isVip) {
905
+ roles.push("VIP");
906
+ }
907
+ if (userInfo.isSubscriber) {
908
+ roles.push("subscriber");
909
+ }
910
+ const roleText = roles.length > 0 ? roles.join(", ") : "viewer";
911
+ let responseText = `${agentName} is talking to ${displayName} (${roleText}) in Twitch chat.`;
912
+ if (userInfo.isBroadcaster) {
913
+ responseText += ` ${displayName} is the channel owner/broadcaster.`;
914
+ } else if (userInfo.isModerator) {
915
+ responseText += ` ${displayName} is a channel moderator.`;
916
+ }
917
+ return {
918
+ data: {
919
+ userId: userInfo.userId,
920
+ username: userInfo.username,
921
+ displayName,
922
+ isBroadcaster: userInfo.isBroadcaster,
923
+ isModerator: userInfo.isModerator,
924
+ isVip: userInfo.isVip,
925
+ isSubscriber: userInfo.isSubscriber,
926
+ roles,
927
+ color: userInfo.color
928
+ },
929
+ values: {
930
+ userId: userInfo.userId,
931
+ username: userInfo.username,
932
+ displayName,
933
+ roleText,
934
+ isBroadcaster: userInfo.isBroadcaster,
935
+ isModerator: userInfo.isModerator
936
+ },
937
+ text: responseText
938
+ };
939
+ }
940
+ };
941
+
942
+ // src/index.ts
943
+ var twitchPlugin = {
944
+ name: "twitch",
945
+ description: "Twitch chat integration plugin for ElizaOS with real-time messaging",
946
+ services: [TwitchService],
947
+ actions: [sendMessage, joinChannel, leaveChannel, listChannels],
948
+ providers: [channelStateProvider, userContextProvider],
949
+ tests: [],
950
+ init: async (_config, runtime) => {
951
+ const username = runtime.getSetting("TWITCH_USERNAME");
952
+ const clientId = runtime.getSetting("TWITCH_CLIENT_ID");
953
+ const accessToken = runtime.getSetting("TWITCH_ACCESS_TOKEN");
954
+ const channel = runtime.getSetting("TWITCH_CHANNEL");
955
+ logger2.info("=".repeat(60));
956
+ logger2.info("Twitch Plugin Configuration");
957
+ logger2.info("=".repeat(60));
958
+ logger2.info(` Username: ${username ? "✓ Set" : "✗ Missing (required)"}`);
959
+ logger2.info(` Client ID: ${clientId ? "✓ Set" : "✗ Missing (required)"}`);
960
+ logger2.info(` Access Token: ${accessToken ? "✓ Set" : "✗ Missing (required)"}`);
961
+ logger2.info(` Channel: ${channel ? `✓ ${channel}` : "✗ Missing (required)"}`);
962
+ logger2.info("=".repeat(60));
963
+ const missing = [];
964
+ if (!username)
965
+ missing.push("TWITCH_USERNAME");
966
+ if (!clientId)
967
+ missing.push("TWITCH_CLIENT_ID");
968
+ if (!accessToken)
969
+ missing.push("TWITCH_ACCESS_TOKEN");
970
+ if (!channel)
971
+ missing.push("TWITCH_CHANNEL");
972
+ if (missing.length > 0) {
973
+ logger2.warn(`Twitch plugin: Missing required configuration: ${missing.join(", ")}`);
974
+ }
975
+ const clientSecret = runtime.getSetting("TWITCH_CLIENT_SECRET");
976
+ const refreshToken = runtime.getSetting("TWITCH_REFRESH_TOKEN");
977
+ const additionalChannels = runtime.getSetting("TWITCH_CHANNELS");
978
+ const requireMention = runtime.getSetting("TWITCH_REQUIRE_MENTION");
979
+ const allowedRoles = runtime.getSetting("TWITCH_ALLOWED_ROLES");
980
+ if (clientSecret && refreshToken) {
981
+ logger2.info(" Token Refresh: ✓ Enabled (client secret and refresh token set)");
982
+ } else if (clientSecret || refreshToken) {
983
+ logger2.warn(" Token Refresh: ⚠ Partial (need both TWITCH_CLIENT_SECRET and TWITCH_REFRESH_TOKEN)");
984
+ }
985
+ if (additionalChannels) {
986
+ logger2.info(` Additional Channels: ${additionalChannels}`);
987
+ }
988
+ if (requireMention === "true") {
989
+ logger2.info(" Require Mention: ✓ Enabled (will only respond to @mentions)");
990
+ }
991
+ if (allowedRoles) {
992
+ logger2.info(` Allowed Roles: ${allowedRoles}`);
993
+ }
994
+ }
995
+ };
996
+ var src_default = twitchPlugin;
997
+ export {
998
+ userContextProvider,
999
+ stripMarkdownForTwitch,
1000
+ splitMessageForTwitch,
1001
+ sendMessage,
1002
+ normalizeChannel,
1003
+ listChannels,
1004
+ leaveChannel,
1005
+ joinChannel,
1006
+ getTwitchUserDisplayName,
1007
+ formatChannelForDisplay,
1008
+ src_default as default,
1009
+ channelStateProvider,
1010
+ TwitchServiceNotInitializedError,
1011
+ TwitchService,
1012
+ TwitchPluginError,
1013
+ TwitchNotConnectedError,
1014
+ TwitchEventTypes,
1015
+ TwitchConfigurationError,
1016
+ TwitchApiError,
1017
+ TWITCH_SERVICE_NAME,
1018
+ MAX_TWITCH_MESSAGE_LENGTH
1019
+ };
1020
+
1021
+ //# debugId=A95FC42F6866C30064756E2164756E21