@kodelyth/twitch 2026.5.39 → 2026.5.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +89 -0
  2. package/api.ts +21 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/dist/api.js +3 -0
  5. package/dist/channel-plugin-api.js +2 -0
  6. package/dist/index.js +18 -0
  7. package/dist/monitor-j1GtQVBd.js +337 -0
  8. package/dist/plugin-BMzrFFQR.js +1285 -0
  9. package/dist/runtime-CwXHrWo3.js +8 -0
  10. package/dist/runtime-api.js +1 -0
  11. package/dist/setup-entry.js +11 -0
  12. package/dist/setup-plugin-api.js +2 -0
  13. package/dist/setup-surface-CovnRl9R.js +527 -0
  14. package/index.test.ts +13 -0
  15. package/index.ts +16 -0
  16. package/klaw.plugin.json +2 -219
  17. package/package.json +3 -3
  18. package/runtime-api.ts +22 -0
  19. package/setup-entry.ts +9 -0
  20. package/setup-plugin-api.ts +3 -0
  21. package/src/access-control.test.ts +373 -0
  22. package/src/access-control.ts +195 -0
  23. package/src/actions.test.ts +75 -0
  24. package/src/actions.ts +175 -0
  25. package/src/client-manager-registry.ts +87 -0
  26. package/src/config-schema.test.ts +46 -0
  27. package/src/config-schema.ts +88 -0
  28. package/src/config.test.ts +233 -0
  29. package/src/config.ts +177 -0
  30. package/src/monitor.ts +311 -0
  31. package/src/outbound.test.ts +572 -0
  32. package/src/outbound.ts +242 -0
  33. package/src/plugin.lifecycle.test.ts +86 -0
  34. package/src/plugin.live.test.ts +120 -0
  35. package/src/plugin.test.ts +77 -0
  36. package/src/plugin.ts +220 -0
  37. package/src/probe.test.ts +196 -0
  38. package/src/probe.ts +130 -0
  39. package/src/resolver.ts +139 -0
  40. package/src/runtime.ts +9 -0
  41. package/src/send.test.ts +342 -0
  42. package/src/send.ts +191 -0
  43. package/src/setup-surface.test.ts +529 -0
  44. package/src/setup-surface.ts +526 -0
  45. package/src/status.test.ts +298 -0
  46. package/src/status.ts +179 -0
  47. package/src/test-fixtures.ts +30 -0
  48. package/src/token.test.ts +198 -0
  49. package/src/token.ts +93 -0
  50. package/src/twitch-client.test.ts +574 -0
  51. package/src/twitch-client.ts +276 -0
  52. package/src/types.ts +104 -0
  53. package/src/utils/markdown.ts +98 -0
  54. package/src/utils/twitch.ts +81 -0
  55. package/test/setup.ts +7 -0
  56. package/tsconfig.json +16 -0
  57. package/api.js +0 -7
  58. package/channel-plugin-api.js +0 -7
  59. package/index.js +0 -7
  60. package/runtime-api.js +0 -7
  61. package/setup-entry.js +0 -7
  62. package/setup-plugin-api.js +0 -7
