@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.
- package/CHANGELOG.md +21 -0
- package/README.md +89 -0
- package/index.ts +20 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +20 -0
- package/src/access-control.test.ts +491 -0
- package/src/access-control.ts +166 -0
- package/src/actions.ts +174 -0
- package/src/client-manager-registry.ts +115 -0
- package/src/config-schema.ts +84 -0
- package/src/config.test.ts +87 -0
- package/src/config.ts +116 -0
- package/src/monitor.ts +273 -0
- package/src/onboarding.test.ts +316 -0
- package/src/onboarding.ts +417 -0
- package/src/outbound.test.ts +403 -0
- package/src/outbound.ts +187 -0
- package/src/plugin.test.ts +39 -0
- package/src/plugin.ts +274 -0
- package/src/probe.test.ts +196 -0
- package/src/probe.ts +119 -0
- package/src/resolver.ts +137 -0
- package/src/runtime.ts +14 -0
- package/src/send.test.ts +276 -0
- package/src/send.ts +136 -0
- package/src/status.test.ts +270 -0
- package/src/status.ts +179 -0
- package/src/test-fixtures.ts +30 -0
- package/src/token.test.ts +171 -0
- package/src/token.ts +91 -0
- package/src/twitch-client.test.ts +589 -0
- package/src/twitch-client.ts +277 -0
- package/src/types.ts +143 -0
- package/src/utils/markdown.ts +98 -0
- package/src/utils/twitch.ts +78 -0
- package/test/setup.ts +7 -0
|
@@ -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
|
+
});
|