@openclaw/twitch 2026.2.21 → 2026.5.2-beta.1
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/README.md +2 -2
- package/api.ts +21 -0
- package/channel-plugin-api.ts +1 -0
- package/index.test.ts +13 -0
- package/index.ts +12 -16
- package/openclaw.plugin.json +224 -1
- package/package.json +36 -7
- package/runtime-api.ts +22 -0
- package/setup-entry.ts +9 -0
- package/setup-plugin-api.ts +3 -0
- package/src/access-control.test.ts +136 -239
- package/src/access-control.ts +11 -4
- package/src/actions.test.ts +74 -0
- package/src/actions.ts +7 -6
- package/src/client-manager-registry.ts +0 -28
- package/src/config-schema.test.ts +46 -0
- package/src/config-schema.ts +25 -21
- package/src/config.test.ts +147 -1
- package/src/config.ts +76 -15
- package/src/monitor.ts +126 -85
- package/src/outbound.test.ts +140 -75
- package/src/outbound.ts +4 -5
- package/src/plugin.test.ts +39 -1
- package/src/plugin.ts +176 -242
- package/src/probe.test.ts +1 -1
- package/src/probe.ts +16 -5
- package/src/resolver.ts +5 -3
- package/src/runtime.ts +8 -13
- package/src/send.test.ts +92 -59
- package/src/send.ts +18 -15
- package/src/setup-surface.test.ts +511 -0
- package/src/setup-surface.ts +520 -0
- package/src/status.test.ts +120 -153
- package/src/status.ts +1 -1
- package/src/test-fixtures.ts +1 -1
- package/src/token.test.ts +22 -1
- package/src/token.ts +8 -6
- package/src/twitch-client.test.ts +18 -25
- package/src/twitch-client.ts +5 -6
- package/src/types.ts +7 -46
- package/src/utils/twitch.ts +8 -2
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -21
- package/src/onboarding.test.ts +0 -316
- package/src/onboarding.ts +0 -417
package/src/outbound.test.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { describe, expect, it, vi } from "vitest";
|
|
13
|
+
import { resolveTwitchAccountContext } from "./config.js";
|
|
13
14
|
import { twitchOutbound } from "./outbound.js";
|
|
14
15
|
import {
|
|
15
16
|
BASE_TWITCH_TEST_ACCOUNT,
|
|
@@ -20,7 +21,7 @@ import {
|
|
|
20
21
|
// Mock dependencies
|
|
21
22
|
vi.mock("./config.js", () => ({
|
|
22
23
|
DEFAULT_ACCOUNT_ID: "default",
|
|
23
|
-
|
|
24
|
+
resolveTwitchAccountContext: vi.fn(),
|
|
24
25
|
}));
|
|
25
26
|
|
|
26
27
|
vi.mock("./send.js", () => ({
|
|
@@ -46,6 +47,20 @@ function assertResolvedTarget(
|
|
|
46
47
|
return result.to;
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
function expectTargetError(
|
|
51
|
+
resolveTarget: NonNullable<typeof twitchOutbound.resolveTarget>,
|
|
52
|
+
params: Parameters<NonNullable<typeof twitchOutbound.resolveTarget>>[0],
|
|
53
|
+
expectedMessage: string,
|
|
54
|
+
) {
|
|
55
|
+
const result = resolveTarget(params);
|
|
56
|
+
|
|
57
|
+
expect(result.ok).toBe(false);
|
|
58
|
+
if (result.ok) {
|
|
59
|
+
throw new Error("expected resolveTarget to fail");
|
|
60
|
+
}
|
|
61
|
+
expect(result.error.message).toContain(expectedMessage);
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
describe("outbound", () => {
|
|
50
65
|
const mockAccount = {
|
|
51
66
|
...BASE_TWITCH_TEST_ACCOUNT,
|
|
@@ -56,6 +71,20 @@ describe("outbound", () => {
|
|
|
56
71
|
const mockConfig = makeTwitchTestConfig(mockAccount);
|
|
57
72
|
installTwitchTestHooks();
|
|
58
73
|
|
|
74
|
+
function setupAccountContext(params?: {
|
|
75
|
+
account?: typeof mockAccount | null;
|
|
76
|
+
availableAccountIds?: string[];
|
|
77
|
+
}) {
|
|
78
|
+
const account = params?.account === undefined ? mockAccount : params.account;
|
|
79
|
+
vi.mocked(resolveTwitchAccountContext).mockImplementation((_cfg, accountId) => ({
|
|
80
|
+
accountId: accountId?.trim() || "default",
|
|
81
|
+
account,
|
|
82
|
+
tokenResolution: { source: "config", token: account?.accessToken ?? "" },
|
|
83
|
+
configured: account !== null,
|
|
84
|
+
availableAccountIds: params?.availableAccountIds ?? ["default"],
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
|
|
59
88
|
describe("metadata", () => {
|
|
60
89
|
it("should have direct delivery mode", () => {
|
|
61
90
|
expect(twitchOutbound.deliveryMode).toBe("direct");
|
|
@@ -65,9 +94,13 @@ describe("outbound", () => {
|
|
|
65
94
|
expect(twitchOutbound.textChunkLimit).toBe(500);
|
|
66
95
|
});
|
|
67
96
|
|
|
68
|
-
it("should
|
|
69
|
-
|
|
70
|
-
|
|
97
|
+
it("should chunk long messages at 500 characters", () => {
|
|
98
|
+
const chunker = twitchOutbound.chunker;
|
|
99
|
+
if (!chunker) {
|
|
100
|
+
throw new Error("twitch outbound.chunker unavailable");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
expect(chunker("a".repeat(600), 500)).toEqual(["a".repeat(500), "a".repeat(100)]);
|
|
71
104
|
});
|
|
72
105
|
});
|
|
73
106
|
|
|
@@ -106,17 +139,15 @@ describe("outbound", () => {
|
|
|
106
139
|
});
|
|
107
140
|
|
|
108
141
|
it("should error when target not in allowlist (implicit mode)", () => {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
expect(result.error.message).toContain("Twitch");
|
|
142
|
+
expectTargetError(
|
|
143
|
+
resolveTarget,
|
|
144
|
+
{
|
|
145
|
+
to: "#notallowed",
|
|
146
|
+
mode: "implicit",
|
|
147
|
+
allowFrom: ["#primary", "#secondary"],
|
|
148
|
+
},
|
|
149
|
+
"Twitch",
|
|
150
|
+
);
|
|
120
151
|
});
|
|
121
152
|
|
|
122
153
|
it("should accept any target when allowlist is empty", () => {
|
|
@@ -131,59 +162,51 @@ describe("outbound", () => {
|
|
|
131
162
|
});
|
|
132
163
|
|
|
133
164
|
it("should error when no target provided with allowlist", () => {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
expect(result.error.message).toContain("Twitch");
|
|
165
|
+
expectTargetError(
|
|
166
|
+
resolveTarget,
|
|
167
|
+
{
|
|
168
|
+
to: undefined,
|
|
169
|
+
mode: "implicit",
|
|
170
|
+
allowFrom: ["#fallback", "#other"],
|
|
171
|
+
},
|
|
172
|
+
"Twitch",
|
|
173
|
+
);
|
|
145
174
|
});
|
|
146
175
|
|
|
147
176
|
it("should return error when no target and no allowlist", () => {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
expect(result.error.message).toContain("Missing target");
|
|
177
|
+
expectTargetError(
|
|
178
|
+
resolveTarget,
|
|
179
|
+
{
|
|
180
|
+
to: undefined,
|
|
181
|
+
mode: "explicit",
|
|
182
|
+
allowFrom: [],
|
|
183
|
+
},
|
|
184
|
+
"Missing target",
|
|
185
|
+
);
|
|
159
186
|
});
|
|
160
187
|
|
|
161
188
|
it("should handle whitespace-only target", () => {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
expect(result.error.message).toContain("Missing target");
|
|
189
|
+
expectTargetError(
|
|
190
|
+
resolveTarget,
|
|
191
|
+
{
|
|
192
|
+
to: " ",
|
|
193
|
+
mode: "explicit",
|
|
194
|
+
allowFrom: [],
|
|
195
|
+
},
|
|
196
|
+
"Missing target",
|
|
197
|
+
);
|
|
173
198
|
});
|
|
174
199
|
|
|
175
200
|
it("should error when target normalizes to empty string", () => {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
186
|
-
expect(result.error.message).toContain("Twitch");
|
|
201
|
+
expectTargetError(
|
|
202
|
+
resolveTarget,
|
|
203
|
+
{
|
|
204
|
+
to: "#",
|
|
205
|
+
mode: "explicit",
|
|
206
|
+
allowFrom: [],
|
|
207
|
+
},
|
|
208
|
+
"Twitch",
|
|
209
|
+
);
|
|
187
210
|
});
|
|
188
211
|
|
|
189
212
|
it("should filter wildcard from allowlist when checking membership", () => {
|
|
@@ -201,10 +224,9 @@ describe("outbound", () => {
|
|
|
201
224
|
|
|
202
225
|
describe("sendText", () => {
|
|
203
226
|
it("should send message successfully", async () => {
|
|
204
|
-
const { getAccountConfig } = await import("./config.js");
|
|
205
227
|
const { sendMessageTwitchInternal } = await import("./send.js");
|
|
206
228
|
|
|
207
|
-
|
|
229
|
+
setupAccountContext();
|
|
208
230
|
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
|
209
231
|
ok: true,
|
|
210
232
|
messageId: "twitch-msg-123",
|
|
@@ -231,9 +253,7 @@ describe("outbound", () => {
|
|
|
231
253
|
});
|
|
232
254
|
|
|
233
255
|
it("should throw when account not found", async () => {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
vi.mocked(getAccountConfig).mockReturnValue(null);
|
|
256
|
+
setupAccountContext({ account: null });
|
|
237
257
|
|
|
238
258
|
await expect(
|
|
239
259
|
twitchOutbound.sendText!({
|
|
@@ -246,10 +266,8 @@ describe("outbound", () => {
|
|
|
246
266
|
});
|
|
247
267
|
|
|
248
268
|
it("should throw when no channel specified", async () => {
|
|
249
|
-
const { getAccountConfig } = await import("./config.js");
|
|
250
|
-
|
|
251
269
|
const accountWithoutChannel = { ...mockAccount, channel: undefined as unknown as string };
|
|
252
|
-
|
|
270
|
+
setupAccountContext({ account: accountWithoutChannel });
|
|
253
271
|
|
|
254
272
|
await expect(
|
|
255
273
|
twitchOutbound.sendText!({
|
|
@@ -262,10 +280,9 @@ describe("outbound", () => {
|
|
|
262
280
|
});
|
|
263
281
|
|
|
264
282
|
it("should use account channel when target not provided", async () => {
|
|
265
|
-
const { getAccountConfig } = await import("./config.js");
|
|
266
283
|
const { sendMessageTwitchInternal } = await import("./send.js");
|
|
267
284
|
|
|
268
|
-
|
|
285
|
+
setupAccountContext();
|
|
269
286
|
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
|
270
287
|
ok: true,
|
|
271
288
|
messageId: "msg-456",
|
|
@@ -288,6 +305,57 @@ describe("outbound", () => {
|
|
|
288
305
|
);
|
|
289
306
|
});
|
|
290
307
|
|
|
308
|
+
it("uses configured defaultAccount when accountId is omitted", async () => {
|
|
309
|
+
const { sendMessageTwitchInternal } = await import("./send.js");
|
|
310
|
+
|
|
311
|
+
vi.mocked(resolveTwitchAccountContext)
|
|
312
|
+
.mockImplementationOnce(() => ({
|
|
313
|
+
accountId: "secondary",
|
|
314
|
+
account: {
|
|
315
|
+
...mockAccount,
|
|
316
|
+
channel: "secondary-channel",
|
|
317
|
+
},
|
|
318
|
+
tokenResolution: { source: "config", token: mockAccount.accessToken },
|
|
319
|
+
configured: true,
|
|
320
|
+
availableAccountIds: ["default", "secondary"],
|
|
321
|
+
}))
|
|
322
|
+
.mockImplementation((_cfg, accountId) => ({
|
|
323
|
+
accountId: accountId?.trim() || "secondary",
|
|
324
|
+
account: {
|
|
325
|
+
...mockAccount,
|
|
326
|
+
channel: "secondary-channel",
|
|
327
|
+
},
|
|
328
|
+
tokenResolution: { source: "config", token: mockAccount.accessToken },
|
|
329
|
+
configured: true,
|
|
330
|
+
availableAccountIds: ["default", "secondary"],
|
|
331
|
+
}));
|
|
332
|
+
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
|
333
|
+
ok: true,
|
|
334
|
+
messageId: "msg-secondary",
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
await twitchOutbound.sendText!({
|
|
338
|
+
cfg: {
|
|
339
|
+
channels: {
|
|
340
|
+
twitch: {
|
|
341
|
+
defaultAccount: "secondary",
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
} as typeof mockConfig,
|
|
345
|
+
to: "#secondary-channel",
|
|
346
|
+
text: "Hello!",
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
|
350
|
+
"secondary-channel",
|
|
351
|
+
"Hello!",
|
|
352
|
+
expect.any(Object),
|
|
353
|
+
"secondary",
|
|
354
|
+
true,
|
|
355
|
+
console,
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
|
|
291
359
|
it("should handle abort signal", async () => {
|
|
292
360
|
const abortController = new AbortController();
|
|
293
361
|
abortController.abort();
|
|
@@ -304,10 +372,9 @@ describe("outbound", () => {
|
|
|
304
372
|
});
|
|
305
373
|
|
|
306
374
|
it("should throw on send failure", async () => {
|
|
307
|
-
const { getAccountConfig } = await import("./config.js");
|
|
308
375
|
const { sendMessageTwitchInternal } = await import("./send.js");
|
|
309
376
|
|
|
310
|
-
|
|
377
|
+
setupAccountContext();
|
|
311
378
|
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
|
312
379
|
ok: false,
|
|
313
380
|
messageId: "failed-msg",
|
|
@@ -328,9 +395,8 @@ describe("outbound", () => {
|
|
|
328
395
|
describe("sendMedia", () => {
|
|
329
396
|
it("should combine text and media URL", async () => {
|
|
330
397
|
const { sendMessageTwitchInternal } = await import("./send.js");
|
|
331
|
-
const { getAccountConfig } = await import("./config.js");
|
|
332
398
|
|
|
333
|
-
|
|
399
|
+
setupAccountContext();
|
|
334
400
|
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
|
335
401
|
ok: true,
|
|
336
402
|
messageId: "media-msg-123",
|
|
@@ -358,9 +424,8 @@ describe("outbound", () => {
|
|
|
358
424
|
|
|
359
425
|
it("should send media URL only when no text", async () => {
|
|
360
426
|
const { sendMessageTwitchInternal } = await import("./send.js");
|
|
361
|
-
const { getAccountConfig } = await import("./config.js");
|
|
362
427
|
|
|
363
|
-
|
|
428
|
+
setupAccountContext();
|
|
364
429
|
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
|
365
430
|
ok: true,
|
|
366
431
|
messageId: "media-only-msg",
|
package/src/outbound.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Supports text and media (URL) sending with markdown stripping and chunking.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { resolveTwitchAccountContext } from "./config.js";
|
|
9
9
|
import { sendMessageTwitchInternal } from "./send.js";
|
|
10
10
|
import type {
|
|
11
11
|
ChannelOutboundAdapter,
|
|
@@ -113,13 +113,12 @@ export const twitchOutbound: ChannelOutboundAdapter = {
|
|
|
113
113
|
throw new Error("Outbound delivery aborted");
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
const resolvedAccountId = accountId ??
|
|
117
|
-
const account =
|
|
116
|
+
const resolvedAccountId = accountId ?? resolveTwitchAccountContext(cfg).accountId;
|
|
117
|
+
const { account, availableAccountIds } = resolveTwitchAccountContext(cfg, resolvedAccountId);
|
|
118
118
|
if (!account) {
|
|
119
|
-
const availableIds = Object.keys(cfg.channels?.twitch?.accounts ?? {});
|
|
120
119
|
throw new Error(
|
|
121
120
|
`Twitch account not found: ${resolvedAccountId}. ` +
|
|
122
|
-
`Available accounts: ${
|
|
121
|
+
`Available accounts: ${availableAccountIds.join(", ") || "none"}`,
|
|
123
122
|
);
|
|
124
123
|
}
|
|
125
124
|
|
package/src/plugin.test.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { OpenClawConfig } from "../api.js";
|
|
3
3
|
import { twitchPlugin } from "./plugin.js";
|
|
4
4
|
|
|
5
|
+
describe("twitchPlugin pairing", () => {
|
|
6
|
+
it("normalizes trimmed twitch user prefixes in allow entries", () => {
|
|
7
|
+
expect(twitchPlugin.pairing?.normalizeAllowEntry?.(" twitch:user:123456 ")).toBe("123456");
|
|
8
|
+
expect(twitchPlugin.pairing?.normalizeAllowEntry?.(" user789012 ")).toBe("789012");
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
|
|
5
12
|
describe("twitchPlugin.status.buildAccountSnapshot", () => {
|
|
6
13
|
it("uses the resolved account ID for multi-account configs", async () => {
|
|
7
14
|
const secondary = {
|
|
@@ -37,3 +44,34 @@ describe("twitchPlugin.status.buildAccountSnapshot", () => {
|
|
|
37
44
|
expect(snapshot?.accountId).toBe("secondary");
|
|
38
45
|
});
|
|
39
46
|
});
|
|
47
|
+
|
|
48
|
+
describe("twitchPlugin.config", () => {
|
|
49
|
+
it("uses configured defaultAccount for omitted-account plugin resolution", () => {
|
|
50
|
+
const cfg = {
|
|
51
|
+
channels: {
|
|
52
|
+
twitch: {
|
|
53
|
+
defaultAccount: "secondary",
|
|
54
|
+
accounts: {
|
|
55
|
+
default: {
|
|
56
|
+
channel: "default-channel",
|
|
57
|
+
username: "default",
|
|
58
|
+
accessToken: "oauth:default-token",
|
|
59
|
+
clientId: "default-client",
|
|
60
|
+
enabled: true,
|
|
61
|
+
},
|
|
62
|
+
secondary: {
|
|
63
|
+
channel: "secondary-channel",
|
|
64
|
+
username: "secondary",
|
|
65
|
+
accessToken: "oauth:secondary-token",
|
|
66
|
+
clientId: "secondary-client",
|
|
67
|
+
enabled: true,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
} as OpenClawConfig;
|
|
73
|
+
|
|
74
|
+
expect(twitchPlugin.config.defaultAccountId?.(cfg)).toBe("secondary");
|
|
75
|
+
expect(twitchPlugin.config.resolveAccount(cfg).accountId).toBe("secondary");
|
|
76
|
+
});
|
|
77
|
+
});
|