@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/types.ts CHANGED
@@ -5,26 +5,18 @@
5
5
  * from OpenClaw core.
6
6
  */
7
7
 
8
- import type {
9
- ChannelGatewayContext,
10
- ChannelOutboundAdapter,
11
- ChannelOutboundContext,
12
- ChannelResolveKind,
13
- ChannelResolveResult,
14
- ChannelStatusAdapter,
15
- } from "../../../src/channels/plugins/types.adapters.js";
16
8
  import type {
17
9
  ChannelAccountSnapshot,
18
- ChannelCapabilities,
19
10
  ChannelLogSink,
20
11
  ChannelMessageActionAdapter,
21
12
  ChannelMessageActionContext,
22
- ChannelMeta,
23
- } from "../../../src/channels/plugins/types.core.js";
24
- import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js";
25
- import type { OpenClawConfig } from "../../../src/config/config.js";
26
- import type { OutboundDeliveryResult } from "../../../src/infra/outbound/deliver.js";
27
- import type { RuntimeEnv } from "../../../src/runtime.js";
13
+ ChannelOutboundAdapter,
14
+ ChannelOutboundContext,
15
+ ChannelPlugin,
16
+ ChannelResolveKind,
17
+ ChannelResolveResult,
18
+ OutboundDeliveryResult,
19
+ } from "../runtime-api.js";
28
20
 
29
21
  // ============================================================================
30
22
  // Twitch-Specific Types
@@ -67,16 +59,6 @@ export interface TwitchAccountConfig {
67
59
  obtainmentTimestamp?: number;
68
60
  }
69
61
 
70
- /**
71
- * Message target for Twitch
72
- */
73
- export interface TwitchTarget {
74
- /** Account ID */
75
- accountId: string;
76
- /** Channel name (defaults to account's channel) */
77
- channel?: string;
78
- }
79
-
80
62
  /**
81
63
  * Twitch message from chat
82
64
  */
@@ -107,37 +89,16 @@ export interface TwitchChatMessage {
107
89
  chatType?: "group";
108
90
  }
109
91
 
110
- /**
111
- * Send result from Twitch client
112
- */
113
- export interface SendResult {
114
- ok: boolean;
115
- error?: string;
116
- messageId?: string;
117
- }
118
-
119
92
  // Re-export core types for convenience
120
93
  export type {
121
94
  ChannelAccountSnapshot,
122
- ChannelGatewayContext,
123
95
  ChannelLogSink,
124
96
  ChannelMessageActionAdapter,
125
97
  ChannelMessageActionContext,
126
- ChannelMeta,
127
98
  ChannelOutboundAdapter,
128
- ChannelStatusAdapter,
129
- ChannelCapabilities,
130
99
  ChannelResolveKind,
131
100
  ChannelResolveResult,
132
101
  ChannelPlugin,
133
102
  ChannelOutboundContext,
134
103
  OutboundDeliveryResult,
135
104
  };
136
-
137
- import type { z } from "zod";
138
- // Import and re-export the schema type
139
- import type { TwitchConfigSchema } from "./config-schema.js";
140
- export type TwitchConfig = z.infer<typeof TwitchConfigSchema>;
141
-
142
- export type { OpenClawConfig };
143
- export type { RuntimeEnv };
@@ -1,7 +1,13 @@
1
+ import { randomUUID } from "node:crypto";
2
+
1
3
  /**
2
4
  * Twitch-specific utility functions
3
5
  */
4
6
 
7
+ function normalizeLowercaseStringOrEmpty(value: unknown): string {
8
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
9
+ }
10
+
5
11
  /**
6
12
  * Normalize Twitch channel names.
7
13
  *
@@ -16,7 +22,7 @@
16
22
  * normalizeTwitchChannel("MyChannel") // "mychannel"
17
23
  */
18
24
  export function normalizeTwitchChannel(channel: string): string {
19
- const trimmed = channel.trim().toLowerCase();
25
+ const trimmed = normalizeLowercaseStringOrEmpty(channel);
20
26
  return trimmed.startsWith("#") ? trimmed.slice(1) : trimmed;
21
27
  }
22
28
 
