@openclaw/twitch 2026.5.2 → 2026.5.3-beta.2

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.
Files changed (54) hide show
  1. package/dist/api.js +3 -0
  2. package/dist/channel-plugin-api.js +2 -0
  3. package/dist/index.js +18 -0
  4. package/dist/markdown-MRdI1sR7.js +306 -0
  5. package/dist/monitor-DS0YTAPB.js +333 -0
  6. package/dist/plugin-BQX9GiIn.js +878 -0
  7. package/dist/runtime-QZ5I3GlI.js +8 -0
  8. package/dist/runtime-api.js +1 -0
  9. package/dist/setup-entry.js +11 -0
  10. package/dist/setup-plugin-api.js +2 -0
  11. package/dist/setup-surface-yArVgckI.js +400 -0
  12. package/dist/twitch-CklAMZL5.js +131 -0
  13. package/package.json +20 -3
  14. package/api.ts +0 -21
  15. package/channel-plugin-api.ts +0 -1
  16. package/index.test.ts +0 -13
  17. package/index.ts +0 -16
  18. package/runtime-api.ts +0 -22
  19. package/setup-entry.ts +0 -9
  20. package/setup-plugin-api.ts +0 -3
  21. package/src/access-control.test.ts +0 -388
  22. package/src/access-control.ts +0 -173
  23. package/src/actions.test.ts +0 -74
  24. package/src/actions.ts +0 -175
  25. package/src/client-manager-registry.ts +0 -87
  26. package/src/config-schema.test.ts +0 -46
  27. package/src/config-schema.ts +0 -88
  28. package/src/config.test.ts +0 -233
  29. package/src/config.ts +0 -177
  30. package/src/monitor.ts +0 -314
  31. package/src/outbound.test.ts +0 -468
  32. package/src/outbound.ts +0 -186
  33. package/src/plugin.test.ts +0 -77
  34. package/src/plugin.ts +0 -208
  35. package/src/probe.test.ts +0 -196
  36. package/src/probe.ts +0 -130
  37. package/src/resolver.ts +0 -139
  38. package/src/runtime.ts +0 -9
  39. package/src/send.test.ts +0 -309
  40. package/src/send.ts +0 -139
  41. package/src/setup-surface.test.ts +0 -511
  42. package/src/setup-surface.ts +0 -520
  43. package/src/status.test.ts +0 -237
  44. package/src/status.ts +0 -179
  45. package/src/test-fixtures.ts +0 -30
  46. package/src/token.test.ts +0 -192
  47. package/src/token.ts +0 -93
  48. package/src/twitch-client.test.ts +0 -582
  49. package/src/twitch-client.ts +0 -276
  50. package/src/types.ts +0 -104
  51. package/src/utils/markdown.ts +0 -98
  52. package/src/utils/twitch.ts +0 -84
  53. package/test/setup.ts +0 -7
  54. package/tsconfig.json +0 -16