@@ -0,0 +1,529 @@
1
+ /**
2
+ * Tests for setup-surface.ts helpers.
3
+ *
4
+ * Tests cover:
5
+ * - promptToken helper
6
+ * - promptUsername helper
7
+ * - promptClientId helper
8
+ * - promptChannelName helper
9
+ * - promptRefreshTokenSetup helper
10
+ * - configureWithEnvToken helper
11
+ * - setTwitchAccount config updates
12
+ */
13
+
14
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15
+ import type { WizardPrompter } from "../api.js";
16
+ import {
17
+ configureWithEnvToken,
18
+ promptChannelName,
19
+ promptClientId,
20
+ promptRefreshTokenSetup,
21
+ promptToken,
22
+ promptUsername,
23
+ setTwitchAccount,
24
+ twitchSetupPlugin,
25
+ twitchSetupWizard,
26
+ } from "./setup-surface.js";
27
+ import type { TwitchAccountConfig } from "./types.js";
28
+
29
+ // Mock the helpers we're testing
30
+ const mockPromptText = vi.fn();
31
+ const mockPromptConfirm = vi.fn();
32
+ const mockPromptNote = vi.fn();
33
+ const mockPrompter: WizardPrompter = {
34
+ text: mockPromptText,
35
+ confirm: mockPromptConfirm,
36
+ note: mockPromptNote,
37
+ } as unknown as WizardPrompter;
38
+ const originalEnvToken = process.env.KLAW_TWITCH_ACCESS_TOKEN;
39
+
40
+ const mockAccount: TwitchAccountConfig = {
41
+ username: "testbot",
42
+ accessToken: "oauth:test123",
43
+ clientId: "test-client-id",
44
+ channel: "#testchannel",
45
+ };
46
+
47
+ function requireFirstTextPromptArgs(): {
48
+ message?: string;
49
+ initialValue?: string;
50
+ validate?: (value: string) => string | undefined;
51
+ } {
52
+ const [call] = mockPromptText.mock.calls;
53
+ if (!call || typeof call[0] !== "object" || call[0] === null || Array.isArray(call[0])) {
54
+ throw new Error("expected Twitch text prompt args");
55
+ }
56
+ return call[0] as {
57
+ message?: string;
58
+ initialValue?: string;
59
+ validate?: (value: string) => string | undefined;
60
+ };
61
+ }
62
+
63
+ describe("setup surface helpers", () => {
64
+ beforeEach(() => {
65
+ vi.clearAllMocks();
66
+ });
67
+
68
+ afterEach(() => {
69
+ if (originalEnvToken === undefined) {
70
+ delete process.env.KLAW_TWITCH_ACCESS_TOKEN;
71
+ } else {
72
+ process.env.KLAW_TWITCH_ACCESS_TOKEN = originalEnvToken;
73
+ }
74
+ // Don't restoreAllMocks as it breaks module-level mocks
75
+ });
76
+
77
+ describe("promptToken", () => {
78
+ it("should return existing token when user confirms to keep it", async () => {
79
+ mockPromptConfirm.mockResolvedValue(true);
80
+
81
+ const result = await promptToken(mockPrompter, mockAccount, undefined);
82
+
83
+ expect(result).toBe("oauth:test123");
84
+ expect(mockPromptConfirm).toHaveBeenCalledWith({
85
+ message: "Access token already configured. Keep it?",
86
+ initialValue: true,
87
+ });
88
+ expect(mockPromptText).not.toHaveBeenCalled();
89
+ });
90
+
91
+ it("should validate token format", async () => {
92
+ // Set up mocks - user doesn't want to keep existing token
93
+ mockPromptConfirm.mockResolvedValueOnce(false);
94
+
95
+ // Track how many times promptText is called
96
+ let promptTextCallCount = 0;
97
+ let capturedValidate: ((value: string) => string | undefined) | undefined;
98
+
99
+ mockPromptText.mockImplementationOnce((_args) => {
100
+ promptTextCallCount++;
101
+ // Capture the validate function from the first argument
102
+ if (_args?.validate) {
103
+ capturedValidate = _args.validate;
104
+ }
105
+ return Promise.resolve("oauth:test123");
106
+ });
107
+
108
+ // Call promptToken
109
+ const result = await promptToken(mockPrompter, mockAccount, undefined);
110
+
111
+ // Verify promptText was called
112
+ expect(promptTextCallCount).toBe(1);
113
+ expect(result).toBe("oauth:test123");
114
+
115
+ // Test the validate function
116
+ if (!capturedValidate) {
117
+ throw new Error("promptToken validate callback was not captured");
118
+ }
119
+ expect(capturedValidate("")).toBe("Required");
120
+ expect(capturedValidate("notoauth")).toBe("Token should start with 'oauth:'");
121
+ expect(capturedValidate("oauth:goodtoken")).toBeUndefined();
122
+ });
123
+ });
124
+
125
+ describe("promptUsername", () => {
126
+ it("should prompt for username with validation", async () => {
127
+ mockPromptText.mockResolvedValue("mybot");
128
+
129
+ const result = await promptUsername(mockPrompter, null);
130
+
131
+ expect(result).toBe("mybot");
132
+ const promptArgs = requireFirstTextPromptArgs();
133
+ expect(promptArgs.message).toBe("Twitch bot username");
134
+ expect(promptArgs.initialValue).toBe("");
135
+ expect(promptArgs.validate?.("")).toBe("Required");
136
+ expect(promptArgs.validate?.("mybot")).toBeUndefined();
137
+ });
138
+ });
139
+
140
+ describe("promptClientId", () => {
141
+ it("should prompt for client ID with validation", async () => {
142
+ mockPromptText.mockResolvedValue("abc123xyz");
143
+
144
+ const result = await promptClientId(mockPrompter, null);
145
+
146
+ expect(result).toBe("abc123xyz");
147
+ const promptArgs = requireFirstTextPromptArgs();
148
+ expect(promptArgs.message).toBe("Twitch Client ID");
149
+ expect(promptArgs.initialValue).toBe("");
150
+ expect(promptArgs.validate?.("")).toBe("Required");
151
+ expect(promptArgs.validate?.("abc123xyz")).toBeUndefined();
152
+ });
153
+ });
154
+
155
+ describe("promptChannelName", () => {
156
+ it("should require a non-empty channel name", async () => {
157
+ mockPromptText.mockResolvedValue("");
158
+
159
+ await promptChannelName(mockPrompter, null);
160
+
161
+ const { validate } = requireFirstTextPromptArgs();
162
+ expect(validate?.("")).toBe("Required");
163
+ expect(validate?.(" ")).toBe("Required");
164
+ expect(validate?.("#chan")).toBeUndefined();
165
+ });
166
+ });
167
+
168
+ describe("promptRefreshTokenSetup", () => {
169
+ it("should return empty object when user declines", async () => {
170
+ mockPromptConfirm.mockResolvedValue(false);
171
+
172
+ const result = await promptRefreshTokenSetup(mockPrompter, mockAccount);
173
+
174
+ expect(result).toStrictEqual({});
175
+ expect(mockPromptConfirm).toHaveBeenCalledWith({
176
+ message: "Enable automatic token refresh (requires client secret and refresh token)?",
177
+ initialValue: false,
178
+ });
179
+ });
180
+
181
+ it("should prompt for credentials when user accepts", async () => {
182
+ mockPromptConfirm
183
+ .mockResolvedValueOnce(true) // First call: useRefresh
184
+ .mockResolvedValueOnce("secret123") // clientSecret
185
+ .mockResolvedValueOnce("refresh123"); // refreshToken
186
+
187
+ mockPromptText.mockResolvedValueOnce("secret123").mockResolvedValueOnce("refresh123");
188
+
189
+ const result = await promptRefreshTokenSetup(mockPrompter, null);
190
+
191
+ expect(result).toEqual({
192
+ clientSecret: "secret123",
193
+ refreshToken: "refresh123",
194
+ });
195
+ });
196
+ });
197
+
198
+ describe("configureWithEnvToken", () => {
199
+ it("should prompt for username and clientId when using env token", async () => {
200
+ // Reset and set up mocks - user accepts env token
201
+ mockPromptConfirm.mockReset().mockResolvedValue(true as never);
202
+
203
+ // Set up mocks for username and clientId prompts
204
+ mockPromptText
205
+ .mockReset()
206
+ .mockResolvedValueOnce("testbot" as never)
207
+ .mockResolvedValueOnce("test-client-id" as never);
208
+
209
+ const result = await configureWithEnvToken(
210
+ {} as Parameters<typeof configureWithEnvToken>[0],
211
+ mockPrompter,
212
+ null,
213
+ "oauth:fromenv",
214
+ false,
215
+ {} as Parameters<typeof configureWithEnvToken>[5],
216
+ );
217
+
218
+ // Should return config with username and clientId
219
+ if (!result) {
220
+ throw new Error("expected Twitch env-token setup result");
221
+ }
222
+ const defaultAccount = result.cfg.channels?.twitch?.accounts?.default as
223
+ | { username?: string; clientId?: string }
224
+ | undefined;
225
+ expect(defaultAccount?.username).toBe("testbot");
226
+ expect(defaultAccount?.clientId).toBe("test-client-id");
227
+ });
228
+
229
+ it("skips env-token shortcut for non-default accounts", async () => {
230
+ mockPromptConfirm.mockReset().mockResolvedValue(true as never);
231
+ mockPromptText
232
+ .mockReset()
233
+ .mockResolvedValueOnce("secondary-bot" as never)
234
+ .mockResolvedValueOnce("secondary-client" as never);
235
+
236
+ const result = await configureWithEnvToken(
237
+ {
238
+ channels: {
239
+ twitch: {
240
+ defaultAccount: "secondary",
241
+ },
242
+ },
243
+ } as Parameters<typeof configureWithEnvToken>[0],
244
+ mockPrompter,
245
+ null,
246
+ "oauth:fromenv",
247
+ false,
248
+ {} as Parameters<typeof configureWithEnvToken>[5],
249
+ );
250
+
251
+ expect(result).toBeNull();
252
+ expect(mockPromptConfirm).not.toHaveBeenCalled();
253
+ expect(mockPromptText).not.toHaveBeenCalled();
254
+ });
255
+ });
256
+
257
+ describe("defaultAccount setup resolution", () => {
258
+ it("reports status for the configured default account", () => {
259
+ const lines = twitchSetupWizard.status?.resolveStatusLines?.({
260
+ cfg: {
261
+ channels: {
262
+ twitch: {
263
+ defaultAccount: "secondary",
264
+ accounts: {
265
+ secondary: {
266
+ username: "secondary-bot",
267
+ accessToken: "oauth:secondary",
268
+ clientId: "secondary-client",
269
+ channel: "#secondary",
270
+ },
271
+ },
272
+ },
273
+ },
274
+ },
275
+ } as never);
276
+
277
+ expect(lines).toEqual(["Twitch (secondary): configured"]);
278
+ });
279
+
280
+ it("reports status for the requested account override", () => {
281
+ const lines = twitchSetupWizard.status?.resolveStatusLines?.({
282
+ cfg: {
283
+ channels: {
284
+ twitch: {
285
+ accounts: {
286
+ default: {
287
+ username: "default-bot",
288
+ accessToken: "oauth:default",
289
+ clientId: "default-client",
290
+ channel: "#default",
291
+ },
292
+ secondary: {
293
+ username: "secondary-bot",
294
+ accessToken: "oauth:secondary",
295
+ clientId: "secondary-client",
296
+ channel: "#secondary",
297
+ },
298
+ },
299
+ },
300
+ },
301
+ },
302
+ accountId: "secondary",
303
+ configured: true,
304
+ } as never);
305
+
306
+ expect(lines).toEqual(["Twitch (secondary): configured"]);
307
+ });
308
+
309
+ it("reports env-token default account setup as configured", async () => {
310
+ process.env.KLAW_TWITCH_ACCESS_TOKEN = "oauth:fromenv";
311
+
312
+ const cfg = {
313
+ channels: {
314
+ twitch: {
315
+ accounts: {
316
+ default: {
317
+ username: "env-bot",
318
+ accessToken: "",
319
+ clientId: "env-client",
320
+ channel: "#env",
321
+ },
322
+ },
323
+ },
324
+ },
325
+ } as Parameters<NonNullable<typeof twitchSetupWizard.status>["resolveConfigured"]>[0]["cfg"];
326
+
327
+ expect(twitchSetupWizard.status?.resolveConfigured({ cfg })).toBe(true);
328
+ const account = twitchSetupPlugin.config.resolveAccount(cfg, "default");
329
+ expect(await twitchSetupPlugin.config.isConfigured?.(account, cfg)).toBe(true);
330
+ });
331
+ });
332
+
333
+ describe("setup wizard account routing", () => {
334
+ type FinalizeArgs = Parameters<NonNullable<typeof twitchSetupWizard.finalize>>[0];
335
+
336
+ async function finalizeTwitchSetupForAccount(cfg: FinalizeArgs["cfg"]) {
337
+ return await twitchSetupWizard.finalize?.({
338
+ cfg,
339
+ accountId: "secondary",
340
+ credentialValues: {},
341
+ runtime: {} as FinalizeArgs["runtime"],
342
+ prompter: mockPrompter,
343
+ options: {},
344
+ forceAllowFrom: false,
345
+ });
346
+ }
347
+
348
+ it("rejects reserved account ids before using them as config keys", () => {
349
+ expect(() =>
350
+ setTwitchAccount(
351
+ {} as Parameters<typeof setTwitchAccount>[0],
352
+ {
353
+ username: "reserved-bot",
354
+ accessToken: "oauth:reserved",
355
+ clientId: "reserved-client",
356
+ channel: "#reserved",
357
+ },
358
+ "__proto__",
359
+ ),
360
+ ).toThrow("Invalid Twitch account id");
361
+
362
+ expect(Object.prototype).not.toHaveProperty("username");
363
+ });
364
+
365
+ it("rejects reserved account ids before env-token writes", async () => {
366
+ await expect(
367
+ configureWithEnvToken(
368
+ {} as Parameters<typeof configureWithEnvToken>[0],
369
+ mockPrompter,
370
+ null,
371
+ "oauth:fromenv",
372
+ false,
373
+ {} as Parameters<typeof configureWithEnvToken>[5],
374
+ "__proto__",
375
+ ),
376
+ ).rejects.toThrow("Invalid Twitch account id");
377
+
378
+ expect(mockPromptConfirm).not.toHaveBeenCalled();
379
+ });
380
+
381
+ it("normalizes account ids before rendering status lines", () => {
382
+ expect(
383
+ twitchSetupWizard.status?.resolveStatusLines?.({
384
+ cfg: {},
385
+ accountId: "Alerts\r\n\u001b[31m",
386
+ configured: false,
387
+ } as never),
388
+ ).toEqual(["Twitch (alerts-31m): needs username, token, and clientId"]);
389
+ });
390
+
391
+ it("reports account-scoped DM policy config keys", () => {
392
+ expect(
393
+ twitchSetupWizard.dmPolicy?.resolveConfigKeys?.(
394
+ {
395
+ channels: {
396
+ twitch: {
397
+ defaultAccount: "secondary",
398
+ },
399
+ },
400
+ } as Parameters<
401
+ NonNullable<NonNullable<typeof twitchSetupWizard.dmPolicy>["resolveConfigKeys"]>
402
+ >[0],
403
+ undefined,
404
+ ),
405
+ ).toEqual({
406
+ policyKey: "channels.twitch.accounts.secondary.allowedRoles",
407
+ allowFromKey: "channels.twitch.accounts.secondary.allowFrom",
408
+ });
409
+
410
+ expect(twitchSetupWizard.dmPolicy?.resolveConfigKeys?.({} as never, "alerts")).toEqual({
411
+ policyKey: "channels.twitch.accounts.alerts.allowedRoles",
412
+ allowFromKey: "channels.twitch.accounts.alerts.allowFrom",
413
+ });
414
+ });
415
+
416
+ it("writes to the requested account when defaultAccount is not created yet", async () => {
417
+ mockPromptText
418
+ .mockReset()
419
+ .mockResolvedValueOnce("secondary-bot" as never)
420
+ .mockResolvedValueOnce("oauth:secondary" as never)
421
+ .mockResolvedValueOnce("secondary-client" as never)
422
+ .mockResolvedValueOnce("#secondary" as never);
423
+ mockPromptConfirm.mockReset().mockResolvedValue(false as never);
424
+
425
+ const result = await finalizeTwitchSetupForAccount({
426
+ channels: {
427
+ twitch: {
428
+ defaultAccount: "secondary",
429
+ accounts: {
430
+ default: {
431
+ username: "default-bot",
432
+ accessToken: "oauth:default",
433
+ clientId: "default-client",
434
+ channel: "#default",
435
+ },
436
+ },
437
+ },
438
+ },
439
+ } as FinalizeArgs["cfg"]);
440
+
441
+ const twitch = result?.cfg?.channels?.twitch;
442
+ expect(twitch?.accounts?.secondary?.username).toBe("secondary-bot");
443
+ expect(twitch?.accounts?.secondary?.accessToken).toBe("oauth:secondary");
444
+ expect(twitch?.accounts?.default?.username).toBe("default-bot");
445
+ });
446
+
447
+ it("persists a token instead of using env-token shortcut for non-default finalize", async () => {
448
+ process.env.KLAW_TWITCH_ACCESS_TOKEN = "oauth:fromenv";
449
+ mockPromptText
450
+ .mockReset()
451
+ .mockResolvedValueOnce("secondary-bot" as never)
452
+ .mockResolvedValueOnce("oauth:persisted" as never)
453
+ .mockResolvedValueOnce("secondary-client" as never)
454
+ .mockResolvedValueOnce("#secondary" as never);
455
+ mockPromptConfirm.mockReset().mockResolvedValue(false as never);
456
+
457
+ const result = await finalizeTwitchSetupForAccount({
458
+ channels: {
459
+ twitch: {
460
+ accounts: {},
461
+ },
462
+ },
463
+ } as FinalizeArgs["cfg"]);
464
+
465
+ const twitch = result?.cfg?.channels?.twitch;
466
+ expect(twitch?.accounts?.secondary?.accessToken).toBe("oauth:persisted");
467
+ expect(mockPromptConfirm).toHaveBeenCalledTimes(1);
468
+ expect(mockPromptConfirm).toHaveBeenCalledWith({
469
+ message: "Enable automatic token refresh (requires client secret and refresh token)?",
470
+ initialValue: false,
471
+ });
472
+ });
473
+ });
474
+
475
+ describe("setup-only plugin config", () => {
476
+ it("lists all configured Twitch accounts", () => {
477
+ const cfg = {
478
+ channels: {
479
+ twitch: {
480
+ defaultAccount: "secondary",
481
+ accounts: {
482
+ default: {
483
+ username: "default-bot",
484
+ accessToken: "oauth:default",
485
+ clientId: "default-client",
486
+ channel: "#default",
487
+ },
488
+ secondary: {
489
+ username: "secondary-bot",
490
+ accessToken: "oauth:secondary",
491
+ clientId: "secondary-client",
492
+ channel: "#secondary",
493
+ },
494
+ },
495
+ },
496
+ },
497
+ } as Parameters<typeof twitchSetupPlugin.config.listAccountIds>[0];
498
+
499
+ expect(twitchSetupPlugin.config.listAccountIds(cfg)).toEqual(["default", "secondary"]);
500
+ expect(twitchSetupPlugin.config.defaultAccountId?.(cfg)).toBe("secondary");
501
+ });
502
+
503
+ it("normalizes exposed account ids", () => {
504
+ const cfg = {
505
+ channels: {
506
+ twitch: {
507
+ accounts: {
508
+ Secondary: {
509
+ username: "secondary-bot",
510
+ accessToken: "oauth:secondary",
511
+ clientId: "secondary-client",
512
+ channel: "#secondary",
513
+ },
514
+ },
515
+ },
516
+ },
517
+ } as Parameters<typeof twitchSetupPlugin.config.listAccountIds>[0];
518
+
519
+ expect(twitchSetupPlugin.config.listAccountIds(cfg)).toEqual(["secondary"]);
520
+ expect(twitchSetupPlugin.config.defaultAccountId?.(cfg)).toBe("secondary");
521
+ expect(twitchSetupPlugin.config.resolveAccount(cfg, "SECONDARY\r\n").accountId).toBe(
522
+ "secondary",
523
+ );
524
+ expect(twitchSetupPlugin.config.resolveAccount(cfg, "SECONDARY\r\n").username).toBe(
525
+ "secondary-bot",
526
+ );
527
+ });
528
+ });
529
+ });