@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
@@ -1,468 +0,0 @@
1
- /**
2
- * Tests for outbound.ts module
3
- *
4
- * Tests cover:
5
- * - resolveTarget with various modes (explicit, implicit, heartbeat)
6
- * - sendText with markdown stripping
7
- * - sendMedia delegation to sendText
8
- * - Error handling for missing accounts/channels
9
- * - Abort signal handling
10
- */
11
-
12
- import { describe, expect, it, vi } from "vitest";
13
- import { resolveTwitchAccountContext } from "./config.js";
14
- import { twitchOutbound } from "./outbound.js";
15
- import {
16
- BASE_TWITCH_TEST_ACCOUNT,
17
- installTwitchTestHooks,
18
- makeTwitchTestConfig,
19
- } from "./test-fixtures.js";
20
-
21
- // Mock dependencies
22
- vi.mock("./config.js", () => ({
23
- DEFAULT_ACCOUNT_ID: "default",
24
- resolveTwitchAccountContext: vi.fn(),
25
- }));
26
-
27
- vi.mock("./send.js", () => ({
28
- sendMessageTwitchInternal: vi.fn(),
29
- }));
30
-
31
- vi.mock("./utils/markdown.js", () => ({
32
- chunkTextForTwitch: vi.fn((text) => text.split(/(.{500})/).filter(Boolean)),
33
- }));
34
-
35
- vi.mock("./utils/twitch.js", () => ({
36
- normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""),
37
- missingTargetError: (channel: string, hint: string) =>
38
- new Error(`Missing target for ${channel}. Provide ${hint}`),
39
- }));
40
-
41
- function assertResolvedTarget(
42
- result: ReturnType<NonNullable<typeof twitchOutbound.resolveTarget>>,
43
- ): string {
44
- if (!result.ok) {
45
- throw result.error;
46
- }
47
- return result.to;
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
-
64
- describe("outbound", () => {
65
- const mockAccount = {
66
- ...BASE_TWITCH_TEST_ACCOUNT,
67
- accessToken: "oauth:test123",
68
- };
69
- const resolveTarget = twitchOutbound.resolveTarget!;
70
-
71
- const mockConfig = makeTwitchTestConfig(mockAccount);
72
- installTwitchTestHooks();
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
-
88
- describe("metadata", () => {
89
- it("should have direct delivery mode", () => {
90
- expect(twitchOutbound.deliveryMode).toBe("direct");
91
- });
92
-
93
- it("should have 500 character text chunk limit", () => {
94
- expect(twitchOutbound.textChunkLimit).toBe(500);
95
- });
96
-
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)]);
104
- });
105
- });
106
-
107
- describe("resolveTarget", () => {
108
- it("should normalize and return target in explicit mode", () => {
109
- const result = resolveTarget({
110
- to: "#MyChannel",
111
- mode: "explicit",
112
- allowFrom: [],
113
- });
114
-
115
- expect(result.ok).toBe(true);
116
- expect(assertResolvedTarget(result)).toBe("mychannel");
117
- });
118
-
119
- it("should return target in implicit mode with wildcard allowlist", () => {
120
- const result = resolveTarget({
121
- to: "#AnyChannel",
122
- mode: "implicit",
123
- allowFrom: ["*"],
124
- });
125
-
126
- expect(result.ok).toBe(true);
127
- expect(assertResolvedTarget(result)).toBe("anychannel");
128
- });
129
-
130
- it("should return target in implicit mode when in allowlist", () => {
131
- const result = resolveTarget({
132
- to: "#allowed",
133
- mode: "implicit",
134
- allowFrom: ["#allowed", "#other"],
135
- });
136
-
137
- expect(result.ok).toBe(true);
138
- expect(assertResolvedTarget(result)).toBe("allowed");
139
- });
140
-
141
- it("should error when target not in allowlist (implicit mode)", () => {
142
- expectTargetError(
143
- resolveTarget,
144
- {
145
- to: "#notallowed",
146
- mode: "implicit",
147
- allowFrom: ["#primary", "#secondary"],
148
- },
149
- "Twitch",
150
- );
151
- });
152
-
153
- it("should accept any target when allowlist is empty", () => {
154
- const result = resolveTarget({
155
- to: "#anychannel",
156
- mode: "heartbeat",
157
- allowFrom: [],
158
- });
159
-
160
- expect(result.ok).toBe(true);
161
- expect(assertResolvedTarget(result)).toBe("anychannel");
162
- });
163
-
164
- it("should error when no target provided with allowlist", () => {
165
- expectTargetError(
166
- resolveTarget,
167
- {
168
- to: undefined,
169
- mode: "implicit",
170
- allowFrom: ["#fallback", "#other"],
171
- },
172
- "Twitch",
173
- );
174
- });
175
-
176
- it("should return error when no target and no allowlist", () => {
177
- expectTargetError(
178
- resolveTarget,
179
- {
180
- to: undefined,
181
- mode: "explicit",
182
- allowFrom: [],
183
- },
184
- "Missing target",
185
- );
186
- });
187
-
188
- it("should handle whitespace-only target", () => {
189
- expectTargetError(
190
- resolveTarget,
191
- {
192
- to: " ",
193
- mode: "explicit",
194
- allowFrom: [],
195
- },
196
- "Missing target",
197
- );
198
- });
199
-
200
- it("should error when target normalizes to empty string", () => {
201
- expectTargetError(
202
- resolveTarget,
203
- {
204
- to: "#",
205
- mode: "explicit",
206
- allowFrom: [],
207
- },
208
- "Twitch",
209
- );
210
- });
211
-
212
- it("should filter wildcard from allowlist when checking membership", () => {
213
- const result = resolveTarget({
214
- to: "#mychannel",
215
- mode: "implicit",
216
- allowFrom: ["*", "#specific"],
217
- });
218
-
219
- // With wildcard, any target is accepted
220
- expect(result.ok).toBe(true);
221
- expect(assertResolvedTarget(result)).toBe("mychannel");
222
- });
223
- });
224
-
225
- describe("sendText", () => {
226
- it("should send message successfully", async () => {
227
- const { sendMessageTwitchInternal } = await import("./send.js");
228
-
229
- setupAccountContext();
230
- vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
231
- ok: true,
232
- messageId: "twitch-msg-123",
233
- });
234
-
235
- const result = await twitchOutbound.sendText!({
236
- cfg: mockConfig,
237
- to: "#testchannel",
238
- text: "Hello Twitch!",
239
- accountId: "default",
240
- });
241
-
242
- expect(result.channel).toBe("twitch");
243
- expect(result.messageId).toBe("twitch-msg-123");
244
- expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
245
- "testchannel",
246
- "Hello Twitch!",
247
- mockConfig,
248
- "default",
249
- true,
250
- console,
251
- );
252
- expect(result.timestamp).toBeGreaterThan(0);
253
- });
254
-
255
- it("should throw when account not found", async () => {
256
- setupAccountContext({ account: null });
257
-
258
- await expect(
259
- twitchOutbound.sendText!({
260
- cfg: mockConfig,
261
- to: "#testchannel",
262
- text: "Hello!",
263
- accountId: "nonexistent",
264
- }),
265
- ).rejects.toThrow("Twitch account not found: nonexistent");
266
- });
267
-
268
- it("should throw when no channel specified", async () => {
269
- const accountWithoutChannel = { ...mockAccount, channel: undefined as unknown as string };
270
- setupAccountContext({ account: accountWithoutChannel });
271
-
272
- await expect(
273
- twitchOutbound.sendText!({
274
- cfg: mockConfig,
275
- to: "",
276
- text: "Hello!",
277
- accountId: "default",
278
- }),
279
- ).rejects.toThrow("No channel specified");
280
- });
281
-
282
- it("should use account channel when target not provided", async () => {
283
- const { sendMessageTwitchInternal } = await import("./send.js");
284
-
285
- setupAccountContext();
286
- vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
287
- ok: true,
288
- messageId: "msg-456",
289
- });
290
-
291
- await twitchOutbound.sendText!({
292
- cfg: mockConfig,
293
- to: "",
294
- text: "Hello!",
295
- accountId: "default",
296
- });
297
-
298
- expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
299
- "testchannel",
300
- "Hello!",
301
- mockConfig,
302
- "default",
303
- true,
304
- console,
305
- );
306
- });
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
-
359
- it("should handle abort signal", async () => {
360
- const abortController = new AbortController();
361
- abortController.abort();
362
-
363
- await expect(
364
- twitchOutbound.sendText!({
365
- cfg: mockConfig,
366
- to: "#testchannel",
367
- text: "Hello!",
368
- accountId: "default",
369
- signal: abortController.signal,
370
- } as Parameters<NonNullable<typeof twitchOutbound.sendText>>[0]),
371
- ).rejects.toThrow("Outbound delivery aborted");
372
- });
373
-
374
- it("should throw on send failure", async () => {
375
- const { sendMessageTwitchInternal } = await import("./send.js");
376
-
377
- setupAccountContext();
378
- vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
379
- ok: false,
380
- messageId: "failed-msg",
381
- error: "Connection lost",
382
- });
383
-
384
- await expect(
385
- twitchOutbound.sendText!({
386
- cfg: mockConfig,
387
- to: "#testchannel",
388
- text: "Hello!",
389
- accountId: "default",
390
- }),
391
- ).rejects.toThrow("Connection lost");
392
- });
393
- });
394
-
395
- describe("sendMedia", () => {
396
- it("should combine text and media URL", async () => {
397
- const { sendMessageTwitchInternal } = await import("./send.js");
398
-
399
- setupAccountContext();
400
- vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
401
- ok: true,
402
- messageId: "media-msg-123",
403
- });
404
-
405
- const result = await twitchOutbound.sendMedia!({
406
- cfg: mockConfig,
407
- to: "#testchannel",
408
- text: "Check this:",
409
- mediaUrl: "https://example.com/image.png",
410
- accountId: "default",
411
- });
412
-
413
- expect(result.channel).toBe("twitch");
414
- expect(result.messageId).toBe("media-msg-123");
415
- expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
416
- expect.anything(),
417
- "Check this: https://example.com/image.png",
418
- expect.anything(),
419
- expect.anything(),
420
- expect.anything(),
421
- expect.anything(),
422
- );
423
- });
424
-
425
- it("should send media URL only when no text", async () => {
426
- const { sendMessageTwitchInternal } = await import("./send.js");
427
-
428
- setupAccountContext();
429
- vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
430
- ok: true,
431
- messageId: "media-only-msg",
432
- });
433
-
434
- await twitchOutbound.sendMedia!({
435
- cfg: mockConfig,
436
- to: "#testchannel",
437
- text: "",
438
- mediaUrl: "https://example.com/image.png",
439
- accountId: "default",
440
- });
441
-
442
- expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
443
- expect.anything(),
444
- "https://example.com/image.png",
445
- expect.anything(),
446
- expect.anything(),
447
- expect.anything(),
448
- expect.anything(),
449
- );
450
- });
451
-
452
- it("should handle abort signal", async () => {
453
- const abortController = new AbortController();
454
- abortController.abort();
455
-
456
- await expect(
457
- twitchOutbound.sendMedia!({
458
- cfg: mockConfig,
459
- to: "#testchannel",
460
- text: "Check this:",
461
- mediaUrl: "https://example.com/image.png",
462
- accountId: "default",
463
- signal: abortController.signal,
464
- } as Parameters<NonNullable<typeof twitchOutbound.sendMedia>>[0]),
465
- ).rejects.toThrow("Outbound delivery aborted");
466
- });
467
- });
468
- });
package/src/outbound.ts DELETED
@@ -1,186 +0,0 @@
1
- /**
2
- * Twitch outbound adapter for sending messages.
3
- *
4
- * Implements the ChannelOutboundAdapter interface for Twitch chat.
5
- * Supports text and media (URL) sending with markdown stripping and chunking.
6
- */
7
-
8
- import { resolveTwitchAccountContext } from "./config.js";
9
- import { sendMessageTwitchInternal } from "./send.js";
10
- import type {
11
- ChannelOutboundAdapter,
12
- ChannelOutboundContext,
13
- OutboundDeliveryResult,
14
- } from "./types.js";
15
- import { chunkTextForTwitch } from "./utils/markdown.js";
16
- import { missingTargetError, normalizeTwitchChannel } from "./utils/twitch.js";
17
-
18
- /**
19
- * Twitch outbound adapter.
20
- *
21
- * Handles sending text and media to Twitch channels with automatic
22
- * markdown stripping and message chunking.
23
- */
24
- export const twitchOutbound: ChannelOutboundAdapter = {
25
- /** Direct delivery mode - messages are sent immediately */
26
- deliveryMode: "direct",
27
-
28
- /** Twitch chat message limit is 500 characters */
29
- textChunkLimit: 500,
30
-
31
- /** Word-boundary chunker with markdown stripping */
32
- chunker: chunkTextForTwitch,
33
-
34
- /**
35
- * Resolve target from context.
36
- *
37
- * Handles target resolution with allowlist support for implicit/heartbeat modes.
38
- * For explicit mode, accepts any valid channel name.
39
- *
40
- * @param params - Resolution parameters
41
- * @returns Resolved target or error
42
- */
43
- resolveTarget: ({ to, allowFrom, mode }) => {
44
- const trimmed = to?.trim() ?? "";
45
- const allowListRaw = (allowFrom ?? [])
46
- .map((entry: unknown) => String(entry).trim())
47
- .filter(Boolean);
48
- const hasWildcard = allowListRaw.includes("*");
49
- const allowList = allowListRaw
50
- .filter((entry: string) => entry !== "*")
51
- .map((entry: string) => normalizeTwitchChannel(entry))
52
- .filter((entry): entry is string => entry.length > 0);
53
-
54
- // If target is provided, normalize and validate it
55
- if (trimmed) {
56
- const normalizedTo = normalizeTwitchChannel(trimmed);
57
- if (!normalizedTo) {
58
- return {
59
- ok: false,
60
- error: missingTargetError("Twitch", "<channel-name>"),
61
- };
62
- }
63
-
64
- // For implicit/heartbeat modes with allowList, check against allowlist
65
- if (mode === "implicit" || mode === "heartbeat") {
66
- if (hasWildcard || allowList.length === 0) {
67
- return { ok: true, to: normalizedTo };
68
- }
69
- if (allowList.includes(normalizedTo)) {
70
- return { ok: true, to: normalizedTo };
71
- }
72
- return {
73
- ok: false,
74
- error: missingTargetError("Twitch", "<channel-name>"),
75
- };
76
- }
77
-
78
- // For explicit mode, accept any valid channel name
79
- return { ok: true, to: normalizedTo };
80
- }
81
-
82
- // No target provided - error
83
-
84
- // No target and no allowFrom - error
85
- return {
86
- ok: false,
87
- error: missingTargetError("Twitch", "<channel-name>"),
88
- };
89
- },
90
-
91
- /**
92
- * Send a text message to a Twitch channel.
93
- *
94
- * Strips markdown if enabled, validates account configuration,
95
- * and sends the message via the Twitch client.
96
- *
97
- * @param params - Send parameters including target, text, and config
98
- * @returns Delivery result with message ID and status
99
- *
100
- * @example
101
- * const result = await twitchOutbound.sendText({
102
- * cfg: openclawConfig,
103
- * to: "#mychannel",
104
- * text: "Hello Twitch!",
105
- * accountId: "default",
106
- * });
107
- */
108
- sendText: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
109
- const { cfg, to, text, accountId } = params;
110
- const signal = (params as { signal?: AbortSignal }).signal;
111
-
112
- if (signal?.aborted) {
113
- throw new Error("Outbound delivery aborted");
114
- }
115
-
116
- const resolvedAccountId = accountId ?? resolveTwitchAccountContext(cfg).accountId;
117
- const { account, availableAccountIds } = resolveTwitchAccountContext(cfg, resolvedAccountId);
118
- if (!account) {
119
- throw new Error(
120
- `Twitch account not found: ${resolvedAccountId}. ` +
121
- `Available accounts: ${availableAccountIds.join(", ") || "none"}`,
122
- );
123
- }
124
-
125
- const channel = to || account.channel;
126
- if (!channel) {
127
- throw new Error("No channel specified and no default channel in account config");
128
- }
129
-
130
- const result = await sendMessageTwitchInternal(
131
- normalizeTwitchChannel(channel),
132
- text,
133
- cfg,
134
- resolvedAccountId,
135
- true, // stripMarkdown
136
- console,
137
- );
138
-
139
- if (!result.ok) {
140
- throw new Error(result.error ?? "Send failed");
141
- }
142
-
143
- return {
144
- channel: "twitch",
145
- messageId: result.messageId,
146
- timestamp: Date.now(),
147
- };
148
- },
149
-
150
- /**
151
- * Send media to a Twitch channel.
152
- *
153
- * Note: Twitch chat doesn't support direct media uploads.
154
- * This sends the media URL as text instead.
155
- *
156
- * @param params - Send parameters including media URL
157
- * @returns Delivery result with message ID and status
158
- *
159
- * @example
160
- * const result = await twitchOutbound.sendMedia({
161
- * cfg: openclawConfig,
162
- * to: "#mychannel",
163
- * text: "Check this out!",
164
- * mediaUrl: "https://example.com/image.png",
165
- * accountId: "default",
166
- * });
167
- */
168
- sendMedia: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
169
- const { text, mediaUrl } = params;
170
- const signal = (params as { signal?: AbortSignal }).signal;
171
-
172
- if (signal?.aborted) {
173
- throw new Error("Outbound delivery aborted");
174
- }
175
-
176
- const message = mediaUrl ? `${text || ""} ${mediaUrl}`.trim() : text;
177
-
178
- if (!twitchOutbound.sendText) {
179
- throw new Error("sendText not implemented");
180
- }
181
- return twitchOutbound.sendText({
182
- ...params,
183
- text: message,
184
- });
185
- },
186
- };