@openclaw/twitch 2026.2.21

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,270 @@
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
+ describe("status", () => {
18
+ describe("collectTwitchStatusIssues", () => {
19
+ it("should detect unconfigured accounts", () => {
20
+ const snapshots: ChannelAccountSnapshot[] = [
21
+ {
22
+ accountId: "default",
23
+ configured: false,
24
+ enabled: true,
25
+ running: false,
26
+ },
27
+ ];
28
+
29
+ const issues = collectTwitchStatusIssues(snapshots);
30
+
31
+ expect(issues.length).toBeGreaterThan(0);
32
+ expect(issues[0]?.kind).toBe("config");
33
+ expect(issues[0]?.message).toContain("not properly configured");
34
+ });
35
+
36
+ it("should detect disabled accounts", () => {
37
+ const snapshots: ChannelAccountSnapshot[] = [
38
+ {
39
+ accountId: "default",
40
+ configured: true,
41
+ enabled: false,
42
+ running: false,
43
+ },
44
+ ];
45
+
46
+ const issues = collectTwitchStatusIssues(snapshots);
47
+
48
+ expect(issues.length).toBeGreaterThan(0);
49
+ const disabledIssue = issues.find((i) => i.message.includes("disabled"));
50
+ expect(disabledIssue).toBeDefined();
51
+ });
52
+
53
+ it("should detect missing clientId when account configured (simplified config)", () => {
54
+ const snapshots: ChannelAccountSnapshot[] = [
55
+ {
56
+ accountId: "default",
57
+ configured: true,
58
+ enabled: true,
59
+ running: false,
60
+ },
61
+ ];
62
+
63
+ const mockCfg = {
64
+ channels: {
65
+ twitch: {
66
+ username: "testbot",
67
+ accessToken: "oauth:test123",
68
+ // clientId missing
69
+ },
70
+ },
71
+ };
72
+
73
+ const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
74
+
75
+ const clientIdIssue = issues.find((i) => i.message.includes("client ID"));
76
+ expect(clientIdIssue).toBeDefined();
77
+ });
78
+
79
+ it("should warn about oauth: prefix in token (simplified config)", () => {
80
+ const snapshots: ChannelAccountSnapshot[] = [
81
+ {
82
+ accountId: "default",
83
+ configured: true,
84
+ enabled: true,
85
+ running: false,
86
+ },
87
+ ];
88
+
89
+ const mockCfg = {
90
+ channels: {
91
+ twitch: {
92
+ username: "testbot",
93
+ accessToken: "oauth:test123", // has prefix
94
+ clientId: "test-id",
95
+ },
96
+ },
97
+ };
98
+
99
+ const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
100
+
101
+ const prefixIssue = issues.find((i) => i.message.includes("oauth:"));
102
+ expect(prefixIssue).toBeDefined();
103
+ expect(prefixIssue?.kind).toBe("config");
104
+ });
105
+
106
+ it("should detect clientSecret without refreshToken (simplified config)", () => {
107
+ const snapshots: ChannelAccountSnapshot[] = [
108
+ {
109
+ accountId: "default",
110
+ configured: true,
111
+ enabled: true,
112
+ running: false,
113
+ },
114
+ ];
115
+
116
+ const mockCfg = {
117
+ channels: {
118
+ twitch: {
119
+ username: "testbot",
120
+ accessToken: "oauth:test123",
121
+ clientId: "test-id",
122
+ clientSecret: "secret123",
123
+ // refreshToken missing
124
+ },
125
+ },
126
+ };
127
+
128
+ const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
129
+
130
+ const secretIssue = issues.find((i) => i.message.includes("clientSecret"));
131
+ expect(secretIssue).toBeDefined();
132
+ });
133
+
134
+ it("should detect empty allowFrom array (simplified config)", () => {
135
+ const snapshots: ChannelAccountSnapshot[] = [
136
+ {
137
+ accountId: "default",
138
+ configured: true,
139
+ enabled: true,
140
+ running: false,
141
+ },
142
+ ];
143
+
144
+ const mockCfg = {
145
+ channels: {
146
+ twitch: {
147
+ username: "testbot",
148
+ accessToken: "test123",
149
+ clientId: "test-id",
150
+ allowFrom: [], // empty array
151
+ },
152
+ },
153
+ };
154
+
155
+ const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
156
+
157
+ const allowFromIssue = issues.find((i) => i.message.includes("allowFrom"));
158
+ expect(allowFromIssue).toBeDefined();
159
+ });
160
+
161
+ it("should detect allowedRoles 'all' with allowFrom conflict (simplified config)", () => {
162
+ const snapshots: ChannelAccountSnapshot[] = [
163
+ {
164
+ accountId: "default",
165
+ configured: true,
166
+ enabled: true,
167
+ running: false,
168
+ },
169
+ ];
170
+
171
+ const mockCfg = {
172
+ channels: {
173
+ twitch: {
174
+ username: "testbot",
175
+ accessToken: "test123",
176
+ clientId: "test-id",
177
+ allowedRoles: ["all"],
178
+ allowFrom: ["123456"], // conflict!
179
+ },
180
+ },
181
+ };
182
+
183
+ const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
184
+
185
+ const conflictIssue = issues.find((i) => i.kind === "intent");
186
+ expect(conflictIssue).toBeDefined();
187
+ expect(conflictIssue?.message).toContain("allowedRoles is set to 'all'");
188
+ });
189
+
190
+ it("should detect runtime errors", () => {
191
+ const snapshots: ChannelAccountSnapshot[] = [
192
+ {
193
+ accountId: "default",
194
+ configured: true,
195
+ enabled: true,
196
+ running: false,
197
+ lastError: "Connection timeout",
198
+ },
199
+ ];
200
+
201
+ const issues = collectTwitchStatusIssues(snapshots);
202
+
203
+ const runtimeIssue = issues.find((i) => i.kind === "runtime");
204
+ expect(runtimeIssue).toBeDefined();
205
+ expect(runtimeIssue?.message).toContain("Connection timeout");
206
+ });
207
+
208
+ it("should detect accounts that never connected", () => {
209
+ const snapshots: ChannelAccountSnapshot[] = [
210
+ {
211
+ accountId: "default",
212
+ configured: true,
213
+ enabled: true,
214
+ running: false,
215
+ lastStartAt: undefined,
216
+ lastInboundAt: undefined,
217
+ lastOutboundAt: undefined,
218
+ },
219
+ ];
220
+
221
+ const issues = collectTwitchStatusIssues(snapshots);
222
+
223
+ const neverConnectedIssue = issues.find((i) =>
224
+ i.message.includes("never connected successfully"),
225
+ );
226
+ expect(neverConnectedIssue).toBeDefined();
227
+ });
228
+
229
+ it("should detect long-running connections", () => {
230
+ const oldDate = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days ago
231
+
232
+ const snapshots: ChannelAccountSnapshot[] = [
233
+ {
234
+ accountId: "default",
235
+ configured: true,
236
+ enabled: true,
237
+ running: true,
238
+ lastStartAt: oldDate,
239
+ },
240
+ ];
241
+
242
+ const issues = collectTwitchStatusIssues(snapshots);
243
+
244
+ const uptimeIssue = issues.find((i) => i.message.includes("running for"));
245
+ expect(uptimeIssue).toBeDefined();
246
+ });
247
+
248
+ it("should handle empty snapshots array", () => {
249
+ const issues = collectTwitchStatusIssues([]);
250
+
251
+ expect(issues).toEqual([]);
252
+ });
253
+
254
+ it("should skip non-Twitch accounts gracefully", () => {
255
+ const snapshots: ChannelAccountSnapshot[] = [
256
+ {
257
+ accountId: "unknown",
258
+ configured: false,
259
+ enabled: true,
260
+ running: false,
261
+ },
262
+ ];
263
+
264
+ const issues = collectTwitchStatusIssues(snapshots);
265
+
266
+ // Should not crash, may return empty or minimal issues
267
+ expect(Array.isArray(issues)).toBe(true);
268
+ });
269
+ });
270
+ });
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 "openclaw/plugin-sdk";
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 type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import { afterEach, beforeEach, vi } from "vitest";
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>): OpenClawConfig {
11
+ return {
12
+ channels: {
13
+ twitch: {
14
+ accounts: {
15
+ default: account,
16
+ },
17
+ },
18
+ },
19
+ } as unknown as OpenClawConfig;
20
+ }
21
+
22
+ export function installTwitchTestHooks() {
23
+ beforeEach(() => {
24
+ vi.clearAllMocks();
25
+ });
26
+
27
+ afterEach(() => {
28
+ vi.restoreAllMocks();
29
+ });
30
+ }
@@ -0,0 +1,171 @@
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 type { OpenClawConfig } from "openclaw/plugin-sdk";
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
13
+ import { resolveTwitchToken, type TwitchTokenSource } from "./token.js";
14
+
15
+ describe("token", () => {
16
+ // Multi-account config for testing non-default accounts
17
+ const mockMultiAccountConfig = {
18
+ channels: {
19
+ twitch: {
20
+ accounts: {
21
+ default: {
22
+ username: "testbot",
23
+ accessToken: "oauth:config-token",
24
+ },
25
+ other: {
26
+ username: "otherbot",
27
+ accessToken: "oauth:other-token",
28
+ },
29
+ },
30
+ },
31
+ },
32
+ } as unknown as OpenClawConfig;
33
+
34
+ // Simplified single-account config
35
+ const mockSimplifiedConfig = {
36
+ channels: {
37
+ twitch: {
38
+ username: "testbot",
39
+ accessToken: "oauth:config-token",
40
+ },
41
+ },
42
+ } as unknown as OpenClawConfig;
43
+
44
+ beforeEach(() => {
45
+ vi.clearAllMocks();
46
+ });
47
+
48
+ afterEach(() => {
49
+ vi.restoreAllMocks();
50
+ delete process.env.OPENCLAW_TWITCH_ACCESS_TOKEN;
51
+ });
52
+
53
+ describe("resolveTwitchToken", () => {
54
+ it("should resolve token from simplified config for default account", () => {
55
+ const result = resolveTwitchToken(mockSimplifiedConfig, { accountId: "default" });
56
+
57
+ expect(result.token).toBe("oauth:config-token");
58
+ expect(result.source).toBe("config");
59
+ });
60
+
61
+ it("should resolve token from config for non-default account (multi-account)", () => {
62
+ const result = resolveTwitchToken(mockMultiAccountConfig, { accountId: "other" });
63
+
64
+ expect(result.token).toBe("oauth:other-token");
65
+ expect(result.source).toBe("config");
66
+ });
67
+
68
+ it("should prioritize config token over env var (simplified config)", () => {
69
+ process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:env-token";
70
+
71
+ const result = resolveTwitchToken(mockSimplifiedConfig, { accountId: "default" });
72
+
73
+ // Config token should be used even if env var exists
74
+ expect(result.token).toBe("oauth:config-token");
75
+ expect(result.source).toBe("config");
76
+ });
77
+
78
+ it("should use env var when config token is empty (simplified config)", () => {
79
+ process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:env-token";
80
+
81
+ const configWithEmptyToken = {
82
+ channels: {
83
+ twitch: {
84
+ username: "testbot",
85
+ accessToken: "",
86
+ },
87
+ },
88
+ } as unknown as OpenClawConfig;
89
+
90
+ const result = resolveTwitchToken(configWithEmptyToken, { accountId: "default" });
91
+
92
+ expect(result.token).toBe("oauth:env-token");
93
+ expect(result.source).toBe("env");
94
+ });
95
+
96
+ it("should return empty token when neither config nor env has token (simplified config)", () => {
97
+ const configWithoutToken = {
98
+ channels: {
99
+ twitch: {
100
+ username: "testbot",
101
+ accessToken: "",
102
+ },
103
+ },
104
+ } as unknown as OpenClawConfig;
105
+
106
+ const result = resolveTwitchToken(configWithoutToken, { accountId: "default" });
107
+
108
+ expect(result.token).toBe("");
109
+ expect(result.source).toBe("none");
110
+ });
111
+
112
+ it("should not use env var for non-default accounts (multi-account)", () => {
113
+ process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:env-token";
114
+
115
+ const configWithoutToken = {
116
+ channels: {
117
+ twitch: {
118
+ accounts: {
119
+ secondary: {
120
+ username: "secondary",
121
+ accessToken: "",
122
+ },
123
+ },
124
+ },
125
+ },
126
+ } as unknown as OpenClawConfig;
127
+
128
+ const result = resolveTwitchToken(configWithoutToken, { accountId: "secondary" });
129
+
130
+ // Non-default accounts shouldn't use env var
131
+ expect(result.token).toBe("");
132
+ expect(result.source).toBe("none");
133
+ });
134
+
135
+ it("should handle missing account gracefully", () => {
136
+ const configWithoutAccount = {
137
+ channels: {
138
+ twitch: {
139
+ accounts: {},
140
+ },
141
+ },
142
+ } as unknown as OpenClawConfig;
143
+
144
+ const result = resolveTwitchToken(configWithoutAccount, { accountId: "nonexistent" });
145
+
146
+ expect(result.token).toBe("");
147
+ expect(result.source).toBe("none");
148
+ });
149
+
150
+ it("should handle missing Twitch config section", () => {
151
+ const configWithoutSection = {
152
+ channels: {},
153
+ } as unknown as OpenClawConfig;
154
+
155
+ const result = resolveTwitchToken(configWithoutSection, { accountId: "default" });
156
+
157
+ expect(result.token).toBe("");
158
+ expect(result.source).toBe("none");
159
+ });
160
+ });
161
+
162
+ describe("TwitchTokenSource type", () => {
163
+ it("should have correct values", () => {
164
+ const sources: TwitchTokenSource[] = ["env", "config", "none"];
165
+
166
+ expect(sources).toContain("env");
167
+ expect(sources).toContain("config");
168
+ expect(sources).toContain("none");
169
+ });
170
+ });
171
+ });