@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,298 @@
1
+ /**
2
+ * Tests for status.ts module
3
+ *
4
+ * Tests cover:
5
+ * - Detection of unconfigured accounts
6
+ * - Detection of disabled accounts
7
+ * - Detection of missing clientId
8
+ * - Token format warnings
9
+ * - Access control warnings
10
+ * - Runtime error detection
11
+ */
12
+
13
+ import { describe, expect, it } from "vitest";
14
+ import { collectTwitchStatusIssues } from "./status.js";
15
+ import type { ChannelAccountSnapshot } from "./types.js";
16
+
17
+ function createSnapshot(overrides: Partial<ChannelAccountSnapshot> = {}): ChannelAccountSnapshot {
18
+ return {
19
+ accountId: "default",
20
+ configured: true,
21
+ enabled: true,
22
+ running: false,
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ function createSimpleTwitchConfig(overrides: Record<string, unknown>) {
28
+ return {
29
+ channels: {
30
+ twitch: overrides,
31
+ },
32
+ };
33
+ }
34
+
35
+ function expectSingleIssue(
36
+ issues: ReturnType<typeof collectTwitchStatusIssues>,
37
+ expected: ReturnType<typeof collectTwitchStatusIssues>[number],
38
+ ): void {
39
+ expect(issues).toEqual([expected]);
40
+ }
41
+
42
+ function expectIssues(
43
+ issues: ReturnType<typeof collectTwitchStatusIssues>,
44
+ expected: ReturnType<typeof collectTwitchStatusIssues>,
45
+ ): void {
46
+ expect(issues).toEqual(expected);
47
+ }
48
+
49
+ function neverConnectedIssue(): ReturnType<typeof collectTwitchStatusIssues>[number] {
50
+ return {
51
+ channel: "twitch",
52
+ accountId: "default",
53
+ kind: "runtime",
54
+ message: "Account has never connected successfully",
55
+ fix: "Start the Twitch gateway to begin receiving messages. Check logs for connection errors.",
56
+ };
57
+ }
58
+
59
+ describe("status", () => {
60
+ describe("collectTwitchStatusIssues", () => {
61
+ it("should detect unconfigured accounts", () => {
62
+ const snapshots: ChannelAccountSnapshot[] = [createSnapshot({ configured: false })];
63
+
64
+ const issues = collectTwitchStatusIssues(snapshots);
65
+
66
+ expectSingleIssue(issues, {
67
+ channel: "twitch",
68
+ accountId: "default",
69
+ kind: "config",
70
+ message: "Twitch account is not properly configured",
71
+ fix: "Add required fields: username, accessToken, and clientId to your account configuration",
72
+ });
73
+ });
74
+
75
+ it("should detect disabled accounts", () => {
76
+ const snapshots: ChannelAccountSnapshot[] = [createSnapshot({ enabled: false })];
77
+
78
+ const issues = collectTwitchStatusIssues(snapshots);
79
+
80
+ expectSingleIssue(issues, {
81
+ channel: "twitch",
82
+ accountId: "default",
83
+ kind: "config",
84
+ message: "Twitch account is disabled",
85
+ fix: "Set enabled: true in your account configuration to enable this account",
86
+ });
87
+ });
88
+
89
+ it("should detect missing clientId when account configured (simplified config)", () => {
90
+ const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
91
+ const mockCfg = createSimpleTwitchConfig({
92
+ username: "testbot",
93
+ accessToken: "oauth:test123",
94
+ // clientId missing
95
+ });
96
+
97
+ const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
98
+
99
+ expectIssues(issues, [
100
+ {
101
+ channel: "twitch",
102
+ accountId: "default",
103
+ kind: "config",
104
+ message: "Twitch client ID is required",
105
+ fix: "Add clientId to your Twitch account configuration (from Twitch Developer Portal)",
106
+ },
107
+ neverConnectedIssue(),
108
+ ]);
109
+ });
110
+
111
+ it("should warn about oauth: prefix in token (simplified config)", () => {
112
+ const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
113
+ const mockCfg = createSimpleTwitchConfig({
114
+ username: "testbot",
115
+ accessToken: "oauth:test123", // has prefix
116
+ clientId: "test-id",
117
+ });
118
+
119
+ const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
120
+
121
+ expectIssues(issues, [
122
+ {
123
+ channel: "twitch",
124
+ accountId: "default",
125
+ kind: "config",
126
+ message: "Token contains 'oauth:' prefix (will be stripped)",
127
+ fix: "The 'oauth:' prefix is optional. You can use just the token value, or keep it as-is (it will be normalized automatically).",
128
+ },
129
+ neverConnectedIssue(),
130
+ ]);
131
+ });
132
+
133
+ it("should detect clientSecret without refreshToken (simplified config)", () => {
134
+ const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
135
+ const mockCfg = createSimpleTwitchConfig({
136
+ username: "testbot",
137
+ accessToken: "oauth:test123",
138
+ clientId: "test-id",
139
+ clientSecret: "secret123",
140
+ // refreshToken missing
141
+ });
142
+
143
+ const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
144
+
145
+ expectIssues(issues, [
146
+ {
147
+ channel: "twitch",
148
+ accountId: "default",
149
+ kind: "config",
150
+ message: "Token contains 'oauth:' prefix (will be stripped)",
151
+ fix: "The 'oauth:' prefix is optional. You can use just the token value, or keep it as-is (it will be normalized automatically).",
152
+ },
153
+ {
154
+ channel: "twitch",
155
+ accountId: "default",
156
+ kind: "config",
157
+ message: "clientSecret provided without refreshToken",
158
+ fix: "For automatic token refresh, provide both clientSecret and refreshToken. Otherwise, clientSecret is not needed.",
159
+ },
160
+ neverConnectedIssue(),
161
+ ]);
162
+ });
163
+
164
+ it("should detect empty allowFrom array (simplified config)", () => {
165
+ const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
166
+ const mockCfg = createSimpleTwitchConfig({
167
+ username: "testbot",
168
+ accessToken: "test123",
169
+ clientId: "test-id",
170
+ allowFrom: [], // empty array
171
+ });
172
+
173
+ const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
174
+
175
+ expectIssues(issues, [
176
+ {
177
+ channel: "twitch",
178
+ accountId: "default",
179
+ kind: "config",
180
+ message: "allowFrom is configured but empty",
181
+ fix: "Either add user IDs to allowFrom, remove the allowFrom field, or use allowedRoles instead.",
182
+ },
183
+ neverConnectedIssue(),
184
+ ]);
185
+ });
186
+
187
+ it("should detect allowedRoles 'all' with allowFrom conflict (simplified config)", () => {
188
+ const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
189
+ const mockCfg = createSimpleTwitchConfig({
190
+ username: "testbot",
191
+ accessToken: "test123",
192
+ clientId: "test-id",
193
+ allowedRoles: ["all"],
194
+ allowFrom: ["123456"], // conflict!
195
+ });
196
+
197
+ const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
198
+
199
+ expectIssues(issues, [
200
+ {
201
+ channel: "twitch",
202
+ accountId: "default",
203
+ kind: "intent",
204
+ message: "allowedRoles is set to 'all' but allowFrom is also configured",
205
+ fix: "When allowedRoles is 'all', the allowFrom list is not needed. Remove allowFrom or set allowedRoles to specific roles.",
206
+ },
207
+ neverConnectedIssue(),
208
+ ]);
209
+ });
210
+
211
+ it("should detect runtime errors", () => {
212
+ const snapshots: ChannelAccountSnapshot[] = [
213
+ createSnapshot({ lastError: "Connection timeout" }),
214
+ ];
215
+
216
+ const issues = collectTwitchStatusIssues(snapshots);
217
+
218
+ expectIssues(issues, [
219
+ {
220
+ channel: "twitch",
221
+ accountId: "default",
222
+ kind: "runtime",
223
+ message: "Last error: Connection timeout",
224
+ fix: "Check your token validity and network connection. Ensure the bot has the required OAuth scopes.",
225
+ },
226
+ neverConnectedIssue(),
227
+ ]);
228
+ });
229
+
230
+ it("should detect accounts that never connected", () => {
231
+ const snapshots: ChannelAccountSnapshot[] = [
232
+ createSnapshot({
233
+ lastStartAt: undefined,
234
+ lastInboundAt: undefined,
235
+ lastOutboundAt: undefined,
236
+ }),
237
+ ];
238
+
239
+ const issues = collectTwitchStatusIssues(snapshots);
240
+
241
+ expectSingleIssue(issues, {
242
+ channel: "twitch",
243
+ accountId: "default",
244
+ kind: "runtime",
245
+ message: "Account has never connected successfully",
246
+ fix: "Start the Twitch gateway to begin receiving messages. Check logs for connection errors.",
247
+ });
248
+ });
249
+
250
+ it("should detect long-running connections", () => {
251
+ const oldDate = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days ago
252
+
253
+ const snapshots: ChannelAccountSnapshot[] = [
254
+ createSnapshot({
255
+ running: true,
256
+ lastStartAt: oldDate,
257
+ }),
258
+ ];
259
+
260
+ const issues = collectTwitchStatusIssues(snapshots);
261
+
262
+ expectSingleIssue(issues, {
263
+ channel: "twitch",
264
+ accountId: "default",
265
+ kind: "runtime",
266
+ message: "Connection has been running for 8 days",
267
+ fix: "Consider restarting the connection periodically to refresh the connection. Twitch tokens may expire after long periods.",
268
+ });
269
+ });
270
+
271
+ it("should handle empty snapshots array", () => {
272
+ const issues = collectTwitchStatusIssues([]);
273
+
274
+ expect(issues).toStrictEqual([]);
275
+ });
276
+
277
+ it("should skip non-Twitch accounts gracefully", () => {
278
+ const snapshots: ChannelAccountSnapshot[] = [
279
+ {
280
+ accountId: "unknown",
281
+ configured: false,
282
+ enabled: true,
283
+ running: false,
284
+ },
285
+ ];
286
+
287
+ const issues = collectTwitchStatusIssues(snapshots);
288
+
289
+ expectSingleIssue(issues, {
290
+ channel: "twitch",
291
+ accountId: "unknown",
292
+ kind: "config",
293
+ message: "Twitch account is not properly configured",
294
+ fix: "Add required fields: username, accessToken, and clientId to your account configuration",
295
+ });
296
+ });
297
+ });
298
+ });
package/src/status.ts ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Twitch status issues collector.
3
+ *
4
+ * Detects and reports configuration issues for Twitch accounts.
5
+ */
6
+
7
+ import type { ChannelStatusIssue } from "klaw/plugin-sdk/channel-contract";
8
+ import { getAccountConfig } from "./config.js";
9
+ import { resolveTwitchToken } from "./token.js";
10
+ import type { ChannelAccountSnapshot } from "./types.js";
11
+ import { isAccountConfigured } from "./utils/twitch.js";
12
+
13
+ /**
14
+ * Collect status issues for Twitch accounts.
15
+ *
16
+ * Analyzes account snapshots and detects configuration problems,
17
+ * authentication issues, and other potential problems.
18
+ *
19
+ * @param accounts - Array of account snapshots to analyze
20
+ * @param getCfg - Optional function to get full config for additional checks
21
+ * @returns Array of detected status issues
22
+ *
23
+ * @example
24
+ * const issues = collectTwitchStatusIssues(accountSnapshots);
25
+ * if (issues.length > 0) {
26
+ * console.warn("Twitch configuration issues detected:");
27
+ * issues.forEach(issue => console.warn(`- ${issue.message}`));
28
+ * }
29
+ */
30
+ export function collectTwitchStatusIssues(
31
+ accounts: ChannelAccountSnapshot[],
32
+ getCfg?: () => unknown,
33
+ ): ChannelStatusIssue[] {
34
+ const issues: ChannelStatusIssue[] = [];
35
+
36
+ for (const entry of accounts) {
37
+ const accountId = entry.accountId;
38
+
39
+ if (!accountId) {
40
+ continue;
41
+ }
42
+
43
+ let account: ReturnType<typeof getAccountConfig> | null = null;
44
+ let cfg: Parameters<typeof resolveTwitchToken>[0] | undefined;
45
+ if (getCfg) {
46
+ try {
47
+ cfg = getCfg() as {
48
+ channels?: { twitch?: { accounts?: Record<string, unknown> } };
49
+ };
50
+ account = getAccountConfig(cfg, accountId);
51
+ } catch {
52
+ // Ignore config access errors
53
+ }
54
+ }
55
+
56
+ if (!entry.configured) {
57
+ issues.push({
58
+ channel: "twitch",
59
+ accountId,
60
+ kind: "config",
61
+ message: "Twitch account is not properly configured",
62
+ fix: "Add required fields: username, accessToken, and clientId to your account configuration",
63
+ });
64
+ continue;
65
+ }
66
+
67
+ if (entry.enabled === false) {
68
+ issues.push({
69
+ channel: "twitch",
70
+ accountId,
71
+ kind: "config",
72
+ message: "Twitch account is disabled",
73
+ fix: "Set enabled: true in your account configuration to enable this account",
74
+ });
75
+ continue;
76
+ }
77
+
78
+ if (account && account.username && account.accessToken && !account.clientId) {
79
+ issues.push({
80
+ channel: "twitch",
81
+ accountId,
82
+ kind: "config",
83
+ message: "Twitch client ID is required",
84
+ fix: "Add clientId to your Twitch account configuration (from Twitch Developer Portal)",
85
+ });
86
+ }
87
+
88
+ const tokenResolution = cfg
89
+ ? resolveTwitchToken(cfg as Parameters<typeof resolveTwitchToken>[0], { accountId })
90
+ : { token: "", source: "none" };
91
+ if (account && isAccountConfigured(account, tokenResolution.token)) {
92
+ if (account.accessToken?.startsWith("oauth:")) {
93
+ issues.push({
94
+ channel: "twitch",
95
+ accountId,
96
+ kind: "config",
97
+ message: "Token contains 'oauth:' prefix (will be stripped)",
98
+ fix: "The 'oauth:' prefix is optional. You can use just the token value, or keep it as-is (it will be normalized automatically).",
99
+ });
100
+ }
101
+
102
+ if (account.clientSecret && !account.refreshToken) {
103
+ issues.push({
104
+ channel: "twitch",
105
+ accountId,
106
+ kind: "config",
107
+ message: "clientSecret provided without refreshToken",
108
+ fix: "For automatic token refresh, provide both clientSecret and refreshToken. Otherwise, clientSecret is not needed.",
109
+ });
110
+ }
111
+
112
+ if (account.allowFrom && account.allowFrom.length === 0) {
113
+ issues.push({
114
+ channel: "twitch",
115
+ accountId,
116
+ kind: "config",
117
+ message: "allowFrom is configured but empty",
118
+ fix: "Either add user IDs to allowFrom, remove the allowFrom field, or use allowedRoles instead.",
119
+ });
120
+ }
121
+
122
+ if (
123
+ account.allowedRoles?.includes("all") &&
124
+ account.allowFrom &&
125
+ account.allowFrom.length > 0
126
+ ) {
127
+ issues.push({
128
+ channel: "twitch",
129
+ accountId,
130
+ kind: "intent",
131
+ message: "allowedRoles is set to 'all' but allowFrom is also configured",
132
+ fix: "When allowedRoles is 'all', the allowFrom list is not needed. Remove allowFrom or set allowedRoles to specific roles.",
133
+ });
134
+ }
135
+ }
136
+
137
+ if (entry.lastError) {
138
+ issues.push({
139
+ channel: "twitch",
140
+ accountId,
141
+ kind: "runtime",
142
+ message: `Last error: ${entry.lastError}`,
143
+ fix: "Check your token validity and network connection. Ensure the bot has the required OAuth scopes.",
144
+ });
145
+ }
146
+
147
+ if (
148
+ entry.configured &&
149
+ !entry.running &&
150
+ !entry.lastStartAt &&
151
+ !entry.lastInboundAt &&
152
+ !entry.lastOutboundAt
153
+ ) {
154
+ issues.push({
155
+ channel: "twitch",
156
+ accountId,
157
+ kind: "runtime",
158
+ message: "Account has never connected successfully",
159
+ fix: "Start the Twitch gateway to begin receiving messages. Check logs for connection errors.",
160
+ });
161
+ }
162
+
163
+ if (entry.running && entry.lastStartAt) {
164
+ const uptime = Date.now() - entry.lastStartAt;
165
+ const daysSinceStart = uptime / (1000 * 60 * 60 * 24);
166
+ if (daysSinceStart > 7) {
167
+ issues.push({
168
+ channel: "twitch",
169
+ accountId,
170
+ kind: "runtime",
171
+ message: `Connection has been running for ${Math.floor(daysSinceStart)} days`,
172
+ fix: "Consider restarting the connection periodically to refresh the connection. Twitch tokens may expire after long periods.",
173
+ });
174
+ }
175
+ }
176
+ }
177
+
178
+ return issues;
179
+ }
@@ -0,0 +1,30 @@
1
+ import { afterEach, beforeEach, vi } from "vitest";
2
+ import type { KlawConfig } from "../runtime-api.js";
3
+
4
+ export const BASE_TWITCH_TEST_ACCOUNT = {
5
+ username: "testbot",
6
+ clientId: "test-client-id",
7
+ channel: "#testchannel",
8
+ };
9
+
10
+ export function makeTwitchTestConfig(account: Record<string, unknown>): KlawConfig {
11
+ return {
12
+ channels: {
13
+ twitch: {
14
+ accounts: {
15
+ default: account,
16
+ },
17
+ },
18
+ },
19
+ } as unknown as KlawConfig;
20
+ }
21
+
22
+ export function installTwitchTestHooks() {
23
+ beforeEach(() => {
24
+ vi.clearAllMocks();
25
+ });
26
+
27
+ afterEach(() => {
28
+ vi.restoreAllMocks();
29
+ });
30
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Tests for token.ts module
3
+ *
4
+ * Tests cover:
5
+ * - Token resolution from config
6
+ * - Token resolution from environment variable
7
+ * - Fallback behavior when token not found
8
+ * - Account ID normalization
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
+ import type { KlawConfig } from "../api.js";
13
+ import { resolveTwitchToken, type TwitchTokenSource } from "./token.js";
14
+
15
+ describe("token", () => {
16
+ const originalAccessToken = process.env.KLAW_TWITCH_ACCESS_TOKEN;
17
+
18
+ // Multi-account config for testing non-default accounts
19
+ const mockMultiAccountConfig = {
20
+ channels: {
21
+ twitch: {
22
+ accounts: {
23
+ default: {
24
+ username: "testbot",
25
+ accessToken: "oauth:config-token",
26
+ },
27
+ other: {
28
+ username: "otherbot",
29
+ accessToken: "oauth:other-token",
30
+ },
31
+ },
32
+ },
33
+ },
34
+ } as unknown as KlawConfig;
35
+
36
+ // Simplified single-account config
37
+ const mockSimplifiedConfig = {
38
+ channels: {
39
+ twitch: {
40
+ username: "testbot",
41
+ accessToken: "oauth:config-token",
42
+ },
43
+ },
44
+ } as unknown as KlawConfig;
45
+
46
+ beforeEach(() => {
47
+ vi.clearAllMocks();
48
+ });
49
+
50
+ afterEach(() => {
51
+ vi.restoreAllMocks();
52
+ if (originalAccessToken === undefined) {
53
+ delete process.env.KLAW_TWITCH_ACCESS_TOKEN;
54
+ } else {
55
+ process.env.KLAW_TWITCH_ACCESS_TOKEN = originalAccessToken;
56
+ }
57
+ });
58
+
59
+ describe("resolveTwitchToken", () => {
60
+ it("should resolve token from simplified config for default account", () => {
61
+ const result = resolveTwitchToken(mockSimplifiedConfig, { accountId: "default" });
62
+
63
+ expect(result.token).toBe("oauth:config-token");
64
+ expect(result.source).toBe("config");
65
+ });
66
+
67
+ it("should resolve token from config for non-default account (multi-account)", () => {
68
+ const result = resolveTwitchToken(mockMultiAccountConfig, { accountId: "other" });
69
+
70
+ expect(result.token).toBe("oauth:other-token");
71
+ expect(result.source).toBe("config");
72
+ });
73
+
74
+ it("should resolve token from normalized account id", () => {
75
+ const result = resolveTwitchToken(
76
+ {
77
+ channels: {
78
+ twitch: {
79
+ accounts: {
80
+ Secondary: {
81
+ username: "secondary",
82
+ accessToken: "oauth:secondary-token",
83
+ },
84
+ },
85
+ },
86
+ },
87
+ } as unknown as KlawConfig,
88
+ { accountId: "secondary" },
89
+ );
90
+
91
+ expect(result.token).toBe("oauth:secondary-token");
92
+ expect(result.source).toBe("config");
93
+ });
94
+
95
+ it("should prioritize config token over env var (simplified config)", () => {
96
+ process.env.KLAW_TWITCH_ACCESS_TOKEN = "oauth:env-token";
97
+
98
+ const result = resolveTwitchToken(mockSimplifiedConfig, { accountId: "default" });
99
+
100
+ // Config token should be used even if env var exists
101
+ expect(result.token).toBe("oauth:config-token");
102
+ expect(result.source).toBe("config");
103
+ });
104
+
105
+ it("should use env var when config token is empty (simplified config)", () => {
106
+ process.env.KLAW_TWITCH_ACCESS_TOKEN = "oauth:env-token";
107
+
108
+ const configWithEmptyToken = {
109
+ channels: {
110
+ twitch: {
111
+ username: "testbot",
112
+ accessToken: "",
113
+ },
114
+ },
115
+ } as unknown as KlawConfig;
116
+
117
+ const result = resolveTwitchToken(configWithEmptyToken, { accountId: "default" });
118
+
119
+ expect(result.token).toBe("oauth:env-token");
120
+ expect(result.source).toBe("env");
121
+ });
122
+
123
+ it("should return empty token when neither config nor env has token (simplified config)", () => {
124
+ const configWithoutToken = {
125
+ channels: {
126
+ twitch: {
127
+ username: "testbot",
128
+ accessToken: "",
129
+ },
130
+ },
131
+ } as unknown as KlawConfig;
132
+
133
+ const result = resolveTwitchToken(configWithoutToken, { accountId: "default" });
134
+
135
+ expect(result.token).toBe("");
136
+ expect(result.source).toBe("none");
137
+ });
138
+
139
+ it("should not use env var for non-default accounts (multi-account)", () => {
140
+ process.env.KLAW_TWITCH_ACCESS_TOKEN = "oauth:env-token";
141
+
142
+ const configWithoutToken = {
143
+ channels: {
144
+ twitch: {
145
+ accounts: {
146
+ secondary: {
147
+ username: "secondary",
148
+ accessToken: "",
149
+ },
150
+ },
151
+ },
152
+ },
153
+ } as unknown as KlawConfig;
154
+
155
+ const result = resolveTwitchToken(configWithoutToken, { accountId: "secondary" });
156
+
157
+ // Non-default accounts shouldn't use env var
158
+ expect(result.token).toBe("");
159
+ expect(result.source).toBe("none");
160
+ });
161
+
162
+ it("should handle missing account gracefully", () => {
163
+ const configWithoutAccount = {
164
+ channels: {
165
+ twitch: {
166
+ accounts: {},
167
+ },
168
+ },
169
+ } as unknown as KlawConfig;
170
+
171
+ const result = resolveTwitchToken(configWithoutAccount, { accountId: "nonexistent" });
172
+
173
+ expect(result.token).toBe("");
174
+ expect(result.source).toBe("none");
175
+ });
176
+
177
+ it("should handle missing Twitch config section", () => {
178
+ const configWithoutSection = {
179
+ channels: {},
180
+ } as unknown as KlawConfig;
181
+
182
+ const result = resolveTwitchToken(configWithoutSection, { accountId: "default" });
183
+
184
+ expect(result.token).toBe("");
185
+ expect(result.source).toBe("none");
186
+ });
187
+ });
188
+
189
+ describe("TwitchTokenSource type", () => {
190
+ it("should have correct values", () => {
191
+ const sources: TwitchTokenSource[] = ["env", "config", "none"];
192
+
193
+ expect(sources).toContain("env");
194
+ expect(sources).toContain("config");
195
+ expect(sources).toContain("none");
196
+ });
197
+ });
198
+ });