package/setup-entry.ts DELETED
@@ -1,9 +0,0 @@
1
- import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
2
-
3
- export default defineBundledChannelSetupEntry({
4
- importMetaUrl: import.meta.url,
5
- plugin: {
6
- specifier: "./setup-plugin-api.js",
7
- exportName: "twitchSetupPlugin",
8
- },
9
- });
@@ -1,3 +0,0 @@
1
- // Keep bundled setup entry imports narrow so setup loads do not pull the
2
- // broader Twitch channel plugin surface.
3
- export { twitchSetupPlugin } from "./src/setup-surface.js";
@@ -1,388 +0,0 @@
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
- function runAccessCheck(params: {
21
- account?: Partial<TwitchAccountConfig>;
22
- message?: Partial<TwitchChatMessage>;
23
- }) {
24
- return checkTwitchAccessControl({
25
- message: {
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: {
44
- message: "@testbot hello",
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
- },
93
- });
94
- expect(result.allowed).toBe(true);
95
- });
96
- });
97
-
98
- describe("requireMention default", () => {
99
- it("defaults to true when undefined", () => {
100
- const result = runAccessCheck({
101
- message: {
102
- message: "hello bot",
103
- },
104
- });
105
- expect(result.allowed).toBe(false);
106
- expect(result.reason).toContain("does not mention the bot");
107
- });
108
-
109
- it("allows mention when requireMention is undefined", () => {
110
- const result = runAccessCheck({
111
- message: {
112
- message: "@testbot hello",
113
- },
114
- });
115
- expect(result.allowed).toBe(true);
116
- });
117
- });
118
-
119
- describe("requireMention", () => {
120
- it("allows messages that mention the bot", () => {
121
- const result = runAccessCheck({
122
- account: { requireMention: true },
123
- message: { message: "@testbot hello" },
124
- });
125
- expect(result.allowed).toBe(true);
126
- });
127
-
128
- it("blocks messages that don't mention the bot", () => {
129
- const result = runAccessCheck({
130
- account: { requireMention: true },
131
- });
132
- expect(result.allowed).toBe(false);
133
- expect(result.reason).toContain("does not mention the bot");
134
- });
135
-
136
- it("is case-insensitive for bot username", () => {
137
- const result = runAccessCheck({
138
- account: { requireMention: true },
139
- message: { message: "@TestBot hello" },
140
- });
141
- expect(result.allowed).toBe(true);
142
- });
143
- });
144
-
145
- describe("allowFrom allowlist", () => {
146
- it("allows users in the allowlist", () => {
147
- const result = expectAllowedAccessCheck({
148
- account: {
149
- allowFrom: ["123456", "789012"],
150
- },
151
- });
152
- expect(result.matchKey).toBe("123456");
153
- expect(result.matchSource).toBe("allowlist");
154
- });
155
-
156
- it("blocks users not in allowlist when allowFrom is set", () => {
157
- expectAllowFromBlocked({
158
- allowFrom: ["789012"],
159
- reason: "allowFrom",
160
- });
161
- });
162
-
163
- it("blocks everyone when allowFrom is explicitly empty", () => {
164
- expectAllowFromBlocked({
165
- allowFrom: [],
166
- reason: "allowFrom",
167
- });
168
- });
169
-
170
- it("blocks messages without userId", () => {
171
- expectAllowFromBlocked({
172
- allowFrom: ["123456"],
173
- message: { userId: undefined },
174
- reason: "user ID not available",
175
- });
176
- });
177
-
178
- it("bypasses role checks when user is in allowlist", () => {
179
- const account: TwitchAccountConfig = {
180
- ...mockAccount,
181
- allowFrom: ["123456"],
182
- allowedRoles: ["owner"],
183
- };
184
- const message: TwitchChatMessage = {
185
- ...mockMessage,
186
- message: "@testbot hello",
187
- isOwner: false,
188
- };
189
-
190
- const result = checkTwitchAccessControl({
191
- message,
192
- account,
193
- botUsername: "testbot",
194
- });
195
- expect(result.allowed).toBe(true);
196
- });
197
-
198
- it("blocks user with role when not in allowlist", () => {
199
- expectAllowFromBlocked({
200
- allowFrom: ["789012"],
201
- allowedRoles: ["moderator"],
202
- message: { userId: "123456", isMod: true },
203
- reason: "allowFrom",
204
- });
205
- });
206
-
207
- it("blocks user not in allowlist even when roles configured", () => {
208
- expectAllowFromBlocked({
209
- allowFrom: ["789012"],
210
- allowedRoles: ["moderator"],
211
- message: { userId: "123456", isMod: false },
212
- reason: "allowFrom",
213
- });
214
- });
215
- });
216
-
217
- describe("allowedRoles", () => {
218
- it("allows users with matching role", () => {
219
- const result = expectSingleRoleAllowed({
220
- role: "moderator",
221
- message: { isMod: true },
222
- });
223
- expect(result.matchSource).toBe("role");
224
- });
225
-
226
- it("allows users with any of multiple roles", () => {
227
- const account: TwitchAccountConfig = {
228
- ...mockAccount,
229
- allowedRoles: ["moderator", "vip", "subscriber"],
230
- };
231
- const message: TwitchChatMessage = {
232
- ...mockMessage,
233
- message: "@testbot hello",
234
- isVip: true,
235
- isMod: false,
236
- isSub: false,
237
- };
238
-
239
- const result = checkTwitchAccessControl({
240
- message,
241
- account,
242
- botUsername: "testbot",
243
- });
244
- expect(result.allowed).toBe(true);
245
- });
246
-
247
- it("blocks users without matching role", () => {
248
- const account: TwitchAccountConfig = {
249
- ...mockAccount,
250
- allowedRoles: ["moderator"],
251
- };
252
- const message: TwitchChatMessage = {
253
- ...mockMessage,
254
- message: "@testbot hello",
255
- isMod: false,
256
- };
257
-
258
- const result = checkTwitchAccessControl({
259
- message,
260
- account,
261
- botUsername: "testbot",
262
- });
263
- expect(result.allowed).toBe(false);
264
- expect(result.reason).toContain("does not have any of the required roles");
265
- });
266
-
267
- it("allows all users when role is 'all'", () => {
268
- const result = expectAllowedAccessCheck({
269
- account: {
270
- allowedRoles: ["all"],
271
- },
272
- });
273
- expect(result.matchKey).toBe("all");
274
- });
275
-
276
- it("handles moderator role", () => {
277
- expectSingleRoleAllowed({
278
- role: "moderator",
279
- message: { isMod: true },
280
- });
281
- });
282
-
283
- it("handles subscriber role", () => {
284
- expectSingleRoleAllowed({
285
- role: "subscriber",
286
- message: { isSub: true },
287
- });
288
- });
289
-
290
- it("handles owner role", () => {
291
- expectSingleRoleAllowed({
292
- role: "owner",
293
- message: { isOwner: true },
294
- });
295
- });
296
-
297
- it("handles vip role", () => {
298
- expectSingleRoleAllowed({
299
- role: "vip",
300
- message: { isVip: true },
301
- });
302
- });
303
- });
304
-
305
- describe("combined restrictions", () => {
306
- it("checks requireMention before allowlist", () => {
307
- const account: TwitchAccountConfig = {
308
- ...mockAccount,
309
- requireMention: true,
310
- allowFrom: ["123456"],
311
- };
312
- const message: TwitchChatMessage = {
313
- ...mockMessage,
314
- message: "hello", // No mention
315
- };
316
-
317
- const result = checkTwitchAccessControl({
318
- message,
319
- account,
320
- botUsername: "testbot",
321
- });
322
- expect(result.allowed).toBe(false);
323
- expect(result.reason).toContain("does not mention the bot");
324
- });
325
-
326
- it("checks allowlist before allowedRoles", () => {
327
- const result = runAccessCheck({
328
- account: {
329
- allowFrom: ["123456"],
330
- allowedRoles: ["owner"],
331
- },
332
- message: {
333
- message: "@testbot hello",
334
- isOwner: false,
335
- },
336
- });
337
- expect(result.allowed).toBe(true);
338
- expect(result.matchSource).toBe("allowlist");
339
- });
340
- });
341
- });
342
-
343
- describe("extractMentions", () => {
344
- it("extracts single mention", () => {
345
- const mentions = extractMentions("hello @testbot");
346
- expect(mentions).toEqual(["testbot"]);
347
- });
348
-
349
- it("extracts multiple mentions", () => {
350
- const mentions = extractMentions("hello @testbot and @otheruser");
351
- expect(mentions).toEqual(["testbot", "otheruser"]);
352
- });
353
-
354
- it("returns empty array when no mentions", () => {
355
- const mentions = extractMentions("hello everyone");
356
- expect(mentions).toEqual([]);
357
- });
358
-
359
- it("handles mentions at start of message", () => {
360
- const mentions = extractMentions("@testbot hello");
361
- expect(mentions).toEqual(["testbot"]);
362
- });
363
-
364
- it("handles mentions at end of message", () => {
365
- const mentions = extractMentions("hello @testbot");
366
- expect(mentions).toEqual(["testbot"]);
367
- });
368
-
369
- it("converts mentions to lowercase", () => {
370
- const mentions = extractMentions("hello @TestBot");
371
- expect(mentions).toEqual(["testbot"]);
372
- });
373
-
374
- it("extracts alphanumeric usernames", () => {
375
- const mentions = extractMentions("hello @user123");
376
- expect(mentions).toEqual(["user123"]);
377
- });
378
-
379
- it("handles underscores in usernames", () => {
380
- const mentions = extractMentions("hello @test_user");
381
- expect(mentions).toEqual(["test_user"]);
382
- });
383
-
384
- it("handles empty string", () => {
385
- const mentions = extractMentions("");
386
- expect(mentions).toEqual([]);
387
- });
388
- });
@@ -1,173 +0,0 @@
1
- import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
2
- import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
3
-
4
- /**
5
- * Result of checking access control for a Twitch message
6
- */
7
- type TwitchAccessControlResult = {
8
- allowed: boolean;
9
- reason?: string;
10
- matchKey?: string;
11
- matchSource?: string;
12
- };
13
-
14
- /**
15
- * Check if a Twitch message should be allowed based on account configuration
16
- *
17
- * This function implements the access control logic for incoming Twitch messages,
18
- * checking allowlists, role-based restrictions, and mention requirements.
19
- *
20
- * Priority order:
21
- * 1. If `requireMention` is true, message must mention the bot
22
- * 2. If `allowFrom` is set, sender must be in the allowlist (by user ID)
23
- * 3. If `allowedRoles` is set (and `allowFrom` is not), sender must have at least one role
24
- *
25
- * Note: `allowFrom` is a hard allowlist. When set, only those user IDs are allowed.
26
- * Use `allowedRoles` as an alternative when you don't want to maintain an allowlist.
27
- *
28
- * Available roles:
29
- * - "moderator": Moderators
30
- * - "owner": Channel owner/broadcaster
31
- * - "vip": VIPs
32
- * - "subscriber": Subscribers
33
- * - "all": Anyone in the chat
34
- */
35
- export function checkTwitchAccessControl(params: {
36
- message: TwitchChatMessage;
37
- account: TwitchAccountConfig;
38
- botUsername: string;
39
- }): TwitchAccessControlResult {
40
- const { message, account, botUsername } = params;
41
-
42
- if (account.requireMention ?? true) {
43
- const mentions = extractMentions(message.message);
44
- if (!mentions.includes(normalizeLowercaseStringOrEmpty(botUsername))) {
45
- return {
46
- allowed: false,
47
- reason: "message does not mention the bot (requireMention is enabled)",
48
- };
49
- }
50
- }
51
-
52
- if (account.allowFrom !== undefined) {
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
- }
60
- const senderId = message.userId;
61
-
62
- if (!senderId) {
63
- return {
64
- allowed: false,
65
- reason: "sender user ID not available for allowlist check",
66
- };
67
- }
68
-
69
- if (allowFrom.includes(senderId)) {
70
- return {
71
- allowed: true,
72
- matchKey: senderId,
73
- matchSource: "allowlist",
74
- };
75
- }
76
-
77
- return {
78
- allowed: false,
79
- reason: "sender is not in allowFrom allowlist",
80
- };
81
- }
82
-
83
- if (account.allowedRoles && account.allowedRoles.length > 0) {
84
- const allowedRoles = account.allowedRoles;
85
-
86
- // "all" grants access to everyone
87
- if (allowedRoles.includes("all")) {
88
- return {
89
- allowed: true,
90
- matchKey: "all",
91
- matchSource: "role",
92
- };
93
- }
94
-
95
- const hasAllowedRole = checkSenderRoles({
96
- message,
97
- allowedRoles,
98
- });
99
-
100
- if (!hasAllowedRole) {
101
- return {
102
- allowed: false,
103
- reason: `sender does not have any of the required roles: ${allowedRoles.join(", ")}`,
104
- };
105
- }
106
-
107
- return {
108
- allowed: true,
109
- matchKey: allowedRoles.join(","),
110
- matchSource: "role",
111
- };
112
- }
113
-
114
- return {
115
- allowed: true,
116
- };
117
- }
118
-
119
- /**
120
- * Check if the sender has any of the allowed roles
121
- */
122
- function checkSenderRoles(params: { message: TwitchChatMessage; allowedRoles: string[] }): boolean {
123
- const { message, allowedRoles } = params;
124
- const { isMod, isOwner, isVip, isSub } = message;
125
-
126
- for (const role of allowedRoles) {
127
- switch (role) {
128
- case "moderator":
129
- if (isMod) {
130
- return true;
131
- }
132
- break;
133
- case "owner":
134
- if (isOwner) {
135
- return true;
136
- }
137
- break;
138
- case "vip":
139
- if (isVip) {
140
- return true;
141
- }
142
- break;
143
- case "subscriber":
144
- if (isSub) {
145
- return true;
146
- }
147
- break;
148
- }
149
- }
150
-
151
- return false;
152
- }
153
-
154
- /**
155
- * Extract @mentions from a Twitch chat message
156
- *
157
- * Returns a list of lowercase usernames that were mentioned in the message.
158
- * Twitch mentions are in the format @username.
159
- */
160
- export function extractMentions(message: string): string[] {
161
- const mentionRegex = /@(\w+)/g;
162
- const mentions: string[] = [];
163
- let match: RegExpExecArray | null;
164
-
165
- while ((match = mentionRegex.exec(message)) !== null) {
166
- const username = match[1];
167
- if (username) {
168
- mentions.push(normalizeLowercaseStringOrEmpty(username));
169
- }
170
- }
171
-
172
- return mentions;
173
- }
@@ -1,74 +0,0 @@
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
- });