@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,526 @@
1
+ /**
2
+ * Twitch setup wizard surface for CLI setup.
3
+ */
4
+
5
+ import { normalizeOptionalAccountId } from "klaw/plugin-sdk/account-id";
6
+ import { getChatChannelMeta, type ChannelPlugin } from "klaw/plugin-sdk/core";
7
+ import {
8
+ formatDocsLink,
9
+ type ChannelSetupAdapter,
10
+ type ChannelSetupDmPolicy,
11
+ type ChannelSetupWizard,
12
+ type KlawConfig,
13
+ type WizardPrompter,
14
+ normalizeAccountId,
15
+ createSetupTranslator,
16
+ } from "klaw/plugin-sdk/setup";
17
+ import {
18
+ DEFAULT_ACCOUNT_ID,
19
+ getAccountConfig,
20
+ listAccountIds,
21
+ resolveDefaultTwitchAccountId,
22
+ resolveTwitchAccountContext,
23
+ } from "./config.js";
24
+ import type { TwitchAccountConfig, TwitchRole } from "./types.js";
25
+ import { isAccountConfigured } from "./utils/twitch.js";
26
+
27
+ const channel = "twitch" as const;
28
+ const t = createSetupTranslator();
29
+ const INVALID_ACCOUNT_ID_MESSAGE = "Invalid Twitch account id";
30
+
31
+ function normalizeRequestedSetupAccountId(accountId: string): string {
32
+ const normalized = normalizeOptionalAccountId(accountId);
33
+ if (!normalized) {
34
+ throw new Error(INVALID_ACCOUNT_ID_MESSAGE);
35
+ }
36
+ return normalized;
37
+ }
38
+
39
+ function resolveSetupAccountId(cfg: KlawConfig, requestedAccountId?: string): string {
40
+ const requested = requestedAccountId?.trim();
41
+ if (requested) {
42
+ return normalizeRequestedSetupAccountId(requested);
43
+ }
44
+
45
+ const preferred = cfg.channels?.twitch?.defaultAccount?.trim();
46
+ return preferred ? normalizeAccountId(preferred) : resolveDefaultTwitchAccountId(cfg);
47
+ }
48
+
49
+ export function setTwitchAccount(
50
+ cfg: KlawConfig,
51
+ account: Partial<TwitchAccountConfig>,
52
+ accountId: string = resolveSetupAccountId(cfg),
53
+ ): KlawConfig {
54
+ const resolvedAccountId = accountId.trim()
55
+ ? normalizeRequestedSetupAccountId(accountId)
56
+ : resolveSetupAccountId(cfg);
57
+ const existing = getAccountConfig(cfg, resolvedAccountId);
58
+ const merged: TwitchAccountConfig = {
59
+ username: account.username ?? existing?.username ?? "",
60
+ accessToken: account.accessToken ?? existing?.accessToken ?? "",
61
+ clientId: account.clientId ?? existing?.clientId ?? "",
62
+ channel: account.channel ?? existing?.channel ?? "",
63
+ enabled: account.enabled ?? existing?.enabled ?? true,
64
+ allowFrom: account.allowFrom ?? existing?.allowFrom,
65
+ allowedRoles: account.allowedRoles ?? existing?.allowedRoles,
66
+ requireMention: account.requireMention ?? existing?.requireMention,
67
+ clientSecret: account.clientSecret ?? existing?.clientSecret,
68
+ refreshToken: account.refreshToken ?? existing?.refreshToken,
69
+ expiresIn: account.expiresIn ?? existing?.expiresIn,
70
+ obtainmentTimestamp: account.obtainmentTimestamp ?? existing?.obtainmentTimestamp,
71
+ };
72
+
73
+ return {
74
+ ...cfg,
75
+ channels: {
76
+ ...cfg.channels,
77
+ twitch: {
78
+ ...((cfg.channels as Record<string, unknown>)?.twitch as
79
+ | Record<string, unknown>
80
+ | undefined),
81
+ enabled: true,
82
+ accounts: {
83
+ ...((
84
+ (cfg.channels as Record<string, unknown>)?.twitch as Record<string, unknown> | undefined
85
+ )?.accounts as Record<string, unknown> | undefined),
86
+ [resolvedAccountId]: merged,
87
+ },
88
+ },
89
+ },
90
+ };
91
+ }
92
+
93
+ async function noteTwitchSetupHelp(prompter: WizardPrompter): Promise<void> {
94
+ await prompter.note(
95
+ [
96
+ t("wizard.twitch.helpRequiresBot"),
97
+ t("wizard.twitch.helpCreateApp"),
98
+ t("wizard.twitch.helpGenerateToken"),
99
+ t("wizard.twitch.helpTokenTools"),
100
+ t("wizard.twitch.helpCopyToken"),
101
+ t("wizard.twitch.helpEnvVars"),
102
+ `Docs: ${formatDocsLink("/channels/twitch", "channels/twitch")}`,
103
+ ].join("\n"),
104
+ t("wizard.twitch.setupTitle"),
105
+ );
106
+ }
107
+
108
+ export async function promptToken(
109
+ prompter: WizardPrompter,
110
+ account: TwitchAccountConfig | null,
111
+ envToken: string | undefined,
112
+ ): Promise<string> {
113
+ const existingToken = account?.accessToken ?? "";
114
+
115
+ if (existingToken && !envToken) {
116
+ const keepToken = await prompter.confirm({
117
+ message: t("wizard.twitch.accessTokenKeep"),
118
+ initialValue: true,
119
+ });
120
+ if (keepToken) {
121
+ return existingToken;
122
+ }
123
+ }
124
+
125
+ return (
126
+ await prompter.text({
127
+ message: t("wizard.twitch.oauthTokenPrompt"),
128
+ initialValue: envToken ?? "",
129
+ validate: (value) => {
130
+ const raw = value?.trim() ?? "";
131
+ if (!raw) {
132
+ return "Required";
133
+ }
134
+ if (!raw.startsWith("oauth:")) {
135
+ return "Token should start with 'oauth:'";
136
+ }
137
+ return undefined;
138
+ },
139
+ })
140
+ ).trim();
141
+ }
142
+
143
+ export async function promptUsername(
144
+ prompter: WizardPrompter,
145
+ account: TwitchAccountConfig | null,
146
+ ): Promise<string> {
147
+ return (
148
+ await prompter.text({
149
+ message: t("wizard.twitch.botUsernamePrompt"),
150
+ initialValue: account?.username ?? "",
151
+ validate: (value) => (value?.trim() ? undefined : "Required"),
152
+ })
153
+ ).trim();
154
+ }
155
+
156
+ export async function promptClientId(
157
+ prompter: WizardPrompter,
158
+ account: TwitchAccountConfig | null,
159
+ ): Promise<string> {
160
+ return (
161
+ await prompter.text({
162
+ message: t("wizard.twitch.clientIdPrompt"),
163
+ initialValue: account?.clientId ?? "",
164
+ validate: (value) => (value?.trim() ? undefined : "Required"),
165
+ })
166
+ ).trim();
167
+ }
168
+
169
+ export async function promptChannelName(
170
+ prompter: WizardPrompter,
171
+ account: TwitchAccountConfig | null,
172
+ ): Promise<string> {
173
+ return (
174
+ await prompter.text({
175
+ message: t("wizard.twitch.channelJoinPrompt"),
176
+ initialValue: account?.channel ?? "",
177
+ validate: (value) => (value?.trim() ? undefined : "Required"),
178
+ })
179
+ ).trim();
180
+ }
181
+
182
+ export async function promptRefreshTokenSetup(
183
+ prompter: WizardPrompter,
184
+ account: TwitchAccountConfig | null,
185
+ ): Promise<{ clientSecret?: string; refreshToken?: string }> {
186
+ const useRefresh = await prompter.confirm({
187
+ message: t("wizard.twitch.refreshTokenPrompt"),
188
+ initialValue: Boolean(account?.clientSecret && account?.refreshToken),
189
+ });
190
+
191
+ if (!useRefresh) {
192
+ return {};
193
+ }
194
+
195
+ const clientSecret =
196
+ (
197
+ await prompter.text({
198
+ message: t("wizard.twitch.clientSecretPrompt"),
199
+ initialValue: account?.clientSecret ?? "",
200
+ validate: (value) => (value?.trim() ? undefined : "Required"),
201
+ })
202
+ ).trim() || undefined;
203
+
204
+ const refreshToken =
205
+ (
206
+ await prompter.text({
207
+ message: t("wizard.twitch.refreshTokenInputPrompt"),
208
+ initialValue: account?.refreshToken ?? "",
209
+ validate: (value) => (value?.trim() ? undefined : "Required"),
210
+ })
211
+ ).trim() || undefined;
212
+
213
+ return { clientSecret, refreshToken };
214
+ }
215
+
216
+ export async function configureWithEnvToken(
217
+ cfg: KlawConfig,
218
+ prompter: WizardPrompter,
219
+ account: TwitchAccountConfig | null,
220
+ envToken: string,
221
+ forceAllowFrom: boolean,
222
+ dmPolicy: ChannelSetupDmPolicy,
223
+ accountId: string = resolveSetupAccountId(cfg),
224
+ ): Promise<{ cfg: KlawConfig } | null> {
225
+ const resolvedAccountId = accountId.trim()
226
+ ? normalizeRequestedSetupAccountId(accountId)
227
+ : resolveSetupAccountId(cfg);
228
+ if (resolvedAccountId !== DEFAULT_ACCOUNT_ID) {
229
+ return null;
230
+ }
231
+
232
+ const useEnv = await prompter.confirm({
233
+ message: t("wizard.twitch.envPrompt"),
234
+ initialValue: true,
235
+ });
236
+ if (!useEnv) {
237
+ return null;
238
+ }
239
+
240
+ const username = await promptUsername(prompter, account);
241
+ const clientId = await promptClientId(prompter, account);
242
+
243
+ const cfgWithAccount = setTwitchAccount(
244
+ cfg,
245
+ {
246
+ username,
247
+ clientId,
248
+ accessToken: envToken,
249
+ enabled: true,
250
+ },
251
+ resolvedAccountId,
252
+ );
253
+
254
+ if (forceAllowFrom && dmPolicy.promptAllowFrom) {
255
+ return {
256
+ cfg: await dmPolicy.promptAllowFrom({
257
+ cfg: cfgWithAccount,
258
+ prompter,
259
+ accountId: resolvedAccountId,
260
+ }),
261
+ };
262
+ }
263
+
264
+ return { cfg: cfgWithAccount };
265
+ }
266
+
267
+ function setTwitchAccessControl(
268
+ cfg: KlawConfig,
269
+ allowedRoles: TwitchRole[],
270
+ requireMention: boolean,
271
+ accountId?: string,
272
+ ): KlawConfig {
273
+ const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
274
+ const account = getAccountConfig(cfg, resolvedAccountId);
275
+ if (!account) {
276
+ return cfg;
277
+ }
278
+
279
+ return setTwitchAccount(
280
+ cfg,
281
+ {
282
+ ...account,
283
+ allowedRoles,
284
+ requireMention,
285
+ },
286
+ resolvedAccountId,
287
+ );
288
+ }
289
+
290
+ function resolveTwitchGroupPolicy(
291
+ cfg: KlawConfig,
292
+ accountId?: string,
293
+ ): "open" | "allowlist" | "disabled" {
294
+ const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
295
+ if (account?.allowedRoles?.includes("all")) {
296
+ return "open";
297
+ }
298
+ if (account?.allowedRoles?.includes("moderator")) {
299
+ return "allowlist";
300
+ }
301
+ return "disabled";
302
+ }
303
+
304
+ function setTwitchGroupPolicy(
305
+ cfg: KlawConfig,
306
+ policy: "open" | "allowlist" | "disabled",
307
+ accountId?: string,
308
+ ): KlawConfig {
309
+ const allowedRoles: TwitchRole[] =
310
+ policy === "open" ? ["all"] : policy === "allowlist" ? ["moderator", "vip"] : [];
311
+ return setTwitchAccessControl(cfg, allowedRoles, true, accountId);
312
+ }
313
+
314
+ const twitchDmPolicy: ChannelSetupDmPolicy = {
315
+ label: "Twitch",
316
+ channel,
317
+ policyKey: "channels.twitch.accounts.default.allowedRoles",
318
+ allowFromKey: "channels.twitch.accounts.default.allowFrom",
319
+ resolveConfigKeys: (cfg, accountId) => {
320
+ const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
321
+ return {
322
+ policyKey: `channels.twitch.accounts.${resolvedAccountId}.allowedRoles`,
323
+ allowFromKey: `channels.twitch.accounts.${resolvedAccountId}.allowFrom`,
324
+ };
325
+ },
326
+ getCurrent: (cfg, accountId) => {
327
+ const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
328
+ if (account?.allowedRoles?.includes("all")) {
329
+ return "open";
330
+ }
331
+ if (account?.allowFrom && account.allowFrom.length > 0) {
332
+ return "allowlist";
333
+ }
334
+ return "disabled";
335
+ },
336
+ setPolicy: (cfg, policy, accountId) => {
337
+ const allowedRoles: TwitchRole[] =
338
+ policy === "open" ? ["all"] : policy === "allowlist" ? [] : ["moderator"];
339
+ return setTwitchAccessControl(cfg, allowedRoles, true, accountId);
340
+ },
341
+ promptAllowFrom: async ({ cfg, prompter, accountId }) => {
342
+ const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
343
+ const account = getAccountConfig(cfg, resolvedAccountId);
344
+ const existingAllowFrom = account?.allowFrom ?? [];
345
+
346
+ const entry = await prompter.text({
347
+ message: t("wizard.twitch.allowFromPrompt"),
348
+ placeholder: "123456789",
349
+ initialValue: existingAllowFrom[0] || undefined,
350
+ });
351
+
352
+ const allowFrom = (entry ?? "")
353
+ .split(/[\n,;]+/g)
354
+ .map((s) => s.trim())
355
+ .filter(Boolean);
356
+
357
+ return setTwitchAccount(
358
+ cfg,
359
+ {
360
+ ...(account ?? undefined),
361
+ allowFrom,
362
+ },
363
+ resolvedAccountId,
364
+ );
365
+ },
366
+ };
367
+
368
+ const twitchGroupAccess: NonNullable<ChannelSetupWizard["groupAccess"]> = {
369
+ label: "Twitch chat",
370
+ placeholder: "",
371
+ skipAllowlistEntries: true,
372
+ currentPolicy: ({ cfg, accountId }) => resolveTwitchGroupPolicy(cfg, accountId),
373
+ currentEntries: ({ cfg, accountId }) => {
374
+ const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
375
+ return account?.allowFrom ?? [];
376
+ },
377
+ updatePrompt: ({ cfg, accountId }) => {
378
+ const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
379
+ return Boolean(account?.allowedRoles?.length || account?.allowFrom?.length);
380
+ },
381
+ setPolicy: ({ cfg, accountId, policy }) => setTwitchGroupPolicy(cfg, policy, accountId),
382
+ resolveAllowlist: async () => [],
383
+ applyAllowlist: ({ cfg }) => cfg,
384
+ };
385
+
386
+ export const twitchSetupAdapter: ChannelSetupAdapter = {
387
+ resolveAccountId: ({ cfg }) => resolveSetupAccountId(cfg),
388
+ applyAccountConfig: ({ cfg, accountId }) =>
389
+ setTwitchAccount(
390
+ cfg,
391
+ {
392
+ enabled: true,
393
+ },
394
+ accountId,
395
+ ),
396
+ };
397
+
398
+ export const twitchSetupWizard: ChannelSetupWizard = {
399
+ channel,
400
+ resolveAccountIdForConfigure: ({ cfg, accountOverride }) =>
401
+ resolveSetupAccountId(cfg, accountOverride),
402
+ resolveShouldPromptAccountIds: () => false,
403
+ status: {
404
+ configuredLabel: t("wizard.channels.statusConfigured"),
405
+ unconfiguredLabel: t("wizard.channels.statusNeedsUsernameTokenClientId"),
406
+ configuredHint: t("wizard.channels.statusConfigured"),
407
+ unconfiguredHint: t("wizard.channels.statusNeedsSetup"),
408
+ resolveConfigured: ({ cfg, accountId }) => {
409
+ return resolveTwitchAccountContext(cfg, resolveSetupAccountId(cfg, accountId)).configured;
410
+ },
411
+ resolveStatusLines: ({ cfg, accountId }) => {
412
+ const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
413
+ const configured = resolveTwitchAccountContext(cfg, resolvedAccountId).configured;
414
+ return [
415
+ `Twitch${resolvedAccountId !== DEFAULT_ACCOUNT_ID ? ` (${resolvedAccountId})` : ""}: ${
416
+ configured
417
+ ? t("wizard.channels.statusConfigured")
418
+ : t("wizard.channels.statusNeedsUsernameTokenClientId")
419
+ }`,
420
+ ];
421
+ },
422
+ },
423
+ credentials: [],
424
+ finalize: async ({ cfg, accountId: requestedAccountId, prompter, forceAllowFrom }) => {
425
+ const accountId = resolveSetupAccountId(cfg, requestedAccountId);
426
+ const account = getAccountConfig(cfg, accountId);
427
+
428
+ if (!account || !isAccountConfigured(account)) {
429
+ await noteTwitchSetupHelp(prompter);
430
+ }
431
+
432
+ const envToken = process.env.KLAW_TWITCH_ACCESS_TOKEN?.trim();
433
+
434
+ if (accountId === DEFAULT_ACCOUNT_ID && envToken && !account?.accessToken) {
435
+ const envResult = await configureWithEnvToken(
436
+ cfg,
437
+ prompter,
438
+ account,
439
+ envToken,
440
+ forceAllowFrom,
441
+ twitchDmPolicy,
442
+ accountId,
443
+ );
444
+ if (envResult) {
445
+ return envResult;
446
+ }
447
+ }
448
+
449
+ const username = await promptUsername(prompter, account);
450
+ const token = await promptToken(prompter, account, envToken);
451
+ const clientId = await promptClientId(prompter, account);
452
+ const channelName = await promptChannelName(prompter, account);
453
+ const { clientSecret, refreshToken } = await promptRefreshTokenSetup(prompter, account);
454
+
455
+ const cfgWithAccount = setTwitchAccount(
456
+ cfg,
457
+ {
458
+ username,
459
+ accessToken: token,
460
+ clientId,
461
+ channel: channelName,
462
+ clientSecret,
463
+ refreshToken,
464
+ enabled: true,
465
+ },
466
+ accountId,
467
+ );
468
+
469
+ const cfgWithAllowFrom =
470
+ forceAllowFrom && twitchDmPolicy.promptAllowFrom
471
+ ? await twitchDmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter, accountId })
472
+ : cfgWithAccount;
473
+
474
+ return { cfg: cfgWithAllowFrom };
475
+ },
476
+ dmPolicy: twitchDmPolicy,
477
+ groupAccess: twitchGroupAccess,
478
+ disable: (cfg) => {
479
+ const twitch = (cfg.channels as Record<string, unknown>)?.twitch as
480
+ | Record<string, unknown>
481
+ | undefined;
482
+ return {
483
+ ...cfg,
484
+ channels: {
485
+ ...cfg.channels,
486
+ twitch: { ...twitch, enabled: false },
487
+ },
488
+ };
489
+ },
490
+ };
491
+
492
+ type ResolvedTwitchAccount = TwitchAccountConfig & { accountId?: string | null };
493
+
494
+ export const twitchSetupPlugin: ChannelPlugin<ResolvedTwitchAccount> = {
495
+ id: channel,
496
+ meta: getChatChannelMeta(channel),
497
+ capabilities: {
498
+ chatTypes: ["group"],
499
+ },
500
+ config: {
501
+ listAccountIds: (cfg) => listAccountIds(cfg),
502
+ resolveAccount: (cfg, accountId) => {
503
+ const resolvedAccountId = normalizeAccountId(accountId ?? resolveDefaultTwitchAccountId(cfg));
504
+ const account = getAccountConfig(cfg, resolvedAccountId);
505
+ if (!account) {
506
+ return {
507
+ accountId: resolvedAccountId,
508
+ username: "",
509
+ accessToken: "",
510
+ clientId: "",
511
+ channel: "",
512
+ enabled: false,
513
+ };
514
+ }
515
+ return {
516
+ accountId: resolvedAccountId,
517
+ ...account,
518
+ };
519
+ },
520
+ defaultAccountId: (cfg) => resolveDefaultTwitchAccountId(cfg),
521
+ isConfigured: (account, cfg) => resolveTwitchAccountContext(cfg, account?.accountId).configured,
522
+ isEnabled: (account) => account.enabled !== false,
523
+ },
524
+ setup: twitchSetupAdapter,
525
+ setupWizard: twitchSetupWizard,
526
+ };