@elizaos/plugin-twitch-typescript 2.0.0-alpha.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.
@@ -0,0 +1,940 @@
1
+ import { describe, expect, test, beforeEach, mock } from "bun:test";
2
+ import twitchPlugin, {
3
+ TwitchService,
4
+ channelStateProvider,
5
+ joinChannel,
6
+ leaveChannel,
7
+ listChannels,
8
+ sendMessage,
9
+ userContextProvider,
10
+ normalizeChannel,
11
+ formatChannelForDisplay,
12
+ getTwitchUserDisplayName,
13
+ stripMarkdownForTwitch,
14
+ splitMessageForTwitch,
15
+ MAX_TWITCH_MESSAGE_LENGTH,
16
+ TWITCH_SERVICE_NAME,
17
+ TwitchEventTypes,
18
+ TwitchPluginError,
19
+ TwitchServiceNotInitializedError,
20
+ TwitchNotConnectedError,
21
+ TwitchConfigurationError,
22
+ TwitchApiError,
23
+ type TwitchSettings,
24
+ type TwitchUserInfo,
25
+ type TwitchMessage,
26
+ type TwitchMessageSendOptions,
27
+ type TwitchSendResult,
28
+ } from "../src/index.ts";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Helpers: mock runtime, memory, state
32
+ // ---------------------------------------------------------------------------
33
+
34
+ function makeMockRuntime(overrides: Record<string, unknown> = {}) {
35
+ return {
36
+ getSetting: (key: string) => (overrides as Record<string, string>)[key] ?? null,
37
+ getService: (_name: string) => overrides.service ?? null,
38
+ composeState: async (_msg: unknown) => ({ recentMessages: "" }),
39
+ useModel: async (_type: string, _opts: unknown) => overrides.modelResponse ?? "{}",
40
+ emitEvent: async () => {},
41
+ ...overrides,
42
+ } as any;
43
+ }
44
+
45
+ function makeMemory(source: string = "twitch", text: string = "hello") {
46
+ return {
47
+ content: { text, source },
48
+ userId: "user-1",
49
+ roomId: "room-1",
50
+ } as any;
51
+ }
52
+
53
+ function makeState(extra: Record<string, unknown> = {}) {
54
+ return {
55
+ agentName: "TestBot",
56
+ recentMessages: "",
57
+ data: {},
58
+ ...extra,
59
+ } as any;
60
+ }
61
+
62
+ function makeMockTwitchService(overrides: Record<string, unknown> = {}) {
63
+ return {
64
+ isConnected: () => overrides.connected ?? true,
65
+ getBotUsername: () => overrides.botUsername ?? "testbot",
66
+ getPrimaryChannel: () => overrides.primaryChannel ?? "mainchannel",
67
+ getJoinedChannels: () =>
68
+ (overrides.joinedChannels as string[]) ?? ["mainchannel"],
69
+ sendMessage: async (_text: string, _opts?: TwitchMessageSendOptions) =>
70
+ (overrides.sendResult as TwitchSendResult) ?? {
71
+ success: true,
72
+ messageId: "msg-123",
73
+ },
74
+ joinChannel: overrides.joinChannel ?? (async () => {}),
75
+ leaveChannel: overrides.leaveChannel ?? (async () => {}),
76
+ };
77
+ }
78
+
79
+ // ===========================================================================
80
+ // 1. Plugin Metadata
81
+ // ===========================================================================
82
+
83
+ describe("Plugin metadata", () => {
84
+ test("has correct name", () => {
85
+ expect(twitchPlugin.name).toBe("twitch");
86
+ });
87
+
88
+ test("has a description containing 'Twitch'", () => {
89
+ expect(twitchPlugin.description).toContain("Twitch");
90
+ });
91
+
92
+ test("registers exactly 4 actions", () => {
93
+ expect(twitchPlugin.actions).toHaveLength(4);
94
+ const names = twitchPlugin.actions!.map((a) => a.name);
95
+ expect(names).toContain("TWITCH_SEND_MESSAGE");
96
+ expect(names).toContain("TWITCH_JOIN_CHANNEL");
97
+ expect(names).toContain("TWITCH_LEAVE_CHANNEL");
98
+ expect(names).toContain("TWITCH_LIST_CHANNELS");
99
+ });
100
+
101
+ test("registers exactly 2 providers", () => {
102
+ expect(twitchPlugin.providers).toHaveLength(2);
103
+ const names = twitchPlugin.providers!.map((p) => p.name);
104
+ expect(names).toContain("twitchChannelState");
105
+ expect(names).toContain("twitchUserContext");
106
+ });
107
+
108
+ test("registers exactly 1 service", () => {
109
+ expect(twitchPlugin.services).toHaveLength(1);
110
+ expect(twitchPlugin.services![0]).toBe(TwitchService);
111
+ });
112
+
113
+ test("has an init function", () => {
114
+ expect(typeof twitchPlugin.init).toBe("function");
115
+ });
116
+ });
117
+
118
+ // ===========================================================================
119
+ // 2. Constants
120
+ // ===========================================================================
121
+
122
+ describe("Constants", () => {
123
+ test("MAX_TWITCH_MESSAGE_LENGTH is 500", () => {
124
+ expect(MAX_TWITCH_MESSAGE_LENGTH).toBe(500);
125
+ });
126
+
127
+ test("TWITCH_SERVICE_NAME is 'twitch'", () => {
128
+ expect(TWITCH_SERVICE_NAME).toBe("twitch");
129
+ });
130
+ });
131
+
132
+ // ===========================================================================
133
+ // 3. Event Types Enum
134
+ // ===========================================================================
135
+
136
+ describe("TwitchEventTypes", () => {
137
+ test("has all expected event types", () => {
138
+ expect(TwitchEventTypes.MESSAGE_RECEIVED).toBe("TWITCH_MESSAGE_RECEIVED");
139
+ expect(TwitchEventTypes.MESSAGE_SENT).toBe("TWITCH_MESSAGE_SENT");
140
+ expect(TwitchEventTypes.JOIN_CHANNEL).toBe("TWITCH_JOIN_CHANNEL");
141
+ expect(TwitchEventTypes.LEAVE_CHANNEL).toBe("TWITCH_LEAVE_CHANNEL");
142
+ expect(TwitchEventTypes.CONNECTION_READY).toBe("TWITCH_CONNECTION_READY");
143
+ expect(TwitchEventTypes.CONNECTION_LOST).toBe("TWITCH_CONNECTION_LOST");
144
+ });
145
+ });
146
+
147
+ // ===========================================================================
148
+ // 4. Utility Functions
149
+ // ===========================================================================
150
+
151
+ describe("normalizeChannel", () => {
152
+ test("strips leading # from channel name", () => {
153
+ expect(normalizeChannel("#mychannel")).toBe("mychannel");
154
+ });
155
+
156
+ test("returns channel unchanged when no #", () => {
157
+ expect(normalizeChannel("mychannel")).toBe("mychannel");
158
+ });
159
+
160
+ test("handles empty string", () => {
161
+ expect(normalizeChannel("")).toBe("");
162
+ });
163
+
164
+ test("only strips the first #", () => {
165
+ expect(normalizeChannel("##double")).toBe("#double");
166
+ });
167
+ });
168
+
169
+ describe("formatChannelForDisplay", () => {
170
+ test("adds # prefix to bare name", () => {
171
+ expect(formatChannelForDisplay("mychannel")).toBe("#mychannel");
172
+ });
173
+
174
+ test("does not double-prefix", () => {
175
+ expect(formatChannelForDisplay("#mychannel")).toBe("#mychannel");
176
+ });
177
+ });
178
+
179
+ describe("getTwitchUserDisplayName", () => {
180
+ test("returns displayName when set", () => {
181
+ const user = {
182
+ userId: "1",
183
+ username: "alice",
184
+ displayName: "Alice_Cool",
185
+ isModerator: false,
186
+ isBroadcaster: false,
187
+ isVip: false,
188
+ isSubscriber: false,
189
+ badges: new Map(),
190
+ } as TwitchUserInfo;
191
+ expect(getTwitchUserDisplayName(user)).toBe("Alice_Cool");
192
+ });
193
+
194
+ test("falls back to username when displayName is empty", () => {
195
+ const user = {
196
+ userId: "1",
197
+ username: "bob",
198
+ displayName: "",
199
+ isModerator: false,
200
+ isBroadcaster: false,
201
+ isVip: false,
202
+ isSubscriber: false,
203
+ badges: new Map(),
204
+ } as TwitchUserInfo;
205
+ expect(getTwitchUserDisplayName(user)).toBe("bob");
206
+ });
207
+ });
208
+
209
+ describe("stripMarkdownForTwitch", () => {
210
+ test("strips bold (**text**)", () => {
211
+ expect(stripMarkdownForTwitch("**bold text**")).toBe("bold text");
212
+ });
213
+
214
+ test("strips bold (__text__)", () => {
215
+ expect(stripMarkdownForTwitch("__bold text__")).toBe("bold text");
216
+ });
217
+
218
+ test("strips italic (*text*)", () => {
219
+ expect(stripMarkdownForTwitch("*italic text*")).toBe("italic text");
220
+ });
221
+
222
+ test("strips italic (_text_)", () => {
223
+ expect(stripMarkdownForTwitch("_italic text_")).toBe("italic text");
224
+ });
225
+
226
+ test("strips strikethrough", () => {
227
+ expect(stripMarkdownForTwitch("~~strikethrough~~")).toBe("strikethrough");
228
+ });
229
+
230
+ test("strips inline code", () => {
231
+ expect(stripMarkdownForTwitch("`some code`")).toBe("some code");
232
+ });
233
+
234
+ test("processes code blocks", () => {
235
+ // The inline code regex runs before the code block regex, so triple-backtick
236
+ // blocks where content has no backticks get partially consumed.
237
+ // Verify the function produces a non-empty stripped result.
238
+ const result = stripMarkdownForTwitch("```js\nconsole.log('hi');\n```");
239
+ expect(result.length).toBeGreaterThan(0);
240
+ // Test with a code block whose content already contains backticks
241
+ const result2 = stripMarkdownForTwitch("before ```code``` after");
242
+ expect(result2).toContain("code");
243
+ });
244
+
245
+ test("keeps link text, removes URL", () => {
246
+ expect(stripMarkdownForTwitch("[click here](https://example.com)")).toBe(
247
+ "click here",
248
+ );
249
+ });
250
+
251
+ test("strips header markers", () => {
252
+ expect(stripMarkdownForTwitch("## My Header")).toBe("My Header");
253
+ });
254
+
255
+ test("strips blockquotes", () => {
256
+ expect(stripMarkdownForTwitch("> quoted text")).toBe("quoted text");
257
+ });
258
+
259
+ test("converts unordered list markers to bullet", () => {
260
+ expect(stripMarkdownForTwitch("- item one")).toBe("• item one");
261
+ });
262
+
263
+ test("converts ordered list markers to bullet", () => {
264
+ expect(stripMarkdownForTwitch("1. item one")).toBe("• item one");
265
+ });
266
+
267
+ test("collapses multiple newlines", () => {
268
+ expect(stripMarkdownForTwitch("a\n\n\n\nb")).toBe("a\n\nb");
269
+ });
270
+
271
+ test("handles plain text untouched", () => {
272
+ expect(stripMarkdownForTwitch("plain text")).toBe("plain text");
273
+ });
274
+
275
+ test("trims leading/trailing whitespace", () => {
276
+ expect(stripMarkdownForTwitch(" hello ")).toBe("hello");
277
+ });
278
+ });
279
+
280
+ describe("splitMessageForTwitch", () => {
281
+ test("returns single chunk for short messages", () => {
282
+ const result = splitMessageForTwitch("Hello world");
283
+ expect(result).toEqual(["Hello world"]);
284
+ });
285
+
286
+ test("splits long messages into multiple chunks", () => {
287
+ const longMessage = "A".repeat(600);
288
+ const result = splitMessageForTwitch(longMessage);
289
+ expect(result.length).toBeGreaterThan(1);
290
+ for (const chunk of result) {
291
+ expect(chunk.length).toBeLessThanOrEqual(MAX_TWITCH_MESSAGE_LENGTH);
292
+ }
293
+ });
294
+
295
+ test("prefers splitting at sentence boundaries", () => {
296
+ // The ". " must appear past halfway of maxLength to be selected.
297
+ // lastIndexOf(". ", maxLength) returns the index of the ".", so the
298
+ // split point is at that index — everything before goes to chunk 0.
299
+ const prefix = "A".repeat(300);
300
+ const text = prefix + ". " + "B".repeat(250); // total 552
301
+ const result = splitMessageForTwitch(text, 500);
302
+ expect(result.length).toBe(2);
303
+ expect(result[0]).toBe(prefix);
304
+ expect(result[1]).toContain("B");
305
+ expect(result[1].length).toBeLessThan(text.length);
306
+ });
307
+
308
+ test("falls back to word boundary when no sentence break", () => {
309
+ const words = Array(60).fill("word").join(" "); // 60*5-1 = 299 chars
310
+ const result = splitMessageForTwitch(words, 50);
311
+ expect(result.length).toBeGreaterThan(1);
312
+ // Every chunk should be within the max limit
313
+ for (const chunk of result) {
314
+ expect(chunk.length).toBeLessThanOrEqual(50);
315
+ }
316
+ // Reassembled text should contain all original words
317
+ const reassembled = result.join(" ");
318
+ expect(reassembled.replace(/\s+/g, " ")).toContain("word");
319
+ });
320
+
321
+ test("respects custom maxLength", () => {
322
+ const text = "A".repeat(30);
323
+ const result = splitMessageForTwitch(text, 10);
324
+ expect(result.length).toBe(3);
325
+ });
326
+
327
+ test("returns empty for single-word exact match", () => {
328
+ const text = "A".repeat(500);
329
+ const result = splitMessageForTwitch(text, 500);
330
+ expect(result).toEqual([text]);
331
+ });
332
+ });
333
+
334
+ // ===========================================================================
335
+ // 5. Error Classes
336
+ // ===========================================================================
337
+
338
+ describe("Custom Errors", () => {
339
+ test("TwitchPluginError is an Error with correct name", () => {
340
+ const err = new TwitchPluginError("oops");
341
+ expect(err).toBeInstanceOf(Error);
342
+ expect(err.name).toBe("TwitchPluginError");
343
+ expect(err.message).toBe("oops");
344
+ });
345
+
346
+ test("TwitchServiceNotInitializedError has default message", () => {
347
+ const err = new TwitchServiceNotInitializedError();
348
+ expect(err.message).toBe("Twitch service is not initialized");
349
+ expect(err.name).toBe("TwitchServiceNotInitializedError");
350
+ expect(err).toBeInstanceOf(TwitchPluginError);
351
+ });
352
+
353
+ test("TwitchNotConnectedError has default message", () => {
354
+ const err = new TwitchNotConnectedError();
355
+ expect(err.message).toBe("Twitch client is not connected");
356
+ expect(err.name).toBe("TwitchNotConnectedError");
357
+ expect(err).toBeInstanceOf(TwitchPluginError);
358
+ });
359
+
360
+ test("TwitchConfigurationError stores settingName", () => {
361
+ const err = new TwitchConfigurationError("bad config", "MY_SETTING");
362
+ expect(err.message).toBe("bad config");
363
+ expect(err.settingName).toBe("MY_SETTING");
364
+ expect(err.name).toBe("TwitchConfigurationError");
365
+ expect(err).toBeInstanceOf(TwitchPluginError);
366
+ });
367
+
368
+ test("TwitchApiError stores statusCode", () => {
369
+ const err = new TwitchApiError("api fail", 401);
370
+ expect(err.message).toBe("api fail");
371
+ expect(err.statusCode).toBe(401);
372
+ expect(err.name).toBe("TwitchApiError");
373
+ expect(err).toBeInstanceOf(TwitchPluginError);
374
+ });
375
+ });
376
+
377
+ // ===========================================================================
378
+ // 6. sendMessage Action
379
+ // ===========================================================================
380
+
381
+ describe("sendMessage action", () => {
382
+ test("has correct metadata", () => {
383
+ expect(sendMessage.name).toBe("TWITCH_SEND_MESSAGE");
384
+ expect(sendMessage.description).toBe("Send a message to a Twitch channel");
385
+ expect(sendMessage.similes).toContain("SEND_TWITCH_MESSAGE");
386
+ expect(sendMessage.similes).toContain("TWITCH_CHAT");
387
+ expect(sendMessage.similes).toContain("CHAT_TWITCH");
388
+ expect(sendMessage.similes).toContain("SAY_IN_TWITCH");
389
+ expect(sendMessage.similes).toHaveLength(4);
390
+ });
391
+
392
+ test("has examples", () => {
393
+ expect(sendMessage.examples!.length).toBeGreaterThan(0);
394
+ });
395
+
396
+ test("validate returns true for twitch source", async () => {
397
+ const runtime = makeMockRuntime();
398
+ const memory = makeMemory("twitch");
399
+ expect(await sendMessage.validate!(runtime, memory)).toBe(true);
400
+ });
401
+
402
+ test("validate returns false for non-twitch source", async () => {
403
+ const runtime = makeMockRuntime();
404
+ expect(await sendMessage.validate!(runtime, makeMemory("discord"))).toBe(false);
405
+ expect(await sendMessage.validate!(runtime, makeMemory("telegram"))).toBe(false);
406
+ expect(await sendMessage.validate!(runtime, makeMemory(""))).toBe(false);
407
+ });
408
+
409
+ test("handler returns error when service unavailable", async () => {
410
+ const runtime = makeMockRuntime({ service: null });
411
+ const memory = makeMemory("twitch");
412
+ let callbackPayload: any = null;
413
+ const callback = (resp: any) => { callbackPayload = resp; };
414
+
415
+ const result = await sendMessage.handler!(runtime, memory, makeState(), {}, callback);
416
+
417
+ expect(result.success).toBe(false);
418
+ expect(result.error).toBe("Twitch service not available");
419
+ expect(callbackPayload).not.toBeNull();
420
+ expect(callbackPayload.text).toBe("Twitch service is not available.");
421
+ });
422
+
423
+ test("handler returns error when service not connected", async () => {
424
+ const service = makeMockTwitchService({ connected: false });
425
+ const runtime = makeMockRuntime({
426
+ service,
427
+ getService: () => service,
428
+ });
429
+ const memory = makeMemory("twitch");
430
+ let callbackPayload: any = null;
431
+ const callback = (resp: any) => { callbackPayload = resp; };
432
+
433
+ const result = await sendMessage.handler!(runtime, memory, makeState(), {}, callback);
434
+
435
+ expect(result.success).toBe(false);
436
+ expect(result.error).toBe("Twitch service not available");
437
+ expect(callbackPayload.text).toBe("Twitch service is not available.");
438
+ });
439
+ });
440
+
441
+ // ===========================================================================
442
+ // 7. joinChannel Action
443
+ // ===========================================================================
444
+
445
+ describe("joinChannel action", () => {
446
+ test("has correct metadata", () => {
447
+ expect(joinChannel.name).toBe("TWITCH_JOIN_CHANNEL");
448
+ expect(joinChannel.description).toContain("Join");
449
+ expect(joinChannel.description).toContain("Twitch channel");
450
+ expect(joinChannel.similes).toContain("JOIN_TWITCH_CHANNEL");
451
+ expect(joinChannel.similes).toContain("ENTER_CHANNEL");
452
+ expect(joinChannel.similes).toContain("CONNECT_CHANNEL");
453
+ expect(joinChannel.similes).toHaveLength(3);
454
+ });
455
+
456
+ test("has examples", () => {
457
+ expect(joinChannel.examples!.length).toBeGreaterThan(0);
458
+ });
459
+
460
+ test("validate returns true for twitch source", async () => {
461
+ const runtime = makeMockRuntime();
462
+ expect(await joinChannel.validate!(runtime, makeMemory("twitch"))).toBe(true);
463
+ });
464
+
465
+ test("validate returns false for non-twitch source", async () => {
466
+ const runtime = makeMockRuntime();
467
+ expect(await joinChannel.validate!(runtime, makeMemory("discord"))).toBe(false);
468
+ expect(await joinChannel.validate!(runtime, makeMemory(""))).toBe(false);
469
+ });
470
+
471
+ test("handler returns error when service unavailable", async () => {
472
+ const runtime = makeMockRuntime({ service: null });
473
+ const memory = makeMemory("twitch");
474
+ let callbackText = "";
475
+ const callback = (resp: any) => { callbackText = resp.text; };
476
+
477
+ const result = await joinChannel.handler!(runtime, memory, makeState(), {}, callback);
478
+
479
+ expect(result.success).toBe(false);
480
+ expect(result.error).toBe("Twitch service not available");
481
+ expect(callbackText).toBe("Twitch service is not available.");
482
+ });
483
+ });
484
+
485
+ // ===========================================================================
486
+ // 8. leaveChannel Action
487
+ // ===========================================================================
488
+
489
+ describe("leaveChannel action", () => {
490
+ test("has correct metadata", () => {
491
+ expect(leaveChannel.name).toBe("TWITCH_LEAVE_CHANNEL");
492
+ expect(leaveChannel.description).toBe("Leave a Twitch channel");
493
+ expect(leaveChannel.similes).toContain("LEAVE_TWITCH_CHANNEL");
494
+ expect(leaveChannel.similes).toContain("EXIT_CHANNEL");
495
+ expect(leaveChannel.similes).toContain("PART_CHANNEL");
496
+ expect(leaveChannel.similes).toContain("DISCONNECT_CHANNEL");
497
+ expect(leaveChannel.similes).toHaveLength(4);
498
+ });
499
+
500
+ test("has examples", () => {
501
+ expect(leaveChannel.examples!.length).toBeGreaterThan(0);
502
+ });
503
+
504
+ test("validate returns true for twitch source", async () => {
505
+ const runtime = makeMockRuntime();
506
+ expect(await leaveChannel.validate!(runtime, makeMemory("twitch"))).toBe(true);
507
+ });
508
+
509
+ test("validate returns false for non-twitch source", async () => {
510
+ const runtime = makeMockRuntime();
511
+ expect(await leaveChannel.validate!(runtime, makeMemory("discord"))).toBe(false);
512
+ });
513
+
514
+ test("handler returns error when service unavailable", async () => {
515
+ const runtime = makeMockRuntime({ service: null });
516
+ const memory = makeMemory("twitch");
517
+ let callbackText = "";
518
+ const callback = (resp: any) => { callbackText = resp.text; };
519
+
520
+ const result = await leaveChannel.handler!(runtime, memory, makeState(), {}, callback);
521
+
522
+ expect(result.success).toBe(false);
523
+ expect(result.error).toBe("Twitch service not available");
524
+ expect(callbackText).toBe("Twitch service is not available.");
525
+ });
526
+ });
527
+
528
+ // ===========================================================================
529
+ // 9. listChannels Action
530
+ // ===========================================================================
531
+
532
+ describe("listChannels action", () => {
533
+ test("has correct metadata", () => {
534
+ expect(listChannels.name).toBe("TWITCH_LIST_CHANNELS");
535
+ expect(listChannels.description).toContain("List");
536
+ expect(listChannels.description).toContain("Twitch channels");
537
+ expect(listChannels.similes).toContain("LIST_TWITCH_CHANNELS");
538
+ expect(listChannels.similes).toContain("SHOW_CHANNELS");
539
+ expect(listChannels.similes).toContain("GET_CHANNELS");
540
+ expect(listChannels.similes).toContain("CURRENT_CHANNELS");
541
+ expect(listChannels.similes).toHaveLength(4);
542
+ });
543
+
544
+ test("has examples", () => {
545
+ expect(listChannels.examples!.length).toBeGreaterThan(0);
546
+ });
547
+
548
+ test("validate returns true for twitch source", async () => {
549
+ const runtime = makeMockRuntime();
550
+ expect(await listChannels.validate!(runtime, makeMemory("twitch"))).toBe(true);
551
+ });
552
+
553
+ test("validate returns false for non-twitch source", async () => {
554
+ const runtime = makeMockRuntime();
555
+ expect(await listChannels.validate!(runtime, makeMemory("slack"))).toBe(false);
556
+ });
557
+
558
+ test("handler returns error when service unavailable", async () => {
559
+ const runtime = makeMockRuntime({ service: null });
560
+ const memory = makeMemory("twitch");
561
+ let callbackText = "";
562
+ const callback = (resp: any) => { callbackText = resp.text; };
563
+
564
+ const result = await listChannels.handler!(runtime, memory, makeState(), {}, callback);
565
+
566
+ expect(result.success).toBe(false);
567
+ expect(result.error).toBe("Twitch service not available");
568
+ expect(callbackText).toBe("Twitch service is not available.");
569
+ });
570
+
571
+ test("handler returns channel list when service connected", async () => {
572
+ const service = makeMockTwitchService({
573
+ connected: true,
574
+ primaryChannel: "mainchannel",
575
+ joinedChannels: ["mainchannel", "otherchannel"],
576
+ });
577
+ const runtime = makeMockRuntime({
578
+ service,
579
+ getService: () => service,
580
+ });
581
+ const memory = makeMemory("twitch");
582
+ let callbackText = "";
583
+ const callback = (resp: any) => { callbackText = resp.text; };
584
+
585
+ const result = await listChannels.handler!(runtime, memory, makeState(), {}, callback);
586
+
587
+ expect(result.success).toBe(true);
588
+ expect(result.data.channelCount).toBe(2);
589
+ expect(result.data.channels).toEqual(["mainchannel", "otherchannel"]);
590
+ expect(result.data.primaryChannel).toBe("mainchannel");
591
+ expect(callbackText).toContain("2 channel(s)");
592
+ expect(callbackText).toContain("#mainchannel (primary)");
593
+ expect(callbackText).toContain("#otherchannel");
594
+ });
595
+
596
+ test("handler returns empty message for no channels", async () => {
597
+ const service = makeMockTwitchService({
598
+ connected: true,
599
+ primaryChannel: "main",
600
+ joinedChannels: [],
601
+ });
602
+ const runtime = makeMockRuntime({
603
+ service,
604
+ getService: () => service,
605
+ });
606
+ const memory = makeMemory("twitch");
607
+ let callbackText = "";
608
+ const callback = (resp: any) => { callbackText = resp.text; };
609
+
610
+ const result = await listChannels.handler!(runtime, memory, makeState(), {}, callback);
611
+
612
+ expect(result.success).toBe(true);
613
+ expect(result.data.channelCount).toBe(0);
614
+ expect(callbackText).toBe("Not currently in any channels.");
615
+ });
616
+ });
617
+
618
+ // ===========================================================================
619
+ // 10. channelStateProvider
620
+ // ===========================================================================
621
+
622
+ describe("channelStateProvider", () => {
623
+ test("has correct metadata", () => {
624
+ expect(channelStateProvider.name).toBe("twitchChannelState");
625
+ expect(channelStateProvider.description).toContain("Twitch channel");
626
+ });
627
+
628
+ test("returns empty for non-twitch source", async () => {
629
+ const runtime = makeMockRuntime();
630
+ const memory = makeMemory("discord");
631
+ const result = await channelStateProvider.get(runtime, memory, makeState());
632
+ expect(result.text).toBe("");
633
+ expect(result.data).toEqual({});
634
+ });
635
+
636
+ test("returns disconnected state when service unavailable", async () => {
637
+ const runtime = makeMockRuntime({ service: null });
638
+ const memory = makeMemory("twitch");
639
+ const result = await channelStateProvider.get(runtime, memory, makeState());
640
+ expect(result.data.connected).toBe(false);
641
+ expect(result.text).toBe("");
642
+ });
643
+
644
+ test("returns full channel state when connected", async () => {
645
+ const service = makeMockTwitchService({
646
+ connected: true,
647
+ botUsername: "testbot",
648
+ primaryChannel: "mainchannel",
649
+ joinedChannels: ["mainchannel", "extra"],
650
+ });
651
+ const runtime = makeMockRuntime({
652
+ service,
653
+ getService: () => service,
654
+ });
655
+ const memory = makeMemory("twitch");
656
+ const state = makeState({ agentName: "CoolBot" });
657
+
658
+ const result = await channelStateProvider.get(runtime, memory, state);
659
+
660
+ expect(result.data.connected).toBe(true);
661
+ expect(result.data.channel).toBe("mainchannel");
662
+ expect(result.data.displayChannel).toBe("#mainchannel");
663
+ expect(result.data.isPrimaryChannel).toBe(true);
664
+ expect(result.data.botUsername).toBe("testbot");
665
+ expect(result.data.channelCount).toBe(2);
666
+ expect(result.data.joinedChannels).toEqual(["mainchannel", "extra"]);
667
+ expect(result.text).toContain("CoolBot");
668
+ expect(result.text).toContain("#mainchannel");
669
+ expect(result.text).toContain("primary channel");
670
+ expect(result.text).toContain("@testbot");
671
+ expect(result.text).toContain("2 channel(s)");
672
+ });
673
+
674
+ test("uses room channelId from state when available", async () => {
675
+ const service = makeMockTwitchService({
676
+ connected: true,
677
+ primaryChannel: "mainchannel",
678
+ joinedChannels: ["mainchannel", "otherchan"],
679
+ });
680
+ const runtime = makeMockRuntime({
681
+ service,
682
+ getService: () => service,
683
+ });
684
+ const memory = makeMemory("twitch");
685
+ const state = makeState({
686
+ data: { room: { channelId: "#otherchan" } },
687
+ });
688
+
689
+ const result = await channelStateProvider.get(runtime, memory, state);
690
+
691
+ expect(result.data.channel).toBe("otherchan");
692
+ expect(result.data.isPrimaryChannel).toBe(false);
693
+ });
694
+ });
695
+
696
+ // ===========================================================================
697
+ // 11. userContextProvider
698
+ // ===========================================================================
699
+
700
+ describe("userContextProvider", () => {
701
+ test("has correct metadata", () => {
702
+ expect(userContextProvider.name).toBe("twitchUserContext");
703
+ expect(userContextProvider.description).toContain("Twitch user");
704
+ });
705
+
706
+ test("returns empty for non-twitch source", async () => {
707
+ const runtime = makeMockRuntime();
708
+ const memory = makeMemory("discord");
709
+ const result = await userContextProvider.get(runtime, memory, makeState());
710
+ expect(result.text).toBe("");
711
+ expect(result.data).toEqual({});
712
+ });
713
+
714
+ test("returns empty when service unavailable", async () => {
715
+ const runtime = makeMockRuntime({ service: null });
716
+ const memory = makeMemory("twitch");
717
+ const result = await userContextProvider.get(runtime, memory, makeState());
718
+ expect(result.text).toBe("");
719
+ expect(result.data).toEqual({});
720
+ });
721
+
722
+ test("returns empty when no user info in metadata", async () => {
723
+ const service = makeMockTwitchService({ connected: true });
724
+ const runtime = makeMockRuntime({
725
+ service,
726
+ getService: () => service,
727
+ });
728
+ const memory = makeMemory("twitch");
729
+ const result = await userContextProvider.get(runtime, memory, makeState());
730
+ expect(result.text).toBe("");
731
+ });
732
+
733
+ test("returns user context for broadcaster", async () => {
734
+ const service = makeMockTwitchService({ connected: true });
735
+ const runtime = makeMockRuntime({
736
+ service,
737
+ getService: () => service,
738
+ });
739
+ const memory = {
740
+ content: {
741
+ text: "hello",
742
+ source: "twitch",
743
+ metadata: {
744
+ user: {
745
+ userId: "12345",
746
+ username: "streamer_dude",
747
+ displayName: "Streamer_Dude",
748
+ isModerator: false,
749
+ isBroadcaster: true,
750
+ isVip: false,
751
+ isSubscriber: true,
752
+ badges: new Map(),
753
+ } as TwitchUserInfo,
754
+ },
755
+ },
756
+ } as any;
757
+ const state = makeState({ agentName: "MyBot" });
758
+
759
+ const result = await userContextProvider.get(runtime, memory, state);
760
+
761
+ expect(result.data.userId).toBe("12345");
762
+ expect(result.data.username).toBe("streamer_dude");
763
+ expect(result.data.displayName).toBe("Streamer_Dude");
764
+ expect(result.data.isBroadcaster).toBe(true);
765
+ expect(result.data.isSubscriber).toBe(true);
766
+ expect(result.data.roles).toContain("broadcaster");
767
+ expect(result.data.roles).toContain("subscriber");
768
+ expect(result.values.roleText).toContain("broadcaster");
769
+ expect(result.text).toContain("MyBot");
770
+ expect(result.text).toContain("Streamer_Dude");
771
+ expect(result.text).toContain("broadcaster");
772
+ expect(result.text).toContain("channel owner/broadcaster");
773
+ });
774
+
775
+ test("returns viewer role when user has no special roles", async () => {
776
+ const service = makeMockTwitchService({ connected: true });
777
+ const runtime = makeMockRuntime({
778
+ service,
779
+ getService: () => service,
780
+ });
781
+ const memory = {
782
+ content: {
783
+ text: "hi",
784
+ source: "twitch",
785
+ metadata: {
786
+ user: {
787
+ userId: "99",
788
+ username: "viewer99",
789
+ displayName: "Viewer99",
790
+ isModerator: false,
791
+ isBroadcaster: false,
792
+ isVip: false,
793
+ isSubscriber: false,
794
+ badges: new Map(),
795
+ } as TwitchUserInfo,
796
+ },
797
+ },
798
+ } as any;
799
+
800
+ const result = await userContextProvider.get(runtime, memory, makeState());
801
+
802
+ expect(result.values.roleText).toBe("viewer");
803
+ expect(result.data.roles).toEqual([]);
804
+ });
805
+
806
+ test("returns moderator context text for moderator", async () => {
807
+ const service = makeMockTwitchService({ connected: true });
808
+ const runtime = makeMockRuntime({
809
+ service,
810
+ getService: () => service,
811
+ });
812
+ const memory = {
813
+ content: {
814
+ text: "hi",
815
+ source: "twitch",
816
+ metadata: {
817
+ user: {
818
+ userId: "55",
819
+ username: "modperson",
820
+ displayName: "ModPerson",
821
+ isModerator: true,
822
+ isBroadcaster: false,
823
+ isVip: false,
824
+ isSubscriber: false,
825
+ badges: new Map(),
826
+ } as TwitchUserInfo,
827
+ },
828
+ },
829
+ } as any;
830
+
831
+ const result = await userContextProvider.get(runtime, memory, makeState());
832
+
833
+ expect(result.data.isModerator).toBe(true);
834
+ expect(result.text).toContain("channel moderator");
835
+ expect(result.text).not.toContain("broadcaster");
836
+ });
837
+ });
838
+
839
+ // ===========================================================================
840
+ // 12. Type Construction
841
+ // ===========================================================================
842
+
843
+ describe("Type construction and shapes", () => {
844
+ test("TwitchUserInfo can be constructed with all fields", () => {
845
+ const user: TwitchUserInfo = {
846
+ userId: "123",
847
+ username: "testuser",
848
+ displayName: "TestUser",
849
+ isModerator: true,
850
+ isBroadcaster: false,
851
+ isVip: true,
852
+ isSubscriber: false,
853
+ color: "#FF0000",
854
+ badges: new Map([["moderator", "1"]]),
855
+ };
856
+ expect(user.userId).toBe("123");
857
+ expect(user.color).toBe("#FF0000");
858
+ expect(user.badges.get("moderator")).toBe("1");
859
+ });
860
+
861
+ test("TwitchMessage can be constructed with reply info", () => {
862
+ const msg: TwitchMessage = {
863
+ id: "msg-1",
864
+ channel: "test",
865
+ text: "hello",
866
+ user: {
867
+ userId: "1",
868
+ username: "user1",
869
+ displayName: "User1",
870
+ isModerator: false,
871
+ isBroadcaster: false,
872
+ isVip: false,
873
+ isSubscriber: false,
874
+ badges: new Map(),
875
+ },
876
+ timestamp: new Date(),
877
+ isAction: false,
878
+ isHighlighted: true,
879
+ replyTo: {
880
+ messageId: "parent-1",
881
+ userId: "2",
882
+ username: "user2",
883
+ text: "original",
884
+ },
885
+ };
886
+ expect(msg.replyTo?.messageId).toBe("parent-1");
887
+ expect(msg.isHighlighted).toBe(true);
888
+ });
889
+
890
+ test("TwitchSendResult success shape", () => {
891
+ const res: TwitchSendResult = {
892
+ success: true,
893
+ messageId: "abc-123",
894
+ };
895
+ expect(res.success).toBe(true);
896
+ expect(res.messageId).toBe("abc-123");
897
+ expect(res.error).toBeUndefined();
898
+ });
899
+
900
+ test("TwitchSendResult failure shape", () => {
901
+ const res: TwitchSendResult = {
902
+ success: false,
903
+ error: "not connected",
904
+ };
905
+ expect(res.success).toBe(false);
906
+ expect(res.error).toBe("not connected");
907
+ expect(res.messageId).toBeUndefined();
908
+ });
909
+
910
+ test("TwitchMessageSendOptions is optional fields", () => {
911
+ const opts: TwitchMessageSendOptions = {};
912
+ expect(opts.channel).toBeUndefined();
913
+ expect(opts.replyTo).toBeUndefined();
914
+
915
+ const opts2: TwitchMessageSendOptions = {
916
+ channel: "test",
917
+ replyTo: "msg-1",
918
+ };
919
+ expect(opts2.channel).toBe("test");
920
+ expect(opts2.replyTo).toBe("msg-1");
921
+ });
922
+ });
923
+
924
+ // ===========================================================================
925
+ // 13. TwitchService Static Properties
926
+ // ===========================================================================
927
+
928
+ describe("TwitchService class", () => {
929
+ test("has correct serviceType", () => {
930
+ expect(TwitchService.serviceType).toBe("twitch");
931
+ });
932
+
933
+ test("has static start method", () => {
934
+ expect(typeof TwitchService.start).toBe("function");
935
+ });
936
+
937
+ test("has static stopRuntime method", () => {
938
+ expect(typeof TwitchService.stopRuntime).toBe("function");
939
+ });
940
+ });