@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
package/src/send.test.ts CHANGED
@@ -11,22 +11,24 @@
11
11
  */
12
12
 
13
13
  import { describe, expect, it, vi } from "vitest";
14
+ import { getClientManager } from "./client-manager-registry.js";
15
+ import { resolveTwitchAccountContext } from "./config.js";
14
16
  import { sendMessageTwitchInternal } from "./send.js";
15
17
  import {
16
18
  BASE_TWITCH_TEST_ACCOUNT,
17
19
  installTwitchTestHooks,
18
20
  makeTwitchTestConfig,
19
21
  } from "./test-fixtures.js";
22
+ import { stripMarkdownForTwitch } from "./utils/markdown.js";
20
23
 
21
24
  // Mock dependencies
22
25
  vi.mock("./config.js", () => ({
23
26
  DEFAULT_ACCOUNT_ID: "default",
24
- getAccountConfig: vi.fn(),
27
+ resolveTwitchAccountContext: vi.fn(),
25
28
  }));
26
29
 
27
30
  vi.mock("./utils/twitch.js", () => ({
28
31
  generateMessageId: vi.fn(() => "test-msg-id"),
29
- isAccountConfigured: vi.fn(() => true),
30
32
  normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""),
31
33
  }));
32
34
 
@@ -55,19 +57,41 @@ describe("send", () => {
55
57
  installTwitchTestHooks();
56
58
 
57
59
  describe("sendMessageTwitchInternal", () => {
58
- it("should send a message successfully", async () => {
59
- const { getAccountConfig } = await import("./config.js");
60
- const { getClientManager } = await import("./client-manager-registry.js");
61
- const { stripMarkdownForTwitch } = await import("./utils/markdown.js");
62
-
63
- vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
60
+ function setupAccountContext(params?: {
61
+ account?: typeof mockAccount | null;
62
+ configured?: boolean;
63
+ availableAccountIds?: string[];
64
+ }) {
65
+ const account = params?.account === undefined ? mockAccount : params.account;
66
+ vi.mocked(resolveTwitchAccountContext).mockImplementation((_cfg, accountId) => ({
67
+ accountId: accountId?.trim() || "default",
68
+ account,
69
+ tokenResolution: { source: "config", token: account?.accessToken ?? "" },
70
+ configured: account ? (params?.configured ?? true) : false,
71
+ availableAccountIds: params?.availableAccountIds ?? ["default"],
72
+ }));
73
+ }
74
+
75
+ async function mockSuccessfulSend(params: {
76
+ messageId: string;
77
+ stripMarkdown?: (text: string) => string;
78
+ }) {
79
+ setupAccountContext();
64
80
  vi.mocked(getClientManager).mockReturnValue({
65
81
  sendMessage: vi.fn().mockResolvedValue({
66
82
  ok: true,
67
- messageId: "twitch-msg-123",
83
+ messageId: params.messageId,
68
84
  }),
69
85
  } as unknown as ReturnType<typeof getClientManager>);
70
- vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => text);
86
+ vi.mocked(stripMarkdownForTwitch).mockImplementation(
87
+ params.stripMarkdown ?? ((text) => text),
88
+ );
89
+
90
+ return { stripMarkdownForTwitch };
91
+ }
92
+
93
+ it("should send a message successfully", async () => {
94
+ await mockSuccessfulSend({ messageId: "twitch-msg-123" });
71
95
 
72
96
  const result = await sendMessageTwitchInternal(
73
97
  "#testchannel",
@@ -83,18 +107,10 @@ describe("send", () => {
83
107
  });
84
108
 
85
109
  it("should strip markdown when enabled", async () => {
86
- const { getAccountConfig } = await import("./config.js");
87
- const { getClientManager } = await import("./client-manager-registry.js");
88
- const { stripMarkdownForTwitch } = await import("./utils/markdown.js");
89
-
90
- vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
91
- vi.mocked(getClientManager).mockReturnValue({
92
- sendMessage: vi.fn().mockResolvedValue({
93
- ok: true,
94
- messageId: "twitch-msg-456",
95
- }),
96
- } as unknown as ReturnType<typeof getClientManager>);
97
- vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => text.replace(/\*\*/g, ""));
110
+ const { stripMarkdownForTwitch } = await mockSuccessfulSend({
111
+ messageId: "twitch-msg-456",
112
+ stripMarkdown: (text) => text.replace(/\*\*/g, ""),
113
+ });
98
114
 
99
115
  await sendMessageTwitchInternal(
100
116
  "#testchannel",
@@ -109,9 +125,7 @@ describe("send", () => {
109
125
  });
110
126
 
111
127
  it("should return error when account not found", async () => {
112
- const { getAccountConfig } = await import("./config.js");
113
-
114
- vi.mocked(getAccountConfig).mockReturnValue(null);
128
+ setupAccountContext({ account: null });
115
129
 
116
130
  const result = await sendMessageTwitchInternal(
117
131
  "#testchannel",
@@ -127,11 +141,7 @@ describe("send", () => {
127
141
  });
128
142
 
129
143
  it("should return error when account not configured", async () => {
130
- const { getAccountConfig } = await import("./config.js");
131
- const { isAccountConfigured } = await import("./utils/twitch.js");
132
-
133
- vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
134
- vi.mocked(isAccountConfigured).mockReturnValue(false);
144
+ setupAccountContext({ configured: false });
135
145
 
136
146
  const result = await sendMessageTwitchInternal(
137
147
  "#testchannel",
@@ -147,16 +157,12 @@ describe("send", () => {
147
157
  });
148
158
 
149
159
  it("should return error when no channel specified", async () => {
150
- const { getAccountConfig } = await import("./config.js");
151
- const { isAccountConfigured } = await import("./utils/twitch.js");
152
-
153
160
  // Set channel to undefined to trigger the error (bypassing type check)
154
161
  const accountWithoutChannel = {
155
162
  ...mockAccount,
156
163
  channel: undefined as unknown as string,
157
164
  };
158
- vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannel);
159
- vi.mocked(isAccountConfigured).mockReturnValue(true);
165
+ setupAccountContext({ account: accountWithoutChannel });
160
166
 
161
167
  const result = await sendMessageTwitchInternal(
162
168
  "",
@@ -172,12 +178,7 @@ describe("send", () => {
172
178
  });
173
179
 
174
180
  it("should skip sending empty message after markdown stripping", async () => {
175
- const { getAccountConfig } = await import("./config.js");
176
- const { isAccountConfigured } = await import("./utils/twitch.js");
177
- const { stripMarkdownForTwitch } = await import("./utils/markdown.js");
178
-
179
- vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
180
- vi.mocked(isAccountConfigured).mockReturnValue(true);
181
+ setupAccountContext();
181
182
  vi.mocked(stripMarkdownForTwitch).mockReturnValue("");
182
183
 
183
184
  const result = await sendMessageTwitchInternal(
@@ -194,12 +195,7 @@ describe("send", () => {
194
195
  });
195
196
 
196
197
  it("should return error when client manager not found", async () => {
197
- const { getAccountConfig } = await import("./config.js");
198
- const { isAccountConfigured } = await import("./utils/twitch.js");
199
- const { getClientManager } = await import("./client-manager-registry.js");
200
-
201
- vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
202
- vi.mocked(isAccountConfigured).mockReturnValue(true);
198
+ setupAccountContext();
203
199
  vi.mocked(getClientManager).mockReturnValue(undefined);
204
200
 
205
201
  const result = await sendMessageTwitchInternal(
@@ -216,12 +212,7 @@ describe("send", () => {
216
212
  });
217
213
 
218
214
  it("should handle send errors gracefully", async () => {
219
- const { getAccountConfig } = await import("./config.js");
220
- const { isAccountConfigured } = await import("./utils/twitch.js");
221
- const { getClientManager } = await import("./client-manager-registry.js");
222
-
223
- vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
224
- vi.mocked(isAccountConfigured).mockReturnValue(true);
215
+ setupAccountContext();
225
216
  vi.mocked(getClientManager).mockReturnValue({
226
217
  sendMessage: vi.fn().mockRejectedValue(new Error("Connection lost")),
227
218
  } as unknown as ReturnType<typeof getClientManager>);
@@ -241,12 +232,7 @@ describe("send", () => {
241
232
  });
242
233
 
243
234
  it("should use account channel when channel parameter is empty", async () => {
244
- const { getAccountConfig } = await import("./config.js");
245
- const { isAccountConfigured } = await import("./utils/twitch.js");
246
- const { getClientManager } = await import("./client-manager-registry.js");
247
-
248
- vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
249
- vi.mocked(isAccountConfigured).mockReturnValue(true);
235
+ setupAccountContext();
250
236
  const mockSend = vi.fn().mockResolvedValue({
251
237
  ok: true,
252
238
  messageId: "twitch-msg-789",
@@ -272,5 +258,52 @@ describe("send", () => {
272
258
  "default",
273
259
  );
274
260
  });
261
+
262
+ it("uses the configured default account when accountId is omitted", async () => {
263
+ const secondaryAccount = {
264
+ ...mockAccount,
265
+ username: "secondary-user",
266
+ channel: "secondary-channel",
267
+ };
268
+ vi.mocked(resolveTwitchAccountContext).mockImplementation((_cfg, accountId) => ({
269
+ accountId: accountId?.trim() || "secondary",
270
+ account: secondaryAccount,
271
+ tokenResolution: { source: "config", token: secondaryAccount.accessToken ?? "" },
272
+ configured: true,
273
+ availableAccountIds: ["default", "secondary"],
274
+ }));
275
+ const mockSend = vi.fn().mockResolvedValue({
276
+ ok: true,
277
+ messageId: "twitch-msg-secondary",
278
+ });
279
+ vi.mocked(getClientManager).mockReturnValue({
280
+ sendMessage: mockSend,
281
+ } as unknown as ReturnType<typeof getClientManager>);
282
+
283
+ const result = await sendMessageTwitchInternal(
284
+ "",
285
+ "Hello!",
286
+ {
287
+ channels: {
288
+ twitch: {
289
+ defaultAccount: "secondary",
290
+ },
291
+ },
292
+ } as never,
293
+ undefined,
294
+ false,
295
+ mockLogger as unknown as Console,
296
+ );
297
+
298
+ expect(result.ok).toBe(true);
299
+ expect(getClientManager).toHaveBeenCalledWith("secondary");
300
+ expect(mockSend).toHaveBeenCalledWith(
301
+ secondaryAccount,
302
+ "secondary-channel",
303
+ "Hello!",
304
+ expect.any(Object),
305
+ "secondary",
306
+ );
307
+ });
275
308
  });
276
309
  });
package/src/send.ts CHANGED
@@ -5,12 +5,12 @@
5
5
  * They support dependency injection via the `deps` parameter for testability.
6
6
  */
7
7
 
8
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
8
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
9
+ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
9
10
  import { getClientManager as getRegistryClientManager } from "./client-manager-registry.js";
10
- import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js";
11
- import { resolveTwitchToken } from "./token.js";
11
+ import { resolveTwitchAccountContext } from "./config.js";
12
12
  import { stripMarkdownForTwitch } from "./utils/markdown.js";
13
- import { generateMessageId, isAccountConfigured, normalizeTwitchChannel } from "./utils/twitch.js";
13
+ import { generateMessageId, normalizeTwitchChannel } from "./utils/twitch.js";
14
14
 
15
15
  /**
16
16
  * Result from sending a message to Twitch.
@@ -52,27 +52,30 @@ export async function sendMessageTwitchInternal(
52
52
  channel: string,
53
53
  text: string,
54
54
  cfg: OpenClawConfig,
55
- accountId: string = DEFAULT_ACCOUNT_ID,
55
+ accountId?: string,
56
56
  stripMarkdown: boolean = true,
57
57
  logger: Console = console,
58
58
  ): Promise<SendMessageResult> {
59
- const account = getAccountConfig(cfg, accountId);
59
+ const {
60
+ account,
61
+ configured,
62
+ availableAccountIds,
63
+ accountId: resolvedAccountId,
64
+ } = resolveTwitchAccountContext(cfg, accountId);
60
65
  if (!account) {
61
- const availableIds = Object.keys(cfg.channels?.twitch?.accounts ?? {});
62
66
  return {
63
67
  ok: false,
64
68
  messageId: generateMessageId(),
65
- error: `Account not found: ${accountId}. Available accounts: ${availableIds.join(", ") || "none"}`,
69
+ error: `Account not found: ${accountId ?? "(default)"}. Available accounts: ${availableAccountIds.join(", ") || "none"}`,
66
70
  };
67
71
  }
68
72
 
69
- const tokenResolution = resolveTwitchToken(cfg, { accountId });
70
- if (!isAccountConfigured(account, tokenResolution.token)) {
73
+ if (!configured) {
71
74
  return {
72
75
  ok: false,
73
76
  messageId: generateMessageId(),
74
77
  error:
75
- `Account ${accountId} is not properly configured. ` +
78
+ `Account ${resolvedAccountId} is not properly configured. ` +
76
79
  "Required: username, clientId, and token (config or env for default account).",
77
80
  };
78
81
  }
@@ -94,12 +97,12 @@ export async function sendMessageTwitchInternal(
94
97
  };
95
98
  }
96
99
 
97
- const clientManager = getRegistryClientManager(accountId);
100
+ const clientManager = getRegistryClientManager(resolvedAccountId);
98
101
  if (!clientManager) {
99
102
  return {
100
103
  ok: false,
101
104
  messageId: generateMessageId(),
102
- error: `Client manager not found for account: ${accountId}. Please start the Twitch gateway first.`,
105
+ error: `Client manager not found for account: ${resolvedAccountId}. Please start the Twitch gateway first.`,
103
106
  };
104
107
  }
105
108
 
@@ -109,7 +112,7 @@ export async function sendMessageTwitchInternal(
109
112
  normalizeTwitchChannel(normalizedChannel),
110
113
  cleanedText,
111
114
  cfg,
112
- accountId,
115
+ resolvedAccountId,
113
116
  );
114
117
 
115
118
  if (!result.ok) {
@@ -125,7 +128,7 @@ export async function sendMessageTwitchInternal(
125
128
  messageId: result.messageId ?? generateMessageId(),
126
129
  };
127
130
  } catch (error) {
128
- const errorMsg = error instanceof Error ? error.message : String(error);
131
+ const errorMsg = formatErrorMessage(error);
129
132
  logger.error(`Failed to send message: ${errorMsg}`);
130
133
  return {
131
134
  ok: false,