@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
@@ -14,17 +14,28 @@ import { describe, expect, it } from "vitest";
14
14
  import { collectTwitchStatusIssues } from "./status.js";
15
15
  import type { ChannelAccountSnapshot } from "./types.js";
16
16
 
17
+ function createSnapshot(overrides: Partial<ChannelAccountSnapshot> = {}): ChannelAccountSnapshot {
18
+ return {
19
+ accountId: "default",
20
+ configured: true,
21
+ enabled: true,
22
+ running: false,
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ function createSimpleTwitchConfig(overrides: Record<string, unknown>) {
28
+ return {
29
+ channels: {
30
+ twitch: overrides,
31
+ },
32
+ };
33
+ }
34
+
17
35
  describe("status", () => {
18
36
  describe("collectTwitchStatusIssues", () => {
19
37
  it("should detect unconfigured accounts", () => {
20
- const snapshots: ChannelAccountSnapshot[] = [
21
- {
22
- accountId: "default",
23
- configured: false,
24
- enabled: true,
25
- running: false,
26
- },
27
- ];
38
+ const snapshots: ChannelAccountSnapshot[] = [createSnapshot({ configured: false })];
28
39
 
29
40
  const issues = collectTwitchStatusIssues(snapshots);
30
41
 
@@ -34,215 +45,166 @@ describe("status", () => {
34
45
  });
35
46
 
36
47
  it("should detect disabled accounts", () => {
37
- const snapshots: ChannelAccountSnapshot[] = [
38
- {
39
- accountId: "default",
40
- configured: true,
41
- enabled: false,
42
- running: false,
43
- },
44
- ];
48
+ const snapshots: ChannelAccountSnapshot[] = [createSnapshot({ enabled: false })];
45
49
 
46
50
  const issues = collectTwitchStatusIssues(snapshots);
47
51
 
48
52
  expect(issues.length).toBeGreaterThan(0);
49
- const disabledIssue = issues.find((i) => i.message.includes("disabled"));
50
- expect(disabledIssue).toBeDefined();
53
+ expect(issues).toContainEqual(
54
+ expect.objectContaining({
55
+ kind: "config",
56
+ message: "Twitch account is disabled",
57
+ }),
58
+ );
51
59
  });
52
60
 
53
61
  it("should detect missing clientId when account configured (simplified config)", () => {
54
- const snapshots: ChannelAccountSnapshot[] = [
55
- {
56
- accountId: "default",
57
- configured: true,
58
- enabled: true,
59
- running: false,
60
- },
61
- ];
62
-
63
- const mockCfg = {
64
- channels: {
65
- twitch: {
66
- username: "testbot",
67
- accessToken: "oauth:test123",
68
- // clientId missing
69
- },
70
- },
71
- };
62
+ const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
63
+ const mockCfg = createSimpleTwitchConfig({
64
+ username: "testbot",
65
+ accessToken: "oauth:test123",
66
+ // clientId missing
67
+ });
72
68
 
73
69
  const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
74
70
 
75
- const clientIdIssue = issues.find((i) => i.message.includes("client ID"));
76
- expect(clientIdIssue).toBeDefined();
71
+ expect(issues).toContainEqual(
72
+ expect.objectContaining({
73
+ kind: "config",
74
+ message: "Twitch client ID is required",
75
+ }),
76
+ );
77
77
  });
78
78
 
79
79
  it("should warn about oauth: prefix in token (simplified config)", () => {
80
- const snapshots: ChannelAccountSnapshot[] = [
81
- {
82
- accountId: "default",
83
- configured: true,
84
- enabled: true,
85
- running: false,
86
- },
87
- ];
88
-
89
- const mockCfg = {
90
- channels: {
91
- twitch: {
92
- username: "testbot",
93
- accessToken: "oauth:test123", // has prefix
94
- clientId: "test-id",
95
- },
96
- },
97
- };
80
+ const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
81
+ const mockCfg = createSimpleTwitchConfig({
82
+ username: "testbot",
83
+ accessToken: "oauth:test123", // has prefix
84
+ clientId: "test-id",
85
+ });
98
86
 
99
87
  const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
100
88
 
101
- const prefixIssue = issues.find((i) => i.message.includes("oauth:"));
102
- expect(prefixIssue).toBeDefined();
103
- expect(prefixIssue?.kind).toBe("config");
89
+ expect(issues).toContainEqual(
90
+ expect.objectContaining({
91
+ kind: "config",
92
+ message: "Token contains 'oauth:' prefix (will be stripped)",
93
+ }),
94
+ );
104
95
  });
105
96
 
106
97
  it("should detect clientSecret without refreshToken (simplified config)", () => {
107
- const snapshots: ChannelAccountSnapshot[] = [
108
- {
109
- accountId: "default",
110
- configured: true,
111
- enabled: true,
112
- running: false,
113
- },
114
- ];
115
-
116
- const mockCfg = {
117
- channels: {
118
- twitch: {
119
- username: "testbot",
120
- accessToken: "oauth:test123",
121
- clientId: "test-id",
122
- clientSecret: "secret123",
123
- // refreshToken missing
124
- },
125
- },
126
- };
98
+ const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
99
+ const mockCfg = createSimpleTwitchConfig({
100
+ username: "testbot",
101
+ accessToken: "oauth:test123",
102
+ clientId: "test-id",
103
+ clientSecret: "secret123",
104
+ // refreshToken missing
105
+ });
127
106
 
128
107
  const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
129
108
 
130
- const secretIssue = issues.find((i) => i.message.includes("clientSecret"));
131
- expect(secretIssue).toBeDefined();
109
+ expect(issues).toContainEqual(
110
+ expect.objectContaining({
111
+ kind: "config",
112
+ message: "clientSecret provided without refreshToken",
113
+ }),
114
+ );
132
115
  });
133
116
 
134
117
  it("should detect empty allowFrom array (simplified config)", () => {
135
- const snapshots: ChannelAccountSnapshot[] = [
136
- {
137
- accountId: "default",
138
- configured: true,
139
- enabled: true,
140
- running: false,
141
- },
142
- ];
143
-
144
- const mockCfg = {
145
- channels: {
146
- twitch: {
147
- username: "testbot",
148
- accessToken: "test123",
149
- clientId: "test-id",
150
- allowFrom: [], // empty array
151
- },
152
- },
153
- };
118
+ const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
119
+ const mockCfg = createSimpleTwitchConfig({
120
+ username: "testbot",
121
+ accessToken: "test123",
122
+ clientId: "test-id",
123
+ allowFrom: [], // empty array
124
+ });
154
125
 
155
126
  const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
156
127
 
157
- const allowFromIssue = issues.find((i) => i.message.includes("allowFrom"));
158
- expect(allowFromIssue).toBeDefined();
128
+ expect(issues).toContainEqual(
129
+ expect.objectContaining({
130
+ kind: "config",
131
+ message: "allowFrom is configured but empty",
132
+ }),
133
+ );
159
134
  });
160
135
 
161
136
  it("should detect allowedRoles 'all' with allowFrom conflict (simplified config)", () => {
162
- const snapshots: ChannelAccountSnapshot[] = [
163
- {
164
- accountId: "default",
165
- configured: true,
166
- enabled: true,
167
- running: false,
168
- },
169
- ];
170
-
171
- const mockCfg = {
172
- channels: {
173
- twitch: {
174
- username: "testbot",
175
- accessToken: "test123",
176
- clientId: "test-id",
177
- allowedRoles: ["all"],
178
- allowFrom: ["123456"], // conflict!
179
- },
180
- },
181
- };
137
+ const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
138
+ const mockCfg = createSimpleTwitchConfig({
139
+ username: "testbot",
140
+ accessToken: "test123",
141
+ clientId: "test-id",
142
+ allowedRoles: ["all"],
143
+ allowFrom: ["123456"], // conflict!
144
+ });
182
145
 
183
146
  const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
184
147
 
185
- const conflictIssue = issues.find((i) => i.kind === "intent");
186
- expect(conflictIssue).toBeDefined();
187
- expect(conflictIssue?.message).toContain("allowedRoles is set to 'all'");
148
+ expect(issues).toContainEqual(
149
+ expect.objectContaining({
150
+ kind: "intent",
151
+ message: "allowedRoles is set to 'all' but allowFrom is also configured",
152
+ }),
153
+ );
188
154
  });
189
155
 
190
156
  it("should detect runtime errors", () => {
191
157
  const snapshots: ChannelAccountSnapshot[] = [
192
- {
193
- accountId: "default",
194
- configured: true,
195
- enabled: true,
196
- running: false,
197
- lastError: "Connection timeout",
198
- },
158
+ createSnapshot({ lastError: "Connection timeout" }),
199
159
  ];
200
160
 
201
161
  const issues = collectTwitchStatusIssues(snapshots);
202
162
 
203
- const runtimeIssue = issues.find((i) => i.kind === "runtime");
204
- expect(runtimeIssue).toBeDefined();
205
- expect(runtimeIssue?.message).toContain("Connection timeout");
163
+ expect(issues).toContainEqual(
164
+ expect.objectContaining({
165
+ kind: "runtime",
166
+ message: "Last error: Connection timeout",
167
+ }),
168
+ );
206
169
  });
207
170
 
208
171
  it("should detect accounts that never connected", () => {
209
172
  const snapshots: ChannelAccountSnapshot[] = [
210
- {
211
- accountId: "default",
212
- configured: true,
213
- enabled: true,
214
- running: false,
173
+ createSnapshot({
215
174
  lastStartAt: undefined,
216
175
  lastInboundAt: undefined,
217
176
  lastOutboundAt: undefined,
218
- },
177
+ }),
219
178
  ];
220
179
 
221
180
  const issues = collectTwitchStatusIssues(snapshots);
222
181
 
223
- const neverConnectedIssue = issues.find((i) =>
224
- i.message.includes("never connected successfully"),
182
+ expect(issues).toContainEqual(
183
+ expect.objectContaining({
184
+ kind: "runtime",
185
+ message: "Account has never connected successfully",
186
+ }),
225
187
  );
226
- expect(neverConnectedIssue).toBeDefined();
227
188
  });
228
189
 
229
190
  it("should detect long-running connections", () => {
230
191
  const oldDate = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days ago
231
192
 
232
193
  const snapshots: ChannelAccountSnapshot[] = [
233
- {
234
- accountId: "default",
235
- configured: true,
236
- enabled: true,
194
+ createSnapshot({
237
195
  running: true,
238
196
  lastStartAt: oldDate,
239
- },
197
+ }),
240
198
  ];
241
199
 
242
200
  const issues = collectTwitchStatusIssues(snapshots);
243
201
 
244
- const uptimeIssue = issues.find((i) => i.message.includes("running for"));
245
- expect(uptimeIssue).toBeDefined();
202
+ expect(issues).toContainEqual(
203
+ expect.objectContaining({
204
+ kind: "runtime",
205
+ message: "Connection has been running for 8 days",
206
+ }),
207
+ );
246
208
  });
247
209
 
248
210
  it("should handle empty snapshots array", () => {
@@ -263,8 +225,13 @@ describe("status", () => {
263
225
 
264
226
  const issues = collectTwitchStatusIssues(snapshots);
265
227
 
266
- // Should not crash, may return empty or minimal issues
267
- expect(Array.isArray(issues)).toBe(true);
228
+ expect(issues).toEqual([
229
+ expect.objectContaining({
230
+ accountId: "unknown",
231
+ kind: "config",
232
+ message: "Twitch account is not properly configured",
233
+ }),
234
+ ]);
268
235
  });
269
236
  });
270
237
  });
package/src/status.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Detects and reports configuration issues for Twitch accounts.
5
5
  */
6
6
 
7
- import type { ChannelStatusIssue } from "openclaw/plugin-sdk";
7
+ import type { ChannelStatusIssue } from "openclaw/plugin-sdk/channel-contract";
8
8
  import { getAccountConfig } from "./config.js";
9
9
  import { resolveTwitchToken } from "./token.js";
10
10
  import type { ChannelAccountSnapshot } from "./types.js";
@@ -1,5 +1,5 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
1
  import { afterEach, beforeEach, vi } from "vitest";
2
+ import type { OpenClawConfig } from "../runtime-api.js";
3
3
 
4
4
  export const BASE_TWITCH_TEST_ACCOUNT = {
5
5
  username: "testbot",
package/src/token.test.ts CHANGED
@@ -8,8 +8,8 @@
8
8
  * - Account ID normalization
9
9
  */
10
10
 
11
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
12
11
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
+ import type { OpenClawConfig } from "../api.js";
13
13
  import { resolveTwitchToken, type TwitchTokenSource } from "./token.js";
14
14
 
15
15
  describe("token", () => {
@@ -65,6 +65,27 @@ describe("token", () => {
65
65
  expect(result.source).toBe("config");
66
66
  });
67
67
 
68
+ it("should resolve token from normalized account id", () => {
69
+ const result = resolveTwitchToken(
70
+ {
71
+ channels: {
72
+ twitch: {
73
+ accounts: {
74
+ Secondary: {
75
+ username: "secondary",
76
+ accessToken: "oauth:secondary-token",
77
+ },
78
+ },
79
+ },
80
+ },
81
+ } as unknown as OpenClawConfig,
82
+ { accountId: "secondary" },
83
+ );
84
+
85
+ expect(result.token).toBe("oauth:secondary-token");
86
+ expect(result.source).toBe("config");
87
+ });
88
+
68
89
  it("should prioritize config token over env var (simplified config)", () => {
69
90
  process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:env-token";
70
91
 
package/src/token.ts CHANGED
@@ -9,8 +9,12 @@
9
9
  * 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only)
10
10
  */
11
11
 
12
- import type { OpenClawConfig } from "../../../src/config/config.js";
13
- import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
12
+ import {
13
+ DEFAULT_ACCOUNT_ID,
14
+ normalizeAccountId,
15
+ resolveNormalizedAccountEntry,
16
+ } from "openclaw/plugin-sdk/account-resolution";
17
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
14
18
 
15
19
  export type TwitchTokenSource = "env" | "config" | "none";
16
20
 
@@ -56,10 +60,8 @@ export function resolveTwitchToken(
56
60
 
57
61
  // Get merged account config (handles both simplified and multi-account patterns)
58
62
  const twitchCfg = cfg?.channels?.twitch;
59
- const accountCfg =
60
- accountId === DEFAULT_ACCOUNT_ID
61
- ? (twitchCfg?.accounts?.[DEFAULT_ACCOUNT_ID] as Record<string, unknown> | undefined)
62
- : (twitchCfg?.accounts?.[accountId] as Record<string, unknown> | undefined);
63
+ const accounts = twitchCfg?.accounts as Record<string, Record<string, unknown>> | undefined;
64
+ const accountCfg = resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId);
63
65
 
64
66
  // For default account, also check base-level config
65
67
  let token: string | undefined;
@@ -9,7 +9,8 @@
9
9
  * - Error handling and edge cases
10
10
  */
11
11
 
12
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
+ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
13
+ import { resolveTwitchToken } from "./token.js";
13
14
  import { TwitchClientManager } from "./twitch-client.js";
14
15
  import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js";
15
16
 
@@ -21,12 +22,10 @@ const mockQuit = vi.fn();
21
22
  const mockUnbind = vi.fn();
22
23
 
23
24
  // Event handler storage for testing
24
- // oxlint-disable-next-line typescript/no-explicit-any
25
25
  const messageHandlers: Array<(channel: string, user: string, message: string, msg: any) => void> =
26
26
  [];
27
27
 
28
28
  // Mock functions that track handlers and return unbind objects
29
- // oxlint-disable-next-line typescript/no-explicit-any
30
29
  const mockOnMessage = vi.fn((handler: any) => {
31
30
  messageHandlers.push(handler);
32
31
  return { unbind: mockUnbind };
@@ -59,10 +58,8 @@ const mockAuthProvider = {
59
58
  };
60
59
 
61
60
  vi.mock("@twurple/auth", () => ({
62
- StaticAuthProvider: class {
63
- constructor(...args: unknown[]) {
64
- mockAuthProvider.constructor(...args);
65
- }
61
+ StaticAuthProvider: function StaticAuthProvider(...args: unknown[]) {
62
+ mockAuthProvider.constructor(...args);
66
63
  },
67
64
  RefreshingAuthProvider: class {
68
65
  addUserForToken = mockAddUserForToken;
@@ -83,6 +80,7 @@ vi.mock("./token.js", () => ({
83
80
  describe("TwitchClientManager", () => {
84
81
  let manager: TwitchClientManager;
85
82
  let mockLogger: ChannelLogSink;
83
+ let resolveTwitchTokenMock: ReturnType<typeof vi.mocked<typeof resolveTwitchToken>>;
86
84
 
87
85
  const testAccount: TwitchAccountConfig = {
88
86
  username: "testbot",
@@ -100,7 +98,11 @@ describe("TwitchClientManager", () => {
100
98
  enabled: true,
101
99
  };
102
100
 
103
- beforeEach(async () => {
101
+ beforeAll(() => {
102
+ resolveTwitchTokenMock = vi.mocked(resolveTwitchToken);
103
+ });
104
+
105
+ beforeEach(() => {
104
106
  // Clear all mocks first
105
107
  vi.clearAllMocks();
106
108
 
@@ -108,8 +110,7 @@ describe("TwitchClientManager", () => {
108
110
  messageHandlers.length = 0;
109
111
 
110
112
  // Re-set up the default token mock implementation after clearing
111
- const { resolveTwitchToken } = await import("./token.js");
112
- vi.mocked(resolveTwitchToken).mockReturnValue({
113
+ resolveTwitchTokenMock.mockReturnValue({
113
114
  token: "oauth:mock-token-from-tests",
114
115
  source: "config" as const,
115
116
  });
@@ -176,8 +177,7 @@ describe("TwitchClientManager", () => {
176
177
  };
177
178
 
178
179
  // Override the mock to return a specific token for this test
179
- const { resolveTwitchToken } = await import("./token.js");
180
- vi.mocked(resolveTwitchToken).mockReturnValue({
180
+ resolveTwitchTokenMock.mockReturnValue({
181
181
  token: "oauth:actualtoken123",
182
182
  source: "config" as const,
183
183
  });
@@ -189,8 +189,7 @@ describe("TwitchClientManager", () => {
189
189
 
190
190
  it("should use token directly when no oauth: prefix", async () => {
191
191
  // Override the mock to return a token without oauth: prefix
192
- const { resolveTwitchToken } = await import("./token.js");
193
- vi.mocked(resolveTwitchToken).mockReturnValue({
192
+ resolveTwitchTokenMock.mockReturnValue({
194
193
  token: "oauth:mock-token-from-tests",
195
194
  source: "config" as const,
196
195
  });
@@ -221,8 +220,7 @@ describe("TwitchClientManager", () => {
221
220
 
222
221
  it("should throw error when token is missing", async () => {
223
222
  // Override the mock to return empty token
224
- const { resolveTwitchToken } = await import("./token.js");
225
- vi.mocked(resolveTwitchToken).mockReturnValue({
223
+ resolveTwitchTokenMock.mockReturnValue({
226
224
  token: "",
227
225
  source: "none" as const,
228
226
  });
@@ -271,7 +269,6 @@ describe("TwitchClientManager", () => {
271
269
 
272
270
  // Check the stored handler is handler2
273
271
  const key = manager.getAccountKey(testAccount);
274
- // oxlint-disable-next-line typescript/no-explicit-any
275
272
  expect((manager as any).messageHandlers.get(key)).toBe(handler2);
276
273
  });
277
274
  });
@@ -293,9 +290,7 @@ describe("TwitchClientManager", () => {
293
290
  await manager.disconnect(testAccount);
294
291
 
295
292
  const key = manager.getAccountKey(testAccount);
296
- // oxlint-disable-next-line typescript/no-explicit-any
297
293
  expect((manager as any).clients.has(key)).toBe(false);
298
- // oxlint-disable-next-line typescript/no-explicit-any
299
294
  expect((manager as any).messageHandlers.has(key)).toBe(false);
300
295
  });
301
296
 
@@ -314,7 +309,6 @@ describe("TwitchClientManager", () => {
314
309
  expect(mockQuit).toHaveBeenCalledTimes(1);
315
310
 
316
311
  const key2 = manager.getAccountKey(testAccount2);
317
- // oxlint-disable-next-line typescript/no-explicit-any
318
312
  expect((manager as any).clients.has(key2)).toBe(true);
319
313
  });
320
314
  });
@@ -327,9 +321,7 @@ describe("TwitchClientManager", () => {
327
321
  await manager.disconnectAll();
328
322
 
329
323
  expect(mockQuit).toHaveBeenCalledTimes(2);
330
- // oxlint-disable-next-line typescript/no-explicit-any
331
324
  expect((manager as any).clients.size).toBe(0);
332
- // oxlint-disable-next-line typescript/no-explicit-any
333
325
  expect((manager as any).messageHandlers.size).toBe(0);
334
326
  });
335
327
 
@@ -348,8 +340,10 @@ describe("TwitchClientManager", () => {
348
340
  it("should send message successfully", async () => {
349
341
  const result = await manager.sendMessage(testAccount, "testchannel", "Hello, world!");
350
342
 
351
- expect(result.ok).toBe(true);
352
- expect(result.messageId).toBeDefined();
343
+ expect(result).toMatchObject({ ok: true });
344
+ expect(result.messageId).toMatch(
345
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
346
+ );
353
347
  expect(mockSay).toHaveBeenCalledWith("testchannel", "Hello, world!");
354
348
  });
355
349
 
@@ -395,7 +389,6 @@ describe("TwitchClientManager", () => {
395
389
 
396
390
  it("should create client if not already connected", async () => {
397
391
  // Clear the existing client
398
- // oxlint-disable-next-line typescript/no-explicit-any
399
392
  (manager as any).clients.clear();
400
393
 
401
394
  // Reset connect call count for this specific test
@@ -1,6 +1,7 @@
1
1
  import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth";
2
2
  import { ChatClient, LogLevel } from "@twurple/chat";
3
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
4
+ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
4
5
  import { resolveTwitchToken } from "./token.js";
5
6
  import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js";
6
7
  import { normalizeToken } from "./utils/twitch.js";
@@ -45,7 +46,7 @@ export class TwitchClientManager {
45
46
  })
46
47
  .catch((err) => {
47
48
  this.logger.error(
48
- `Failed to add user to RefreshingAuthProvider: ${err instanceof Error ? err.message : String(err)}`,
49
+ `Failed to add user to RefreshingAuthProvider: ${formatErrorMessage(err)}`,
49
50
  );
50
51
  });
51
52
 
@@ -250,12 +251,10 @@ export class TwitchClientManager {
250
251
 
251
252
  return { ok: true, messageId };
252
253
  } catch (error) {
253
- this.logger.error(
254
- `Failed to send message: ${error instanceof Error ? error.message : String(error)}`,
255
- );
254
+ this.logger.error(`Failed to send message: ${formatErrorMessage(error)}`);
256
255
  return {
257
256
  ok: false,
258
- error: error instanceof Error ? error.message : String(error),
257
+ error: formatErrorMessage(error),
259
258
  };
260
259
  }
261
260
  }