@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.
Files changed (45) hide show
  1. package/README.md +2 -2
  2. package/api.ts +21 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/index.test.ts +13 -0
  5. package/index.ts +12 -16
  6. package/openclaw.plugin.json +224 -1
  7. package/package.json +36 -7
  8. package/runtime-api.ts +22 -0
  9. package/setup-entry.ts +9 -0
  10. package/setup-plugin-api.ts +3 -0
  11. package/src/access-control.test.ts +136 -239
  12. package/src/access-control.ts +11 -4
  13. package/src/actions.test.ts +74 -0
  14. package/src/actions.ts +7 -6
  15. package/src/client-manager-registry.ts +0 -28
  16. package/src/config-schema.test.ts +46 -0
  17. package/src/config-schema.ts +25 -21
  18. package/src/config.test.ts +147 -1
  19. package/src/config.ts +76 -15
  20. package/src/monitor.ts +126 -85
  21. package/src/outbound.test.ts +140 -75
  22. package/src/outbound.ts +4 -5
  23. package/src/plugin.test.ts +39 -1
  24. package/src/plugin.ts +176 -242
  25. package/src/probe.test.ts +1 -1
  26. package/src/probe.ts +16 -5
  27. package/src/resolver.ts +5 -3
  28. package/src/runtime.ts +8 -13
  29. package/src/send.test.ts +92 -59
  30. package/src/send.ts +18 -15
  31. package/src/setup-surface.test.ts +511 -0
  32. package/src/setup-surface.ts +520 -0
  33. package/src/status.test.ts +120 -153
  34. package/src/status.ts +1 -1
  35. package/src/test-fixtures.ts +1 -1
  36. package/src/token.test.ts +22 -1
  37. package/src/token.ts +8 -6
  38. package/src/twitch-client.test.ts +18 -25
  39. package/src/twitch-client.ts +5 -6
  40. package/src/types.ts +7 -46
  41. package/src/utils/twitch.ts +8 -2
  42. package/tsconfig.json +16 -0
  43. package/CHANGELOG.md +0 -21
  44. package/src/onboarding.test.ts +0 -316
  45. package/src/onboarding.ts +0 -417
@@ -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
- getAccountConfig: vi.fn(),
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 have chunker function", () => {
69
- expect(twitchOutbound.chunker).toBeDefined();
70
- expect(typeof twitchOutbound.chunker).toBe("function");
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
- const result = resolveTarget({
110
- to: "#notallowed",
111
- mode: "implicit",
112
- allowFrom: ["#primary", "#secondary"],
113
- });
114
-
115
- expect(result.ok).toBe(false);
116
- if (result.ok) {
117
- throw new Error("expected resolveTarget to fail");
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
- const result = resolveTarget({
135
- to: undefined,
136
- mode: "implicit",
137
- allowFrom: ["#fallback", "#other"],
138
- });
139
-
140
- expect(result.ok).toBe(false);
141
- if (result.ok) {
142
- throw new Error("expected resolveTarget to fail");
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
- const result = resolveTarget({
149
- to: undefined,
150
- mode: "explicit",
151
- allowFrom: [],
152
- });
153
-
154
- expect(result.ok).toBe(false);
155
- if (result.ok) {
156
- throw new Error("expected resolveTarget to fail");
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
- const result = resolveTarget({
163
- to: " ",
164
- mode: "explicit",
165
- allowFrom: [],
166
- });
167
-
168
- expect(result.ok).toBe(false);
169
- if (result.ok) {
170
- throw new Error("expected resolveTarget to fail");
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
- const result = resolveTarget({
177
- to: "#",
178
- mode: "explicit",
179
- allowFrom: [],
180
- });
181
-
182
- expect(result.ok).toBe(false);
183
- if (result.ok) {
184
- throw new Error("expected resolveTarget to fail");
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
- vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
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
- const { getAccountConfig } = await import("./config.js");
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
- vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannel);
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
- vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
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
- vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
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
- vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
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
- vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
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 { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js";
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 ?? DEFAULT_ACCOUNT_ID;
117
- const account = getAccountConfig(cfg, resolvedAccountId);
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: ${availableIds.join(", ") || "none"}`,
121
+ `Available accounts: ${availableAccountIds.join(", ") || "none"}`,
123
122
  );
124
123
  }
125
124
 
@@ -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
+ });