@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 ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## 2026.1.23
4
+
5
+ ### Features
6
+
7
+ - Initial Twitch plugin release
8
+ - Twitch chat integration via @twurple (IRC connection)
9
+ - Multi-account support with per-channel configuration
10
+ - Access control via user ID allowlists and role-based restrictions
11
+ - Automatic token refresh with RefreshingAuthProvider
12
+ - Environment variable fallback for default account token
13
+ - Message actions support
14
+ - Status monitoring and probing
15
+ - Outbound message delivery with markdown stripping
16
+
17
+ ### Improvements
18
+
19
+ - Added proper configuration schema with Zod validation
20
+ - Added plugin descriptor (openclaw.plugin.json)
21
+ - Added comprehensive README and documentation
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # @openclaw/twitch
2
+
3
+ Twitch channel plugin for OpenClaw.
4
+
5
+ ## Install (local checkout)
6
+
7
+ ```bash
8
+ openclaw plugins install ./extensions/twitch
9
+ ```
10
+
11
+ ## Install (npm)
12
+
13
+ ```bash
14
+ openclaw plugins install @openclaw/twitch
15
+ ```
16
+
17
+ Onboarding: select Twitch and confirm the install prompt to fetch the plugin automatically.
18
+
19
+ ## Config
20
+
21
+ Minimal config (simplified single-account):
22
+
23
+ **⚠️ Important:** `requireMention` defaults to `true`. Add access control (`allowFrom` or `allowedRoles`) to prevent unauthorized users from triggering the bot.
24
+
25
+ ```json5
26
+ {
27
+ channels: {
28
+ twitch: {
29
+ enabled: true,
30
+ username: "openclaw",
31
+ accessToken: "oauth:abc123...", // OAuth Access Token (add oauth: prefix)
32
+ clientId: "xyz789...", // Client ID from Token Generator
33
+ channel: "vevisk", // Channel to join (required)
34
+ allowFrom: ["123456789"], // (recommended) Your Twitch user ID only (Convert your twitch username to ID at https://www.streamweasels.com/tools/convert-twitch-username-%20to-user-id/)
35
+ },
36
+ },
37
+ }
38
+ ```
39
+
40
+ **Access control options:**
41
+
42
+ - `requireMention: false` - Disable the default mention requirement to respond to all messages
43
+ - `allowFrom: ["your_user_id"]` - Restrict to your Twitch user ID only (find your ID at https://www.twitchangles.com/xqc or similar)
44
+ - `allowedRoles: ["moderator", "vip", "subscriber"]` - Restrict to specific roles
45
+
46
+ Multi-account config (advanced):
47
+
48
+ ```json5
49
+ {
50
+ channels: {
51
+ twitch: {
52
+ enabled: true,
53
+ accounts: {
54
+ default: {
55
+ username: "openclaw",
56
+ accessToken: "oauth:abc123...",
57
+ clientId: "xyz789...",
58
+ channel: "vevisk",
59
+ },
60
+ channel2: {
61
+ username: "openclaw",
62
+ accessToken: "oauth:def456...",
63
+ clientId: "uvw012...",
64
+ channel: "secondchannel",
65
+ },
66
+ },
67
+ },
68
+ },
69
+ }
70
+ ```
71
+
72
+ ## Setup
73
+
74
+ 1. Create a dedicated Twitch account for the bot, then generate credentials: [Twitch Token Generator](https://twitchtokengenerator.com/)
75
+ - Select **Bot Token**
76
+ - Verify scopes `chat:read` and `chat:write` are selected
77
+ - Copy the **Access Token** to `token` property
78
+ - Copy the **Client ID** to `clientId` property
79
+ 2. Start the gateway
80
+
81
+ ## Full documentation
82
+
83
+ See https://docs.openclaw.ai/channels/twitch for:
84
+
85
+ - Token refresh setup
86
+ - Access control patterns
87
+ - Multi-account configuration
88
+ - Troubleshooting
89
+ - Capabilities & limits
package/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
3
+ import { twitchPlugin } from "./src/plugin.js";
4
+ import { setTwitchRuntime } from "./src/runtime.js";
5
+
6
+ export { monitorTwitchProvider } from "./src/monitor.js";
7
+
8
+ const plugin = {
9
+ id: "twitch",
10
+ name: "Twitch",
11
+ description: "Twitch channel plugin",
12
+ configSchema: emptyPluginConfigSchema(),
13
+ register(api: OpenClawPluginApi) {
14
+ setTwitchRuntime(api.runtime);
15
+ // oxlint-disable-next-line typescript/no-explicit-any
16
+ api.registerChannel({ plugin: twitchPlugin as any });
17
+ },
18
+ };
19
+
20
+ export default plugin;
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "twitch",
3
+ "channels": ["twitch"],
4
+ "configSchema": {
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {}
8
+ }
9
+ }
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@openclaw/twitch",
3
+ "version": "2026.2.21",
4
+ "description": "OpenClaw Twitch channel plugin",
5
+ "type": "module",
6
+ "dependencies": {
7
+ "@twurple/api": "^8.0.3",
8
+ "@twurple/auth": "^8.0.3",
9
+ "@twurple/chat": "^8.0.3",
10
+ "zod": "^4.3.6"
11
+ },
12
+ "devDependencies": {
13
+ "openclaw": "workspace:*"
14
+ },
15
+ "openclaw": {
16
+ "extensions": [
17
+ "./index.ts"
18
+ ]
19
+ }
20
+ }
@@ -0,0 +1,491 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { checkTwitchAccessControl, extractMentions } from "./access-control.js";
3
+ import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
4
+
5
+ describe("checkTwitchAccessControl", () => {
6
+ const mockAccount: TwitchAccountConfig = {
7
+ username: "testbot",
8
+ accessToken: "test",
9
+ clientId: "test-client-id",
10
+ channel: "testchannel",
11
+ };
12
+
13
+ const mockMessage: TwitchChatMessage = {
14
+ username: "testuser",
15
+ userId: "123456",
16
+ message: "hello bot",
17
+ channel: "testchannel",
18
+ };
19
+
20
+ describe("when no restrictions are configured", () => {
21
+ it("allows messages that mention the bot (default requireMention)", () => {
22
+ const message: TwitchChatMessage = {
23
+ ...mockMessage,
24
+ message: "@testbot hello",
25
+ };
26
+ const result = checkTwitchAccessControl({
27
+ message,
28
+ account: mockAccount,
29
+ botUsername: "testbot",
30
+ });
31
+ expect(result.allowed).toBe(true);
32
+ });
33
+ });
34
+
35
+ describe("requireMention default", () => {
36
+ it("defaults to true when undefined", () => {
37
+ const message: TwitchChatMessage = {
38
+ ...mockMessage,
39
+ message: "hello bot",
40
+ };
41
+
42
+ const result = checkTwitchAccessControl({
43
+ message,
44
+ account: mockAccount,
45
+ botUsername: "testbot",
46
+ });
47
+ expect(result.allowed).toBe(false);
48
+ expect(result.reason).toContain("does not mention the bot");
49
+ });
50
+
51
+ it("allows mention when requireMention is undefined", () => {
52
+ const message: TwitchChatMessage = {
53
+ ...mockMessage,
54
+ message: "@testbot hello",
55
+ };
56
+
57
+ const result = checkTwitchAccessControl({
58
+ message,
59
+ account: mockAccount,
60
+ botUsername: "testbot",
61
+ });
62
+ expect(result.allowed).toBe(true);
63
+ });
64
+ });
65
+
66
+ describe("requireMention", () => {
67
+ it("allows messages that mention the bot", () => {
68
+ const account: TwitchAccountConfig = {
69
+ ...mockAccount,
70
+ requireMention: true,
71
+ };
72
+ const message: TwitchChatMessage = {
73
+ ...mockMessage,
74
+ message: "@testbot hello",
75
+ };
76
+
77
+ const result = checkTwitchAccessControl({
78
+ message,
79
+ account,
80
+ botUsername: "testbot",
81
+ });
82
+ expect(result.allowed).toBe(true);
83
+ });
84
+
85
+ it("blocks messages that don't mention the bot", () => {
86
+ const account: TwitchAccountConfig = {
87
+ ...mockAccount,
88
+ requireMention: true,
89
+ };
90
+
91
+ const result = checkTwitchAccessControl({
92
+ message: mockMessage,
93
+ account,
94
+ botUsername: "testbot",
95
+ });
96
+ expect(result.allowed).toBe(false);
97
+ expect(result.reason).toContain("does not mention the bot");
98
+ });
99
+
100
+ it("is case-insensitive for bot username", () => {
101
+ const account: TwitchAccountConfig = {
102
+ ...mockAccount,
103
+ requireMention: true,
104
+ };
105
+ const message: TwitchChatMessage = {
106
+ ...mockMessage,
107
+ message: "@TestBot hello",
108
+ };
109
+
110
+ const result = checkTwitchAccessControl({
111
+ message,
112
+ account,
113
+ botUsername: "testbot",
114
+ });
115
+ expect(result.allowed).toBe(true);
116
+ });
117
+ });
118
+
119
+ describe("allowFrom allowlist", () => {
120
+ it("allows users in the allowlist", () => {
121
+ const account: TwitchAccountConfig = {
122
+ ...mockAccount,
123
+ allowFrom: ["123456", "789012"],
124
+ };
125
+ const message: TwitchChatMessage = {
126
+ ...mockMessage,
127
+ message: "@testbot hello",
128
+ };
129
+
130
+ const result = checkTwitchAccessControl({
131
+ message,
132
+ account,
133
+ botUsername: "testbot",
134
+ });
135
+ expect(result.allowed).toBe(true);
136
+ expect(result.matchKey).toBe("123456");
137
+ expect(result.matchSource).toBe("allowlist");
138
+ });
139
+
140
+ it("blocks users not in allowlist when allowFrom is set", () => {
141
+ const account: TwitchAccountConfig = {
142
+ ...mockAccount,
143
+ allowFrom: ["789012"],
144
+ };
145
+ const message: TwitchChatMessage = {
146
+ ...mockMessage,
147
+ message: "@testbot hello",
148
+ };
149
+
150
+ const result = checkTwitchAccessControl({
151
+ message,
152
+ account,
153
+ botUsername: "testbot",
154
+ });
155
+ expect(result.allowed).toBe(false);
156
+ expect(result.reason).toContain("allowFrom");
157
+ });
158
+
159
+ it("blocks messages without userId", () => {
160
+ const account: TwitchAccountConfig = {
161
+ ...mockAccount,
162
+ allowFrom: ["123456"],
163
+ };
164
+ const message: TwitchChatMessage = {
165
+ ...mockMessage,
166
+ message: "@testbot hello",
167
+ userId: undefined,
168
+ };
169
+
170
+ const result = checkTwitchAccessControl({
171
+ message,
172
+ account,
173
+ botUsername: "testbot",
174
+ });
175
+ expect(result.allowed).toBe(false);
176
+ expect(result.reason).toContain("user ID not available");
177
+ });
178
+
179
+ it("bypasses role checks when user is in allowlist", () => {
180
+ const account: TwitchAccountConfig = {
181
+ ...mockAccount,
182
+ allowFrom: ["123456"],
183
+ allowedRoles: ["owner"],
184
+ };
185
+ const message: TwitchChatMessage = {
186
+ ...mockMessage,
187
+ message: "@testbot hello",
188
+ isOwner: false,
189
+ };
190
+
191
+ const result = checkTwitchAccessControl({
192
+ message,
193
+ account,
194
+ botUsername: "testbot",
195
+ });
196
+ expect(result.allowed).toBe(true);
197
+ });
198
+
199
+ it("blocks user with role when not in allowlist", () => {
200
+ const account: TwitchAccountConfig = {
201
+ ...mockAccount,
202
+ allowFrom: ["789012"],
203
+ allowedRoles: ["moderator"],
204
+ };
205
+ const message: TwitchChatMessage = {
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",
216
+ });
217
+ expect(result.allowed).toBe(false);
218
+ expect(result.reason).toContain("allowFrom");
219
+ });
220
+
221
+ it("blocks user not in allowlist even when roles configured", () => {
222
+ const account: TwitchAccountConfig = {
223
+ ...mockAccount,
224
+ allowFrom: ["789012"],
225
+ allowedRoles: ["moderator"],
226
+ };
227
+ const message: TwitchChatMessage = {
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",
238
+ });
239
+ expect(result.allowed).toBe(false);
240
+ expect(result.reason).toContain("allowFrom");
241
+ });
242
+ });
243
+
244
+ describe("allowedRoles", () => {
245
+ it("allows users with matching role", () => {
246
+ const account: TwitchAccountConfig = {
247
+ ...mockAccount,
248
+ allowedRoles: ["moderator"],
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",
260
+ });
261
+ expect(result.allowed).toBe(true);
262
+ expect(result.matchSource).toBe("role");
263
+ });
264
+
265
+ it("allows users with any of multiple roles", () => {
266
+ const account: TwitchAccountConfig = {
267
+ ...mockAccount,
268
+ allowedRoles: ["moderator", "vip", "subscriber"],
269
+ };
270
+ const message: TwitchChatMessage = {
271
+ ...mockMessage,
272
+ message: "@testbot hello",
273
+ isVip: true,
274
+ isMod: false,
275
+ isSub: false,
276
+ };
277
+
278
+ const result = checkTwitchAccessControl({
279
+ message,
280
+ account,
281
+ botUsername: "testbot",
282
+ });
283
+ expect(result.allowed).toBe(true);
284
+ });
285
+
286
+ it("blocks users without matching role", () => {
287
+ const account: TwitchAccountConfig = {
288
+ ...mockAccount,
289
+ allowedRoles: ["moderator"],
290
+ };
291
+ const message: TwitchChatMessage = {
292
+ ...mockMessage,
293
+ message: "@testbot hello",
294
+ isMod: false,
295
+ };
296
+
297
+ const result = checkTwitchAccessControl({
298
+ message,
299
+ account,
300
+ botUsername: "testbot",
301
+ });
302
+ expect(result.allowed).toBe(false);
303
+ expect(result.reason).toContain("does not have any of the required roles");
304
+ });
305
+
306
+ it("allows all users when role is 'all'", () => {
307
+ const account: TwitchAccountConfig = {
308
+ ...mockAccount,
309
+ allowedRoles: ["all"],
310
+ };
311
+ const message: TwitchChatMessage = {
312
+ ...mockMessage,
313
+ message: "@testbot hello",
314
+ };
315
+
316
+ const result = checkTwitchAccessControl({
317
+ message,
318
+ account,
319
+ botUsername: "testbot",
320
+ });
321
+ expect(result.allowed).toBe(true);
322
+ expect(result.matchKey).toBe("all");
323
+ });
324
+
325
+ it("handles moderator role", () => {
326
+ const account: TwitchAccountConfig = {
327
+ ...mockAccount,
328
+ allowedRoles: ["moderator"],
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",
340
+ });
341
+ expect(result.allowed).toBe(true);
342
+ });
343
+
344
+ it("handles subscriber role", () => {
345
+ const account: TwitchAccountConfig = {
346
+ ...mockAccount,
347
+ allowedRoles: ["subscriber"],
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",
359
+ });
360
+ expect(result.allowed).toBe(true);
361
+ });
362
+
363
+ it("handles owner role", () => {
364
+ const account: TwitchAccountConfig = {
365
+ ...mockAccount,
366
+ allowedRoles: ["owner"],
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",
378
+ });
379
+ expect(result.allowed).toBe(true);
380
+ });
381
+
382
+ it("handles vip role", () => {
383
+ const account: TwitchAccountConfig = {
384
+ ...mockAccount,
385
+ allowedRoles: ["vip"],
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",
397
+ });
398
+ expect(result.allowed).toBe(true);
399
+ });
400
+ });
401
+
402
+ describe("combined restrictions", () => {
403
+ it("checks requireMention before allowlist", () => {
404
+ const account: TwitchAccountConfig = {
405
+ ...mockAccount,
406
+ requireMention: true,
407
+ allowFrom: ["123456"],
408
+ };
409
+ const message: TwitchChatMessage = {
410
+ ...mockMessage,
411
+ message: "hello", // No mention
412
+ };
413
+
414
+ const result = checkTwitchAccessControl({
415
+ message,
416
+ account,
417
+ botUsername: "testbot",
418
+ });
419
+ expect(result.allowed).toBe(false);
420
+ expect(result.reason).toContain("does not mention the bot");
421
+ });
422
+
423
+ it("checks allowlist before allowedRoles", () => {
424
+ const account: TwitchAccountConfig = {
425
+ ...mockAccount,
426
+ allowFrom: ["123456"],
427
+ allowedRoles: ["owner"],
428
+ };
429
+ const message: TwitchChatMessage = {
430
+ ...mockMessage,
431
+ message: "@testbot hello",
432
+ isOwner: false,
433
+ };
434
+
435
+ const result = checkTwitchAccessControl({
436
+ message,
437
+ account,
438
+ botUsername: "testbot",
439
+ });
440
+ expect(result.allowed).toBe(true);
441
+ expect(result.matchSource).toBe("allowlist");
442
+ });
443
+ });
444
+ });
445
+
446
+ describe("extractMentions", () => {
447
+ it("extracts single mention", () => {
448
+ const mentions = extractMentions("hello @testbot");
449
+ expect(mentions).toEqual(["testbot"]);
450
+ });
451
+
452
+ it("extracts multiple mentions", () => {
453
+ const mentions = extractMentions("hello @testbot and @otheruser");
454
+ expect(mentions).toEqual(["testbot", "otheruser"]);
455
+ });
456
+
457
+ it("returns empty array when no mentions", () => {
458
+ const mentions = extractMentions("hello everyone");
459
+ expect(mentions).toEqual([]);
460
+ });
461
+
462
+ it("handles mentions at start of message", () => {
463
+ const mentions = extractMentions("@testbot hello");
464
+ expect(mentions).toEqual(["testbot"]);
465
+ });
466
+
467
+ it("handles mentions at end of message", () => {
468
+ const mentions = extractMentions("hello @testbot");
469
+ expect(mentions).toEqual(["testbot"]);
470
+ });
471
+
472
+ it("converts mentions to lowercase", () => {
473
+ const mentions = extractMentions("hello @TestBot");
474
+ expect(mentions).toEqual(["testbot"]);
475
+ });
476
+
477
+ it("extracts alphanumeric usernames", () => {
478
+ const mentions = extractMentions("hello @user123");
479
+ expect(mentions).toEqual(["user123"]);
480
+ });
481
+
482
+ it("handles underscores in usernames", () => {
483
+ const mentions = extractMentions("hello @test_user");
484
+ expect(mentions).toEqual(["test_user"]);
485
+ });
486
+
487
+ it("handles empty string", () => {
488
+ const mentions = extractMentions("");
489
+ expect(mentions).toEqual([]);
490
+ });
491
+ });