@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
|
@@ -17,16 +17,79 @@ describe("checkTwitchAccessControl", () => {
|
|
|
17
17
|
channel: "testchannel",
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
function runAccessCheck(params: {
|
|
21
|
+
account?: Partial<TwitchAccountConfig>;
|
|
22
|
+
message?: Partial<TwitchChatMessage>;
|
|
23
|
+
}) {
|
|
24
|
+
return checkTwitchAccessControl({
|
|
25
|
+
message: {
|
|
23
26
|
...mockMessage,
|
|
27
|
+
...params.message,
|
|
28
|
+
},
|
|
29
|
+
account: {
|
|
30
|
+
...mockAccount,
|
|
31
|
+
...params.account,
|
|
32
|
+
},
|
|
33
|
+
botUsername: "testbot",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function expectSingleRoleAllowed(params: {
|
|
38
|
+
role: NonNullable<TwitchAccountConfig["allowedRoles"]>[number];
|
|
39
|
+
message: Partial<TwitchChatMessage>;
|
|
40
|
+
}) {
|
|
41
|
+
const result = runAccessCheck({
|
|
42
|
+
account: { allowedRoles: [params.role] },
|
|
43
|
+
message: {
|
|
24
44
|
message: "@testbot hello",
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
45
|
+
...params.message,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
expect(result.allowed).toBe(true);
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function expectAllowedAccessCheck(params: {
|
|
53
|
+
account?: Partial<TwitchAccountConfig>;
|
|
54
|
+
message?: Partial<TwitchChatMessage>;
|
|
55
|
+
}) {
|
|
56
|
+
const result = runAccessCheck({
|
|
57
|
+
account: params.account,
|
|
58
|
+
message: {
|
|
59
|
+
message: "@testbot hello",
|
|
60
|
+
...params.message,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
expect(result.allowed).toBe(true);
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function expectAllowFromBlocked(params: {
|
|
68
|
+
allowFrom: string[];
|
|
69
|
+
allowedRoles?: NonNullable<TwitchAccountConfig["allowedRoles"]>;
|
|
70
|
+
message?: Partial<TwitchChatMessage>;
|
|
71
|
+
reason: string;
|
|
72
|
+
}) {
|
|
73
|
+
const result = runAccessCheck({
|
|
74
|
+
account: {
|
|
75
|
+
allowFrom: params.allowFrom,
|
|
76
|
+
allowedRoles: params.allowedRoles,
|
|
77
|
+
},
|
|
78
|
+
message: {
|
|
79
|
+
message: "@testbot hello",
|
|
80
|
+
...params.message,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
expect(result.allowed).toBe(false);
|
|
84
|
+
expect(result.reason).toContain(params.reason);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe("when no restrictions are configured", () => {
|
|
88
|
+
it("allows messages that mention the bot (default requireMention)", () => {
|
|
89
|
+
const result = runAccessCheck({
|
|
90
|
+
message: {
|
|
91
|
+
message: "@testbot hello",
|
|
92
|
+
},
|
|
30
93
|
});
|
|
31
94
|
expect(result.allowed).toBe(true);
|
|
32
95
|
});
|
|
@@ -34,30 +97,20 @@ describe("checkTwitchAccessControl", () => {
|
|
|
34
97
|
|
|
35
98
|
describe("requireMention default", () => {
|
|
36
99
|
it("defaults to true when undefined", () => {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const result = checkTwitchAccessControl({
|
|
43
|
-
message,
|
|
44
|
-
account: mockAccount,
|
|
45
|
-
botUsername: "testbot",
|
|
100
|
+
const result = runAccessCheck({
|
|
101
|
+
message: {
|
|
102
|
+
message: "hello bot",
|
|
103
|
+
},
|
|
46
104
|
});
|
|
47
105
|
expect(result.allowed).toBe(false);
|
|
48
106
|
expect(result.reason).toContain("does not mention the bot");
|
|
49
107
|
});
|
|
50
108
|
|
|
51
109
|
it("allows mention when requireMention is undefined", () => {
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const result = checkTwitchAccessControl({
|
|
58
|
-
message,
|
|
59
|
-
account: mockAccount,
|
|
60
|
-
botUsername: "testbot",
|
|
110
|
+
const result = runAccessCheck({
|
|
111
|
+
message: {
|
|
112
|
+
message: "@testbot hello",
|
|
113
|
+
},
|
|
61
114
|
});
|
|
62
115
|
expect(result.allowed).toBe(true);
|
|
63
116
|
});
|
|
@@ -65,52 +118,25 @@ describe("checkTwitchAccessControl", () => {
|
|
|
65
118
|
|
|
66
119
|
describe("requireMention", () => {
|
|
67
120
|
it("allows messages that mention the bot", () => {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
};
|
|
72
|
-
const message: TwitchChatMessage = {
|
|
73
|
-
...mockMessage,
|
|
74
|
-
message: "@testbot hello",
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
const result = checkTwitchAccessControl({
|
|
78
|
-
message,
|
|
79
|
-
account,
|
|
80
|
-
botUsername: "testbot",
|
|
121
|
+
const result = runAccessCheck({
|
|
122
|
+
account: { requireMention: true },
|
|
123
|
+
message: { message: "@testbot hello" },
|
|
81
124
|
});
|
|
82
125
|
expect(result.allowed).toBe(true);
|
|
83
126
|
});
|
|
84
127
|
|
|
85
128
|
it("blocks messages that don't mention the bot", () => {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
requireMention: true,
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
const result = checkTwitchAccessControl({
|
|
92
|
-
message: mockMessage,
|
|
93
|
-
account,
|
|
94
|
-
botUsername: "testbot",
|
|
129
|
+
const result = runAccessCheck({
|
|
130
|
+
account: { requireMention: true },
|
|
95
131
|
});
|
|
96
132
|
expect(result.allowed).toBe(false);
|
|
97
133
|
expect(result.reason).toContain("does not mention the bot");
|
|
98
134
|
});
|
|
99
135
|
|
|
100
136
|
it("is case-insensitive for bot username", () => {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
};
|
|
105
|
-
const message: TwitchChatMessage = {
|
|
106
|
-
...mockMessage,
|
|
107
|
-
message: "@TestBot hello",
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
const result = checkTwitchAccessControl({
|
|
111
|
-
message,
|
|
112
|
-
account,
|
|
113
|
-
botUsername: "testbot",
|
|
137
|
+
const result = runAccessCheck({
|
|
138
|
+
account: { requireMention: true },
|
|
139
|
+
message: { message: "@TestBot hello" },
|
|
114
140
|
});
|
|
115
141
|
expect(result.allowed).toBe(true);
|
|
116
142
|
});
|
|
@@ -118,62 +144,35 @@ describe("checkTwitchAccessControl", () => {
|
|
|
118
144
|
|
|
119
145
|
describe("allowFrom allowlist", () => {
|
|
120
146
|
it("allows users in the allowlist", () => {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const message: TwitchChatMessage = {
|
|
126
|
-
...mockMessage,
|
|
127
|
-
message: "@testbot hello",
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
const result = checkTwitchAccessControl({
|
|
131
|
-
message,
|
|
132
|
-
account,
|
|
133
|
-
botUsername: "testbot",
|
|
147
|
+
const result = expectAllowedAccessCheck({
|
|
148
|
+
account: {
|
|
149
|
+
allowFrom: ["123456", "789012"],
|
|
150
|
+
},
|
|
134
151
|
});
|
|
135
|
-
expect(result.allowed).toBe(true);
|
|
136
152
|
expect(result.matchKey).toBe("123456");
|
|
137
153
|
expect(result.matchSource).toBe("allowlist");
|
|
138
154
|
});
|
|
139
155
|
|
|
140
156
|
it("blocks users not in allowlist when allowFrom is set", () => {
|
|
141
|
-
|
|
142
|
-
...mockAccount,
|
|
157
|
+
expectAllowFromBlocked({
|
|
143
158
|
allowFrom: ["789012"],
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
message: "@testbot hello",
|
|
148
|
-
};
|
|
159
|
+
reason: "allowFrom",
|
|
160
|
+
});
|
|
161
|
+
});
|
|
149
162
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
163
|
+
it("blocks everyone when allowFrom is explicitly empty", () => {
|
|
164
|
+
expectAllowFromBlocked({
|
|
165
|
+
allowFrom: [],
|
|
166
|
+
reason: "allowFrom",
|
|
154
167
|
});
|
|
155
|
-
expect(result.allowed).toBe(false);
|
|
156
|
-
expect(result.reason).toContain("allowFrom");
|
|
157
168
|
});
|
|
158
169
|
|
|
159
170
|
it("blocks messages without userId", () => {
|
|
160
|
-
|
|
161
|
-
...mockAccount,
|
|
171
|
+
expectAllowFromBlocked({
|
|
162
172
|
allowFrom: ["123456"],
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
...mockMessage,
|
|
166
|
-
message: "@testbot hello",
|
|
167
|
-
userId: undefined,
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
const result = checkTwitchAccessControl({
|
|
171
|
-
message,
|
|
172
|
-
account,
|
|
173
|
-
botUsername: "testbot",
|
|
173
|
+
message: { userId: undefined },
|
|
174
|
+
reason: "user ID not available",
|
|
174
175
|
});
|
|
175
|
-
expect(result.allowed).toBe(false);
|
|
176
|
-
expect(result.reason).toContain("user ID not available");
|
|
177
176
|
});
|
|
178
177
|
|
|
179
178
|
it("bypasses role checks when user is in allowlist", () => {
|
|
@@ -197,68 +196,30 @@ describe("checkTwitchAccessControl", () => {
|
|
|
197
196
|
});
|
|
198
197
|
|
|
199
198
|
it("blocks user with role when not in allowlist", () => {
|
|
200
|
-
|
|
201
|
-
...mockAccount,
|
|
199
|
+
expectAllowFromBlocked({
|
|
202
200
|
allowFrom: ["789012"],
|
|
203
201
|
allowedRoles: ["moderator"],
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
...mockMessage,
|
|
207
|
-
message: "@testbot hello",
|
|
208
|
-
userId: "123456",
|
|
209
|
-
isMod: true,
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
const result = checkTwitchAccessControl({
|
|
213
|
-
message,
|
|
214
|
-
account,
|
|
215
|
-
botUsername: "testbot",
|
|
202
|
+
message: { userId: "123456", isMod: true },
|
|
203
|
+
reason: "allowFrom",
|
|
216
204
|
});
|
|
217
|
-
expect(result.allowed).toBe(false);
|
|
218
|
-
expect(result.reason).toContain("allowFrom");
|
|
219
205
|
});
|
|
220
206
|
|
|
221
207
|
it("blocks user not in allowlist even when roles configured", () => {
|
|
222
|
-
|
|
223
|
-
...mockAccount,
|
|
208
|
+
expectAllowFromBlocked({
|
|
224
209
|
allowFrom: ["789012"],
|
|
225
210
|
allowedRoles: ["moderator"],
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
...mockMessage,
|
|
229
|
-
message: "@testbot hello",
|
|
230
|
-
userId: "123456",
|
|
231
|
-
isMod: false,
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
const result = checkTwitchAccessControl({
|
|
235
|
-
message,
|
|
236
|
-
account,
|
|
237
|
-
botUsername: "testbot",
|
|
211
|
+
message: { userId: "123456", isMod: false },
|
|
212
|
+
reason: "allowFrom",
|
|
238
213
|
});
|
|
239
|
-
expect(result.allowed).toBe(false);
|
|
240
|
-
expect(result.reason).toContain("allowFrom");
|
|
241
214
|
});
|
|
242
215
|
});
|
|
243
216
|
|
|
244
217
|
describe("allowedRoles", () => {
|
|
245
218
|
it("allows users with matching role", () => {
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
};
|
|
250
|
-
const message: TwitchChatMessage = {
|
|
251
|
-
...mockMessage,
|
|
252
|
-
message: "@testbot hello",
|
|
253
|
-
isMod: true,
|
|
254
|
-
};
|
|
255
|
-
|
|
256
|
-
const result = checkTwitchAccessControl({
|
|
257
|
-
message,
|
|
258
|
-
account,
|
|
259
|
-
botUsername: "testbot",
|
|
219
|
+
const result = expectSingleRoleAllowed({
|
|
220
|
+
role: "moderator",
|
|
221
|
+
message: { isMod: true },
|
|
260
222
|
});
|
|
261
|
-
expect(result.allowed).toBe(true);
|
|
262
223
|
expect(result.matchSource).toBe("role");
|
|
263
224
|
});
|
|
264
225
|
|
|
@@ -304,98 +265,40 @@ describe("checkTwitchAccessControl", () => {
|
|
|
304
265
|
});
|
|
305
266
|
|
|
306
267
|
it("allows all users when role is 'all'", () => {
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const message: TwitchChatMessage = {
|
|
312
|
-
...mockMessage,
|
|
313
|
-
message: "@testbot hello",
|
|
314
|
-
};
|
|
315
|
-
|
|
316
|
-
const result = checkTwitchAccessControl({
|
|
317
|
-
message,
|
|
318
|
-
account,
|
|
319
|
-
botUsername: "testbot",
|
|
268
|
+
const result = expectAllowedAccessCheck({
|
|
269
|
+
account: {
|
|
270
|
+
allowedRoles: ["all"],
|
|
271
|
+
},
|
|
320
272
|
});
|
|
321
|
-
expect(result.allowed).toBe(true);
|
|
322
273
|
expect(result.matchKey).toBe("all");
|
|
323
274
|
});
|
|
324
275
|
|
|
325
276
|
it("handles moderator role", () => {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
};
|
|
330
|
-
const message: TwitchChatMessage = {
|
|
331
|
-
...mockMessage,
|
|
332
|
-
message: "@testbot hello",
|
|
333
|
-
isMod: true,
|
|
334
|
-
};
|
|
335
|
-
|
|
336
|
-
const result = checkTwitchAccessControl({
|
|
337
|
-
message,
|
|
338
|
-
account,
|
|
339
|
-
botUsername: "testbot",
|
|
277
|
+
expectSingleRoleAllowed({
|
|
278
|
+
role: "moderator",
|
|
279
|
+
message: { isMod: true },
|
|
340
280
|
});
|
|
341
|
-
expect(result.allowed).toBe(true);
|
|
342
281
|
});
|
|
343
282
|
|
|
344
283
|
it("handles subscriber role", () => {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
};
|
|
349
|
-
const message: TwitchChatMessage = {
|
|
350
|
-
...mockMessage,
|
|
351
|
-
message: "@testbot hello",
|
|
352
|
-
isSub: true,
|
|
353
|
-
};
|
|
354
|
-
|
|
355
|
-
const result = checkTwitchAccessControl({
|
|
356
|
-
message,
|
|
357
|
-
account,
|
|
358
|
-
botUsername: "testbot",
|
|
284
|
+
expectSingleRoleAllowed({
|
|
285
|
+
role: "subscriber",
|
|
286
|
+
message: { isSub: true },
|
|
359
287
|
});
|
|
360
|
-
expect(result.allowed).toBe(true);
|
|
361
288
|
});
|
|
362
289
|
|
|
363
290
|
it("handles owner role", () => {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
};
|
|
368
|
-
const message: TwitchChatMessage = {
|
|
369
|
-
...mockMessage,
|
|
370
|
-
message: "@testbot hello",
|
|
371
|
-
isOwner: true,
|
|
372
|
-
};
|
|
373
|
-
|
|
374
|
-
const result = checkTwitchAccessControl({
|
|
375
|
-
message,
|
|
376
|
-
account,
|
|
377
|
-
botUsername: "testbot",
|
|
291
|
+
expectSingleRoleAllowed({
|
|
292
|
+
role: "owner",
|
|
293
|
+
message: { isOwner: true },
|
|
378
294
|
});
|
|
379
|
-
expect(result.allowed).toBe(true);
|
|
380
295
|
});
|
|
381
296
|
|
|
382
297
|
it("handles vip role", () => {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
};
|
|
387
|
-
const message: TwitchChatMessage = {
|
|
388
|
-
...mockMessage,
|
|
389
|
-
message: "@testbot hello",
|
|
390
|
-
isVip: true,
|
|
391
|
-
};
|
|
392
|
-
|
|
393
|
-
const result = checkTwitchAccessControl({
|
|
394
|
-
message,
|
|
395
|
-
account,
|
|
396
|
-
botUsername: "testbot",
|
|
298
|
+
expectSingleRoleAllowed({
|
|
299
|
+
role: "vip",
|
|
300
|
+
message: { isVip: true },
|
|
397
301
|
});
|
|
398
|
-
expect(result.allowed).toBe(true);
|
|
399
302
|
});
|
|
400
303
|
});
|
|
401
304
|
|
|
@@ -421,21 +324,15 @@ describe("checkTwitchAccessControl", () => {
|
|
|
421
324
|
});
|
|
422
325
|
|
|
423
326
|
it("checks allowlist before allowedRoles", () => {
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
};
|
|
434
|
-
|
|
435
|
-
const result = checkTwitchAccessControl({
|
|
436
|
-
message,
|
|
437
|
-
account,
|
|
438
|
-
botUsername: "testbot",
|
|
327
|
+
const result = runAccessCheck({
|
|
328
|
+
account: {
|
|
329
|
+
allowFrom: ["123456"],
|
|
330
|
+
allowedRoles: ["owner"],
|
|
331
|
+
},
|
|
332
|
+
message: {
|
|
333
|
+
message: "@testbot hello",
|
|
334
|
+
isOwner: false,
|
|
335
|
+
},
|
|
439
336
|
});
|
|
440
337
|
expect(result.allowed).toBe(true);
|
|
441
338
|
expect(result.matchSource).toBe("allowlist");
|
package/src/access-control.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
|
1
2
|
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Result of checking access control for a Twitch message
|
|
5
6
|
*/
|
|
6
|
-
|
|
7
|
+
type TwitchAccessControlResult = {
|
|
7
8
|
allowed: boolean;
|
|
8
9
|
reason?: string;
|
|
9
10
|
matchKey?: string;
|
|
@@ -40,7 +41,7 @@ export function checkTwitchAccessControl(params: {
|
|
|
40
41
|
|
|
41
42
|
if (account.requireMention ?? true) {
|
|
42
43
|
const mentions = extractMentions(message.message);
|
|
43
|
-
if (!mentions.includes(botUsername
|
|
44
|
+
if (!mentions.includes(normalizeLowercaseStringOrEmpty(botUsername))) {
|
|
44
45
|
return {
|
|
45
46
|
allowed: false,
|
|
46
47
|
reason: "message does not mention the bot (requireMention is enabled)",
|
|
@@ -48,8 +49,14 @@ export function checkTwitchAccessControl(params: {
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
if (account.allowFrom
|
|
52
|
+
if (account.allowFrom !== undefined) {
|
|
52
53
|
const allowFrom = account.allowFrom;
|
|
54
|
+
if (allowFrom.length === 0) {
|
|
55
|
+
return {
|
|
56
|
+
allowed: false,
|
|
57
|
+
reason: "sender is not in allowFrom allowlist",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
53
60
|
const senderId = message.userId;
|
|
54
61
|
|
|
55
62
|
if (!senderId) {
|
|
@@ -158,7 +165,7 @@ export function extractMentions(message: string): string[] {
|
|
|
158
165
|
while ((match = mentionRegex.exec(message)) !== null) {
|
|
159
166
|
const username = match[1];
|
|
160
167
|
if (username) {
|
|
161
|
-
mentions.push(username
|
|
168
|
+
mentions.push(normalizeLowercaseStringOrEmpty(username));
|
|
162
169
|
}
|
|
163
170
|
}
|
|
164
171
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
+
import { twitchMessageActions } from "./actions.js";
|
|
3
|
+
import type { ResolvedTwitchAccountContext } from "./config.js";
|
|
4
|
+
import { resolveTwitchAccountContext } from "./config.js";
|
|
5
|
+
import { twitchOutbound } from "./outbound.js";
|
|
6
|
+
|
|
7
|
+
vi.mock("./config.js", () => ({
|
|
8
|
+
DEFAULT_ACCOUNT_ID: "default",
|
|
9
|
+
resolveTwitchAccountContext: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("./outbound.js", () => ({
|
|
13
|
+
twitchOutbound: {
|
|
14
|
+
sendText: vi.fn(),
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
function createSecondaryAccountContext(accountId = "secondary"): ResolvedTwitchAccountContext {
|
|
19
|
+
return {
|
|
20
|
+
accountId,
|
|
21
|
+
account: {
|
|
22
|
+
channel: "secondary-channel",
|
|
23
|
+
username: "secondary",
|
|
24
|
+
accessToken: "oauth:secondary-token",
|
|
25
|
+
clientId: "secondary-client",
|
|
26
|
+
enabled: true,
|
|
27
|
+
},
|
|
28
|
+
tokenResolution: { source: "config", token: "oauth:secondary-token" },
|
|
29
|
+
configured: true,
|
|
30
|
+
availableAccountIds: ["default", "secondary"],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("twitchMessageActions", () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
vi.clearAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("uses configured defaultAccount when action accountId is omitted", async () => {
|
|
40
|
+
vi.mocked(resolveTwitchAccountContext)
|
|
41
|
+
.mockImplementationOnce(() => createSecondaryAccountContext())
|
|
42
|
+
.mockImplementation((_cfg, accountId) =>
|
|
43
|
+
createSecondaryAccountContext(accountId?.trim() || "secondary"),
|
|
44
|
+
);
|
|
45
|
+
const sendText = twitchOutbound.sendText;
|
|
46
|
+
if (!sendText) {
|
|
47
|
+
throw new Error("twitchOutbound.sendText is unavailable");
|
|
48
|
+
}
|
|
49
|
+
vi.mocked(sendText).mockResolvedValue({
|
|
50
|
+
channel: "twitch",
|
|
51
|
+
messageId: "msg-1",
|
|
52
|
+
timestamp: 1,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await twitchMessageActions.handleAction!({
|
|
56
|
+
action: "send",
|
|
57
|
+
params: { message: "Hello!" },
|
|
58
|
+
cfg: {
|
|
59
|
+
channels: {
|
|
60
|
+
twitch: {
|
|
61
|
+
defaultAccount: "secondary",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
} as never);
|
|
66
|
+
|
|
67
|
+
expect(twitchOutbound.sendText).toHaveBeenCalledWith(
|
|
68
|
+
expect.objectContaining({
|
|
69
|
+
accountId: "secondary",
|
|
70
|
+
to: "secondary-channel",
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
});
|
package/src/actions.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Handles tool-based actions for Twitch, such as sending messages.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
8
|
+
import { resolveTwitchAccountContext } from "./config.js";
|
|
8
9
|
import { twitchOutbound } from "./outbound.js";
|
|
9
10
|
import type { ChannelMessageActionAdapter, ChannelMessageActionContext } from "./types.js";
|
|
10
11
|
|
|
@@ -68,7 +69,7 @@ export const twitchMessageActions: ChannelMessageActionAdapter = {
|
|
|
68
69
|
/**
|
|
69
70
|
* List available actions for this channel.
|
|
70
71
|
*/
|
|
71
|
-
|
|
72
|
+
describeMessageTool: () => ({ actions: [...TWITCH_ACTIONS] }),
|
|
72
73
|
|
|
73
74
|
/**
|
|
74
75
|
* Check if an action is supported.
|
|
@@ -130,12 +131,12 @@ export const twitchMessageActions: ChannelMessageActionAdapter = {
|
|
|
130
131
|
|
|
131
132
|
const message = readStringParam(ctx.params, "message", { required: true });
|
|
132
133
|
const to = readStringParam(ctx.params, "to", { required: false });
|
|
133
|
-
const accountId = ctx.accountId ??
|
|
134
|
+
const accountId = ctx.accountId ?? resolveTwitchAccountContext(ctx.cfg).accountId;
|
|
134
135
|
|
|
135
|
-
const account =
|
|
136
|
+
const { account, availableAccountIds } = resolveTwitchAccountContext(ctx.cfg, accountId);
|
|
136
137
|
if (!account) {
|
|
137
138
|
return errorResponse(
|
|
138
|
-
`Account not found: ${accountId}. Available accounts: ${
|
|
139
|
+
`Account not found: ${accountId}. Available accounts: ${availableAccountIds.join(", ") || "none"}`,
|
|
139
140
|
);
|
|
140
141
|
}
|
|
141
142
|
|
|
@@ -167,7 +168,7 @@ export const twitchMessageActions: ChannelMessageActionAdapter = {
|
|
|
167
168
|
details: { ok: true },
|
|
168
169
|
};
|
|
169
170
|
} catch (error) {
|
|
170
|
-
const errorMsg =
|
|
171
|
+
const errorMsg = formatErrorMessage(error);
|
|
171
172
|
return errorResponse(errorMsg);
|
|
172
173
|
}
|
|
173
174
|
},
|
|
@@ -85,31 +85,3 @@ export async function removeClientManager(accountId: string): Promise<void> {
|
|
|
85
85
|
registry.delete(accountId);
|
|
86
86
|
entry.logger.info(`Unregistered client manager for account: ${accountId}`);
|
|
87
87
|
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Disconnect and remove all client managers from the registry.
|
|
91
|
-
*
|
|
92
|
-
* @returns Promise that resolves when all cleanup is complete
|
|
93
|
-
*/
|
|
94
|
-
export async function removeAllClientManagers(): Promise<void> {
|
|
95
|
-
const promises = [...registry.keys()].map((accountId) => removeClientManager(accountId));
|
|
96
|
-
await Promise.all(promises);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Get the number of registered client managers.
|
|
101
|
-
*
|
|
102
|
-
* @returns The count of registered managers
|
|
103
|
-
*/
|
|
104
|
-
export function getRegisteredClientManagerCount(): number {
|
|
105
|
-
return registry.size;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Clear all client managers without disconnecting.
|
|
110
|
-
*
|
|
111
|
-
* This is primarily for testing purposes.
|
|
112
|
-
*/
|
|
113
|
-
export function _clearAllClientManagersForTest(): void {
|
|
114
|
-
registry.clear();
|
|
115
|
-
}
|