@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,233 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ getAccountConfig,
4
+ listAccountIds,
5
+ resolveDefaultTwitchAccountId,
6
+ resolveTwitchAccountContext,
7
+ } from "./config.js";
8
+
9
+ describe("getAccountConfig", () => {
10
+ const mockMultiAccountConfig = {
11
+ channels: {
12
+ twitch: {
13
+ accounts: {
14
+ default: {
15
+ username: "testbot",
16
+ accessToken: "oauth:test123",
17
+ },
18
+ secondary: {
19
+ username: "secondbot",
20
+ accessToken: "oauth:secondary",
21
+ },
22
+ },
23
+ },
24
+ },
25
+ };
26
+
27
+ const mockSimplifiedConfig = {
28
+ channels: {
29
+ twitch: {
30
+ username: "testbot",
31
+ accessToken: "oauth:test123",
32
+ },
33
+ },
34
+ };
35
+
36
+ it("returns account config for valid account ID (multi-account)", () => {
37
+ const result = getAccountConfig(mockMultiAccountConfig, "default");
38
+
39
+ expect(result?.username).toBe("testbot");
40
+ });
41
+
42
+ it("returns account config for default account (simplified config)", () => {
43
+ const result = getAccountConfig(mockSimplifiedConfig, "default");
44
+
45
+ expect(result?.username).toBe("testbot");
46
+ });
47
+
48
+ it("returns non-default account from multi-account config", () => {
49
+ const result = getAccountConfig(mockMultiAccountConfig, "secondary");
50
+
51
+ expect(result?.username).toBe("secondbot");
52
+ });
53
+
54
+ it("normalizes account ids without reading inherited account properties", () => {
55
+ const accounts = Object.create({
56
+ inherited: {
57
+ username: "inherited-bot",
58
+ accessToken: "oauth:inherited",
59
+ },
60
+ }) as Record<string, unknown>;
61
+ accounts.Secondary = {
62
+ username: "secondbot",
63
+ accessToken: "oauth:secondary",
64
+ };
65
+
66
+ const cfg = {
67
+ channels: {
68
+ twitch: {
69
+ accounts,
70
+ },
71
+ },
72
+ };
73
+
74
+ expect(getAccountConfig(cfg, "SECONDARY\r\n")).toEqual({
75
+ username: "secondbot",
76
+ accessToken: "oauth:secondary",
77
+ });
78
+ expect(getAccountConfig(cfg, "inherited")).toBeNull();
79
+ });
80
+
81
+ it("returns null for non-existent account ID", () => {
82
+ const result = getAccountConfig(mockMultiAccountConfig, "nonexistent");
83
+
84
+ expect(result).toBeNull();
85
+ });
86
+
87
+ it("returns null when core config is null", () => {
88
+ const result = getAccountConfig(null, "default");
89
+
90
+ expect(result).toBeNull();
91
+ });
92
+
93
+ it("returns null when core config is undefined", () => {
94
+ const result = getAccountConfig(undefined, "default");
95
+
96
+ expect(result).toBeNull();
97
+ });
98
+
99
+ it("returns null when channels are not defined", () => {
100
+ const result = getAccountConfig({}, "default");
101
+
102
+ expect(result).toBeNull();
103
+ });
104
+
105
+ it("returns null when twitch is not defined", () => {
106
+ const result = getAccountConfig({ channels: {} }, "default");
107
+
108
+ expect(result).toBeNull();
109
+ });
110
+
111
+ it("returns null when accounts are not defined", () => {
112
+ const result = getAccountConfig({ channels: { twitch: {} } }, "default");
113
+
114
+ expect(result).toBeNull();
115
+ });
116
+ });
117
+
118
+ describe("listAccountIds", () => {
119
+ it("includes the implicit default account from simplified config", () => {
120
+ expect(
121
+ listAccountIds({
122
+ channels: {
123
+ twitch: {
124
+ username: "testbot",
125
+ accessToken: "oauth:test123",
126
+ },
127
+ },
128
+ } as Parameters<typeof listAccountIds>[0]),
129
+ ).toEqual(["default"]);
130
+ });
131
+
132
+ it("combines explicit accounts with the implicit default account once", () => {
133
+ expect(
134
+ listAccountIds({
135
+ channels: {
136
+ twitch: {
137
+ username: "testbot",
138
+ accounts: {
139
+ default: { username: "testbot" },
140
+ secondary: { username: "secondbot" },
141
+ },
142
+ },
143
+ },
144
+ } as Parameters<typeof listAccountIds>[0]),
145
+ ).toEqual(["default", "secondary"]);
146
+ });
147
+
148
+ it("normalizes configured account ids", () => {
149
+ expect(
150
+ listAccountIds({
151
+ channels: {
152
+ twitch: {
153
+ accounts: {
154
+ Secondary: { username: "secondbot" },
155
+ "Alerts\r\n\u001b[31m": { username: "alerts" },
156
+ },
157
+ },
158
+ },
159
+ } as Parameters<typeof listAccountIds>[0]),
160
+ ).toEqual(["alerts-31m", "secondary"]);
161
+ });
162
+ });
163
+
164
+ describe("resolveDefaultTwitchAccountId", () => {
165
+ it("prefers channels.twitch.defaultAccount when configured", () => {
166
+ expect(
167
+ resolveDefaultTwitchAccountId({
168
+ channels: {
169
+ twitch: {
170
+ defaultAccount: "secondary",
171
+ accounts: {
172
+ default: { username: "default" },
173
+ secondary: { username: "secondary" },
174
+ },
175
+ },
176
+ },
177
+ } as Parameters<typeof resolveDefaultTwitchAccountId>[0]),
178
+ ).toBe("secondary");
179
+ });
180
+ });
181
+
182
+ describe("resolveTwitchAccountContext", () => {
183
+ it("uses configured defaultAccount when accountId is omitted", () => {
184
+ const context = resolveTwitchAccountContext({
185
+ channels: {
186
+ twitch: {
187
+ defaultAccount: "secondary",
188
+ accounts: {
189
+ default: {
190
+ username: "default-bot",
191
+ accessToken: "oauth:default-token",
192
+ },
193
+ secondary: {
194
+ username: "second-bot",
195
+ accessToken: "oauth:second-token",
196
+ },
197
+ },
198
+ },
199
+ },
200
+ } as Parameters<typeof resolveTwitchAccountContext>[0]);
201
+
202
+ expect(context.accountId).toBe("secondary");
203
+ expect(context.account?.username).toBe("second-bot");
204
+ });
205
+
206
+ it("keeps account and token lookup aligned after account id normalization", () => {
207
+ const context = resolveTwitchAccountContext(
208
+ {
209
+ channels: {
210
+ twitch: {
211
+ accounts: {
212
+ Secondary: {
213
+ username: "second-bot",
214
+ accessToken: "oauth:second-token",
215
+ clientId: "second-client",
216
+ channel: "#second",
217
+ },
218
+ },
219
+ },
220
+ },
221
+ } as Parameters<typeof resolveTwitchAccountContext>[0],
222
+ "secondary",
223
+ );
224
+
225
+ expect(context.accountId).toBe("secondary");
226
+ expect(context.account?.username).toBe("second-bot");
227
+ expect(context.tokenResolution).toEqual({
228
+ token: "oauth:second-token",
229
+ source: "config",
230
+ });
231
+ expect(context.configured).toBe(true);
232
+ });
233
+ });
package/src/config.ts ADDED
@@ -0,0 +1,177 @@
1
+ import {
2
+ listCombinedAccountIds,
3
+ normalizeAccountId,
4
+ resolveNormalizedAccountEntry,
5
+ } from "klaw/plugin-sdk/account-resolution";
6
+ import type { KlawConfig } from "klaw/plugin-sdk/config-contracts";
7
+ import { resolveTwitchToken, type TwitchTokenResolution } from "./token.js";
8
+ import type { TwitchAccountConfig } from "./types.js";
9
+ import { isAccountConfigured } from "./utils/twitch.js";
10
+
11
+ /**
12
+ * Default account ID for Twitch
13
+ */
14
+ export const DEFAULT_ACCOUNT_ID = "default";
15
+
16
+ export type ResolvedTwitchAccountContext = {
17
+ accountId: string;
18
+ account: TwitchAccountConfig | null;
19
+ tokenResolution: TwitchTokenResolution;
20
+ configured: boolean;
21
+ availableAccountIds: string[];
22
+ };
23
+
24
+ /**
25
+ * Get account config from core config
26
+ *
27
+ * Handles two patterns:
28
+ * 1. Simplified single-account: base-level properties create implicit "default" account
29
+ * 2. Multi-account: explicit accounts object
30
+ *
31
+ * For "default" account, base-level properties take precedence over accounts.default
32
+ * For other accounts, only the accounts object is checked
33
+ */
34
+ export function getAccountConfig(
35
+ coreConfig: unknown,
36
+ accountId: string,
37
+ ): TwitchAccountConfig | null {
38
+ if (!coreConfig || typeof coreConfig !== "object") {
39
+ return null;
40
+ }
41
+
42
+ const cfg = coreConfig as KlawConfig;
43
+ const normalizedAccountId = normalizeAccountId(accountId);
44
+ const twitch = cfg.channels?.twitch;
45
+ // Access accounts via unknown to handle union type (single-account vs multi-account)
46
+ const twitchRaw = twitch as Record<string, unknown> | undefined;
47
+ const accounts = twitchRaw?.accounts as Record<string, TwitchAccountConfig> | undefined;
48
+
49
+ // For default account, check base-level config first
50
+ if (normalizedAccountId === DEFAULT_ACCOUNT_ID) {
51
+ const accountFromAccounts = resolveNormalizedAccountEntry(
52
+ accounts,
53
+ DEFAULT_ACCOUNT_ID,
54
+ normalizeAccountId,
55
+ );
56
+
57
+ // Base-level properties that can form an implicit default account
58
+ const baseLevel = {
59
+ username: typeof twitchRaw?.username === "string" ? twitchRaw.username : undefined,
60
+ accessToken: typeof twitchRaw?.accessToken === "string" ? twitchRaw.accessToken : undefined,
61
+ clientId: typeof twitchRaw?.clientId === "string" ? twitchRaw.clientId : undefined,
62
+ channel: typeof twitchRaw?.channel === "string" ? twitchRaw.channel : undefined,
63
+ enabled: typeof twitchRaw?.enabled === "boolean" ? twitchRaw.enabled : undefined,
64
+ allowFrom: Array.isArray(twitchRaw?.allowFrom) ? twitchRaw.allowFrom : undefined,
65
+ allowedRoles: Array.isArray(twitchRaw?.allowedRoles) ? twitchRaw.allowedRoles : undefined,
66
+ requireMention:
67
+ typeof twitchRaw?.requireMention === "boolean" ? twitchRaw.requireMention : undefined,
68
+ clientSecret:
69
+ typeof twitchRaw?.clientSecret === "string" ? twitchRaw.clientSecret : undefined,
70
+ refreshToken:
71
+ typeof twitchRaw?.refreshToken === "string" ? twitchRaw.refreshToken : undefined,
72
+ expiresIn: typeof twitchRaw?.expiresIn === "number" ? twitchRaw.expiresIn : undefined,
73
+ obtainmentTimestamp:
74
+ typeof twitchRaw?.obtainmentTimestamp === "number"
75
+ ? twitchRaw.obtainmentTimestamp
76
+ : undefined,
77
+ };
78
+
79
+ // Merge: base-level takes precedence over accounts.default
80
+ const merged: Partial<TwitchAccountConfig> = {
81
+ ...accountFromAccounts,
82
+ ...baseLevel,
83
+ } as Partial<TwitchAccountConfig>;
84
+
85
+ // Only return if we have at least username
86
+ if (merged.username) {
87
+ return merged as TwitchAccountConfig;
88
+ }
89
+
90
+ // Fall through to accounts.default if no base-level username
91
+ if (accountFromAccounts) {
92
+ return accountFromAccounts;
93
+ }
94
+
95
+ return null;
96
+ }
97
+
98
+ // For non-default accounts, only check accounts object
99
+ const account = resolveNormalizedAccountEntry(accounts, normalizedAccountId, normalizeAccountId);
100
+ if (!account) {
101
+ return null;
102
+ }
103
+
104
+ return account;
105
+ }
106
+
107
+ /**
108
+ * List all configured account IDs
109
+ *
110
+ * Includes both explicit accounts and implicit "default" from base-level config
111
+ */
112
+ export function listAccountIds(cfg: KlawConfig): string[] {
113
+ const twitch = cfg.channels?.twitch;
114
+ // Access accounts via unknown to handle union type (single-account vs multi-account)
115
+ const twitchRaw = twitch as Record<string, unknown> | undefined;
116
+ const accountMap = twitchRaw?.accounts as Record<string, unknown> | undefined;
117
+
118
+ // Add implicit "default" if base-level config exists and "default" not already present
119
+ const hasBaseLevelConfig =
120
+ twitchRaw &&
121
+ (typeof twitchRaw.username === "string" ||
122
+ typeof twitchRaw.accessToken === "string" ||
123
+ typeof twitchRaw.channel === "string");
124
+
125
+ return listCombinedAccountIds({
126
+ configuredAccountIds: Object.keys(accountMap ?? {}).map((accountId) =>
127
+ normalizeAccountId(accountId),
128
+ ),
129
+ implicitAccountId: hasBaseLevelConfig ? DEFAULT_ACCOUNT_ID : undefined,
130
+ });
131
+ }
132
+
133
+ export function resolveDefaultTwitchAccountId(cfg: KlawConfig): string {
134
+ const preferredRaw =
135
+ typeof cfg.channels?.twitch?.defaultAccount === "string"
136
+ ? cfg.channels.twitch.defaultAccount.trim()
137
+ : "";
138
+ const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : "";
139
+ const ids = listAccountIds(cfg);
140
+ if (preferred && ids.includes(preferred)) {
141
+ return preferred;
142
+ }
143
+ if (ids.includes(DEFAULT_ACCOUNT_ID)) {
144
+ return DEFAULT_ACCOUNT_ID;
145
+ }
146
+ return ids[0] ?? DEFAULT_ACCOUNT_ID;
147
+ }
148
+
149
+ export function resolveTwitchAccountContext(
150
+ cfg: KlawConfig,
151
+ accountId?: string | null,
152
+ ): ResolvedTwitchAccountContext {
153
+ const resolvedAccountId = accountId?.trim()
154
+ ? normalizeAccountId(accountId)
155
+ : resolveDefaultTwitchAccountId(cfg);
156
+ const account = getAccountConfig(cfg, resolvedAccountId);
157
+ const tokenResolution = resolveTwitchToken(cfg, { accountId: resolvedAccountId });
158
+ return {
159
+ accountId: resolvedAccountId,
160
+ account,
161
+ tokenResolution,
162
+ configured: account ? isAccountConfigured(account, tokenResolution.token) : false,
163
+ availableAccountIds: listAccountIds(cfg),
164
+ };
165
+ }
166
+
167
+ export function resolveTwitchSnapshotAccountId(
168
+ cfg: KlawConfig,
169
+ account: TwitchAccountConfig,
170
+ ): string {
171
+ const twitch = (cfg as Record<string, unknown>).channels as Record<string, unknown> | undefined;
172
+ const twitchCfg = twitch?.twitch as Record<string, unknown> | undefined;
173
+ const accountMap = (twitchCfg?.accounts as Record<string, unknown> | undefined) ?? {};
174
+ return (
175
+ Object.entries(accountMap).find(([, value]) => value === account)?.[0] ?? DEFAULT_ACCOUNT_ID
176
+ );
177
+ }