@@ -40,7 +46,7 @@ export function missingTargetError(provider: string, hint?: string): Error {
40
46
  * @returns A unique message ID
41
47
  */
42
48
  export function generateMessageId(): string {
43
- return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
49
+ return `${Date.now()}-${randomUUID()}`;
44
50
  }
45
51
 
46
52
  /**
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": "../tsconfig.package-boundary.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "."
5
+ },
6
+ "include": ["./*.ts", "./src/**/*.ts"],
7
+ "exclude": [
8
+ "./**/*.test.ts",
9
+ "./dist/**",
10
+ "./node_modules/**",
11
+ "./src/test-support/**",
12
+ "./src/**/*test-helpers.ts",
13
+ "./src/**/*test-harness.ts",
14
+ "./src/**/*test-support.ts"
15
+ ]
16
+ }
package/CHANGELOG.md DELETED
@@ -1,21 +0,0 @@
1
- # Changelog
2
-
3
- ## 2026.1.23
4
-
5
- ### Features
6
-
7
- - Initial Twitch plugin release
8
- - Twitch chat integration via @twurple (IRC connection)
9
- - Multi-account support with per-channel configuration
10
- - Access control via user ID allowlists and role-based restrictions
11
- - Automatic token refresh with RefreshingAuthProvider
12
- - Environment variable fallback for default account token
13
- - Message actions support
14
- - Status monitoring and probing
15
- - Outbound message delivery with markdown stripping
16
-
17
- ### Improvements
18
-
19
- - Added proper configuration schema with Zod validation
20
- - Added plugin descriptor (openclaw.plugin.json)
21
- - Added comprehensive README and documentation
@@ -1,316 +0,0 @@
1
- /**
2
- * Tests for onboarding.ts helpers
3
- *
4
- * Tests cover:
5
- * - promptToken helper
6
- * - promptUsername helper
7
- * - promptClientId helper
8
- * - promptChannelName helper
9
- * - promptRefreshTokenSetup helper
10
- * - configureWithEnvToken helper
11
- * - setTwitchAccount config updates
12
- */
13
-
14
- import type { WizardPrompter } from "openclaw/plugin-sdk";
15
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
- import type { TwitchAccountConfig } from "./types.js";
17
-
18
- vi.mock("openclaw/plugin-sdk", () => ({
19
- formatDocsLink: (url: string, fallback: string) => fallback || url,
20
- promptChannelAccessConfig: vi.fn(async () => null),
21
- }));
22
-
23
- // Mock the helpers we're testing
24
- const mockPromptText = vi.fn();
25
- const mockPromptConfirm = vi.fn();
26
- const mockPrompter: WizardPrompter = {
27
- text: mockPromptText,
28
- confirm: mockPromptConfirm,
29
- } as unknown as WizardPrompter;
30
-
31
- const mockAccount: TwitchAccountConfig = {
32
- username: "testbot",
33
- accessToken: "oauth:test123",
34
- clientId: "test-client-id",
35
- channel: "#testchannel",
36
- };
37
-
38
- describe("onboarding helpers", () => {
39
- beforeEach(() => {
40
- vi.clearAllMocks();
41
- });
42
-
43
- afterEach(() => {
44
- // Don't restoreAllMocks as it breaks module-level mocks
45
- });
46
-
47
- describe("promptToken", () => {
48
- it("should return existing token when user confirms to keep it", async () => {
49
- const { promptToken } = await import("./onboarding.js");
50
-
51
- mockPromptConfirm.mockResolvedValue(true);
52
-
53
- const result = await promptToken(mockPrompter, mockAccount, undefined);
54
-
55
- expect(result).toBe("oauth:test123");
56
- expect(mockPromptConfirm).toHaveBeenCalledWith({
57
- message: "Access token already configured. Keep it?",
58
- initialValue: true,
59
- });
60
- expect(mockPromptText).not.toHaveBeenCalled();
61
- });
62
-
63
- it("should prompt for new token when user doesn't keep existing", async () => {
64
- const { promptToken } = await import("./onboarding.js");
65
-
66
- mockPromptConfirm.mockResolvedValue(false);
67
- mockPromptText.mockResolvedValue("oauth:newtoken123");
68
-
69
- const result = await promptToken(mockPrompter, mockAccount, undefined);
70
-
71
- expect(result).toBe("oauth:newtoken123");
72
- expect(mockPromptText).toHaveBeenCalledWith({
73
- message: "Twitch OAuth token (oauth:...)",
74
- initialValue: "",
75
- validate: expect.any(Function),
76
- });
77
- });
78
-
79
- it("should use env token as initial value when provided", async () => {
80
- const { promptToken } = await import("./onboarding.js");
81
-
82
- mockPromptConfirm.mockResolvedValue(false);
83
- mockPromptText.mockResolvedValue("oauth:fromenv");
84
-
85
- await promptToken(mockPrompter, null, "oauth:fromenv");
86
-
87
- expect(mockPromptText).toHaveBeenCalledWith(
88
- expect.objectContaining({
89
- initialValue: "oauth:fromenv",
90
- }),
91
- );
92
- });
93
-
94
- it("should validate token format", async () => {
95
- const { promptToken } = await import("./onboarding.js");
96
-
97
- // Set up mocks - user doesn't want to keep existing token
98
- mockPromptConfirm.mockResolvedValueOnce(false);
99
-
100
- // Track how many times promptText is called
101
- let promptTextCallCount = 0;
102
- let capturedValidate: ((value: string) => string | undefined) | undefined;
103
-
104
- mockPromptText.mockImplementationOnce((_args) => {
105
- promptTextCallCount++;
106
- // Capture the validate function from the first argument
107
- if (_args?.validate) {
108
- capturedValidate = _args.validate;
109
- }
110
- return Promise.resolve("oauth:test123");
111
- });
112
-
113
- // Call promptToken
114
- const result = await promptToken(mockPrompter, mockAccount, undefined);
115
-
116
- // Verify promptText was called
117
- expect(promptTextCallCount).toBe(1);
118
- expect(result).toBe("oauth:test123");
119
-
120
- // Test the validate function
121
- expect(capturedValidate).toBeDefined();
122
- expect(capturedValidate!("")).toBe("Required");
123
- expect(capturedValidate!("notoauth")).toBe("Token should start with 'oauth:'");
124
- });
125
-
126
- it("should return early when no existing token and no env token", async () => {
127
- const { promptToken } = await import("./onboarding.js");
128
-
129
- mockPromptText.mockResolvedValue("oauth:newtoken");
130
-
131
- const result = await promptToken(mockPrompter, null, undefined);
132
-
133
- expect(result).toBe("oauth:newtoken");
134
- expect(mockPromptConfirm).not.toHaveBeenCalled();
135
- });
136
- });
137
-
138
- describe("promptUsername", () => {
139
- it("should prompt for username with validation", async () => {
140
- const { promptUsername } = await import("./onboarding.js");
141
-
142
- mockPromptText.mockResolvedValue("mybot");
143
-
144
- const result = await promptUsername(mockPrompter, null);
145
-
146
- expect(result).toBe("mybot");
147
- expect(mockPromptText).toHaveBeenCalledWith({
148
- message: "Twitch bot username",
149
- initialValue: "",
150
- validate: expect.any(Function),
151
- });
152
- });
153
-
154
- it("should use existing username as initial value", async () => {
155
- const { promptUsername } = await import("./onboarding.js");
156
-
157
- mockPromptText.mockResolvedValue("testbot");
158
-
159
- await promptUsername(mockPrompter, mockAccount);
160
-
161
- expect(mockPromptText).toHaveBeenCalledWith(
162
- expect.objectContaining({
163
- initialValue: "testbot",
164
- }),
165
- );
166
- });
167
- });
168
-
169
- describe("promptClientId", () => {
170
- it("should prompt for client ID with validation", async () => {
171
- const { promptClientId } = await import("./onboarding.js");
172
-
173
- mockPromptText.mockResolvedValue("abc123xyz");
174
-
175
- const result = await promptClientId(mockPrompter, null);
176
-
177
- expect(result).toBe("abc123xyz");
178
- expect(mockPromptText).toHaveBeenCalledWith({
179
- message: "Twitch Client ID",
180
- initialValue: "",
181
- validate: expect.any(Function),
182
- });
183
- });
184
- });
185
-
186
- describe("promptChannelName", () => {
187
- it("should return channel name when provided", async () => {
188
- const { promptChannelName } = await import("./onboarding.js");
189
-
190
- mockPromptText.mockResolvedValue("#mychannel");
191
-
192
- const result = await promptChannelName(mockPrompter, null);
193
-
194
- expect(result).toBe("#mychannel");
195
- });
196
-
197
- it("should require a non-empty channel name", async () => {
198
- const { promptChannelName } = await import("./onboarding.js");
199
-
200
- mockPromptText.mockResolvedValue("");
201
-
202
- await promptChannelName(mockPrompter, null);
203
-
204
- const { validate } = mockPromptText.mock.calls[0]?.[0] ?? {};
205
- expect(validate?.("")).toBe("Required");
206
- expect(validate?.(" ")).toBe("Required");
207
- expect(validate?.("#chan")).toBeUndefined();
208
- });
209
- });
210
-
211
- describe("promptRefreshTokenSetup", () => {
212
- it("should return empty object when user declines", async () => {
213
- const { promptRefreshTokenSetup } = await import("./onboarding.js");
214
-
215
- mockPromptConfirm.mockResolvedValue(false);
216
-
217
- const result = await promptRefreshTokenSetup(mockPrompter, mockAccount);
218
-
219
- expect(result).toEqual({});
220
- expect(mockPromptConfirm).toHaveBeenCalledWith({
221
- message: "Enable automatic token refresh (requires client secret and refresh token)?",
222
- initialValue: false,
223
- });
224
- });
225
-
226
- it("should prompt for credentials when user accepts", async () => {
227
- const { promptRefreshTokenSetup } = await import("./onboarding.js");
228
-
229
- mockPromptConfirm
230
- .mockResolvedValueOnce(true) // First call: useRefresh
231
- .mockResolvedValueOnce("secret123") // clientSecret
232
- .mockResolvedValueOnce("refresh123"); // refreshToken
233
-
234
- mockPromptText.mockResolvedValueOnce("secret123").mockResolvedValueOnce("refresh123");
235
-
236
- const result = await promptRefreshTokenSetup(mockPrompter, null);
237
-
238
- expect(result).toEqual({
239
- clientSecret: "secret123",
240
- refreshToken: "refresh123",
241
- });
242
- });
243
-
244
- it("should use existing values as initial prompts", async () => {
245
- const { promptRefreshTokenSetup } = await import("./onboarding.js");
246
-
247
- const accountWithRefresh = {
248
- ...mockAccount,
249
- clientSecret: "existing-secret",
250
- refreshToken: "existing-refresh",
251
- };
252
-
253
- mockPromptConfirm.mockResolvedValue(true);
254
- mockPromptText
255
- .mockResolvedValueOnce("existing-secret")
256
- .mockResolvedValueOnce("existing-refresh");
257
-
258
- await promptRefreshTokenSetup(mockPrompter, accountWithRefresh);
259
-
260
- expect(mockPromptConfirm).toHaveBeenCalledWith(
261
- expect.objectContaining({
262
- initialValue: true, // Both clientSecret and refreshToken exist
263
- }),
264
- );
265
- });
266
- });
267
-
268
- describe("configureWithEnvToken", () => {
269
- it("should return null when user declines env token", async () => {
270
- const { configureWithEnvToken } = await import("./onboarding.js");
271
-
272
- // Reset and set up mock - user declines env token
273
- mockPromptConfirm.mockReset().mockResolvedValue(false as never);
274
-
275
- const result = await configureWithEnvToken(
276
- {} as Parameters<typeof configureWithEnvToken>[0],
277
- mockPrompter,
278
- null,
279
- "oauth:fromenv",
280
- false,
281
- {} as Parameters<typeof configureWithEnvToken>[5],
282
- );
283
-
284
- // Since user declined, should return null without prompting for username/clientId
285
- expect(result).toBeNull();
286
- expect(mockPromptText).not.toHaveBeenCalled();
287
- });
288
-
289
- it("should prompt for username and clientId when using env token", async () => {
290
- const { configureWithEnvToken } = await import("./onboarding.js");
291
-
292
- // Reset and set up mocks - user accepts env token
293
- mockPromptConfirm.mockReset().mockResolvedValue(true as never);
294
-
295
- // Set up mocks for username and clientId prompts
296
- mockPromptText
297
- .mockReset()
298
- .mockResolvedValueOnce("testbot" as never)
299
- .mockResolvedValueOnce("test-client-id" as never);
300
-
301
- const result = await configureWithEnvToken(
302
- {} as Parameters<typeof configureWithEnvToken>[0],
303
- mockPrompter,
304
- null,
305
- "oauth:fromenv",
306
- false,
307
- {} as Parameters<typeof configureWithEnvToken>[5],
308
- );
309
-
310
- // Should return config with username and clientId
311
- expect(result).not.toBeNull();
312
- expect(result?.cfg.channels?.twitch?.accounts?.default?.username).toBe("testbot");
313
- expect(result?.cfg.channels?.twitch?.accounts?.default?.clientId).toBe("test-client-id");
314
- });
315
- });
316
- });