@openclaw/twitch 2026.2.21
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.
- package/CHANGELOG.md +21 -0
- package/README.md +89 -0
- package/index.ts +20 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +20 -0
- package/src/access-control.test.ts +491 -0
- package/src/access-control.ts +166 -0
- package/src/actions.ts +174 -0
- package/src/client-manager-registry.ts +115 -0
- package/src/config-schema.ts +84 -0
- package/src/config.test.ts +87 -0
- package/src/config.ts +116 -0
- package/src/monitor.ts +273 -0
- package/src/onboarding.test.ts +316 -0
- package/src/onboarding.ts +417 -0
- package/src/outbound.test.ts +403 -0
- package/src/outbound.ts +187 -0
- package/src/plugin.test.ts +39 -0
- package/src/plugin.ts +274 -0
- package/src/probe.test.ts +196 -0
- package/src/probe.ts +119 -0
- package/src/resolver.ts +137 -0
- package/src/runtime.ts +14 -0
- package/src/send.test.ts +276 -0
- package/src/send.ts +136 -0
- package/src/status.test.ts +270 -0
- package/src/status.ts +179 -0
- package/src/test-fixtures.ts +30 -0
- package/src/token.test.ts +171 -0
- package/src/token.ts +91 -0
- package/src/twitch-client.test.ts +589 -0
- package/src/twitch-client.ts +277 -0
- package/src/types.ts +143 -0
- package/src/utils/markdown.ts +98 -0
- package/src/utils/twitch.ts +78 -0
- package/test/setup.ts +7 -0
|
@@ -0,0 +1,403 @@
|
|
|
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 { twitchOutbound } from "./outbound.js";
|
|
14
|
+
import {
|
|
15
|
+
BASE_TWITCH_TEST_ACCOUNT,
|
|
16
|
+
installTwitchTestHooks,
|
|
17
|
+
makeTwitchTestConfig,
|
|
18
|
+
} from "./test-fixtures.js";
|
|
19
|
+
|
|
20
|
+
// Mock dependencies
|
|
21
|
+
vi.mock("./config.js", () => ({
|
|
22
|
+
DEFAULT_ACCOUNT_ID: "default",
|
|
23
|
+
getAccountConfig: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock("./send.js", () => ({
|
|
27
|
+
sendMessageTwitchInternal: vi.fn(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock("./utils/markdown.js", () => ({
|
|
31
|
+
chunkTextForTwitch: vi.fn((text) => text.split(/(.{500})/).filter(Boolean)),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
vi.mock("./utils/twitch.js", () => ({
|
|
35
|
+
normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""),
|
|
36
|
+
missingTargetError: (channel: string, hint: string) =>
|
|
37
|
+
new Error(`Missing target for ${channel}. Provide ${hint}`),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
function assertResolvedTarget(
|
|
41
|
+
result: ReturnType<NonNullable<typeof twitchOutbound.resolveTarget>>,
|
|
42
|
+
): string {
|
|
43
|
+
if (!result.ok) {
|
|
44
|
+
throw result.error;
|
|
45
|
+
}
|
|
46
|
+
return result.to;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("outbound", () => {
|
|
50
|
+
const mockAccount = {
|
|
51
|
+
...BASE_TWITCH_TEST_ACCOUNT,
|
|
52
|
+
accessToken: "oauth:test123",
|
|
53
|
+
};
|
|
54
|
+
const resolveTarget = twitchOutbound.resolveTarget!;
|
|
55
|
+
|
|
56
|
+
const mockConfig = makeTwitchTestConfig(mockAccount);
|
|
57
|
+
installTwitchTestHooks();
|
|
58
|
+
|
|
59
|
+
describe("metadata", () => {
|
|
60
|
+
it("should have direct delivery mode", () => {
|
|
61
|
+
expect(twitchOutbound.deliveryMode).toBe("direct");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("should have 500 character text chunk limit", () => {
|
|
65
|
+
expect(twitchOutbound.textChunkLimit).toBe(500);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should have chunker function", () => {
|
|
69
|
+
expect(twitchOutbound.chunker).toBeDefined();
|
|
70
|
+
expect(typeof twitchOutbound.chunker).toBe("function");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("resolveTarget", () => {
|
|
75
|
+
it("should normalize and return target in explicit mode", () => {
|
|
76
|
+
const result = resolveTarget({
|
|
77
|
+
to: "#MyChannel",
|
|
78
|
+
mode: "explicit",
|
|
79
|
+
allowFrom: [],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(result.ok).toBe(true);
|
|
83
|
+
expect(assertResolvedTarget(result)).toBe("mychannel");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("should return target in implicit mode with wildcard allowlist", () => {
|
|
87
|
+
const result = resolveTarget({
|
|
88
|
+
to: "#AnyChannel",
|
|
89
|
+
mode: "implicit",
|
|
90
|
+
allowFrom: ["*"],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(result.ok).toBe(true);
|
|
94
|
+
expect(assertResolvedTarget(result)).toBe("anychannel");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("should return target in implicit mode when in allowlist", () => {
|
|
98
|
+
const result = resolveTarget({
|
|
99
|
+
to: "#allowed",
|
|
100
|
+
mode: "implicit",
|
|
101
|
+
allowFrom: ["#allowed", "#other"],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(result.ok).toBe(true);
|
|
105
|
+
expect(assertResolvedTarget(result)).toBe("allowed");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
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");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("should accept any target when allowlist is empty", () => {
|
|
123
|
+
const result = resolveTarget({
|
|
124
|
+
to: "#anychannel",
|
|
125
|
+
mode: "heartbeat",
|
|
126
|
+
allowFrom: [],
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(result.ok).toBe(true);
|
|
130
|
+
expect(assertResolvedTarget(result)).toBe("anychannel");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
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");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
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");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
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");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
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");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("should filter wildcard from allowlist when checking membership", () => {
|
|
190
|
+
const result = resolveTarget({
|
|
191
|
+
to: "#mychannel",
|
|
192
|
+
mode: "implicit",
|
|
193
|
+
allowFrom: ["*", "#specific"],
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// With wildcard, any target is accepted
|
|
197
|
+
expect(result.ok).toBe(true);
|
|
198
|
+
expect(assertResolvedTarget(result)).toBe("mychannel");
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("sendText", () => {
|
|
203
|
+
it("should send message successfully", async () => {
|
|
204
|
+
const { getAccountConfig } = await import("./config.js");
|
|
205
|
+
const { sendMessageTwitchInternal } = await import("./send.js");
|
|
206
|
+
|
|
207
|
+
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
|
208
|
+
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
|
209
|
+
ok: true,
|
|
210
|
+
messageId: "twitch-msg-123",
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const result = await twitchOutbound.sendText!({
|
|
214
|
+
cfg: mockConfig,
|
|
215
|
+
to: "#testchannel",
|
|
216
|
+
text: "Hello Twitch!",
|
|
217
|
+
accountId: "default",
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(result.channel).toBe("twitch");
|
|
221
|
+
expect(result.messageId).toBe("twitch-msg-123");
|
|
222
|
+
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
|
223
|
+
"testchannel",
|
|
224
|
+
"Hello Twitch!",
|
|
225
|
+
mockConfig,
|
|
226
|
+
"default",
|
|
227
|
+
true,
|
|
228
|
+
console,
|
|
229
|
+
);
|
|
230
|
+
expect(result.timestamp).toBeGreaterThan(0);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("should throw when account not found", async () => {
|
|
234
|
+
const { getAccountConfig } = await import("./config.js");
|
|
235
|
+
|
|
236
|
+
vi.mocked(getAccountConfig).mockReturnValue(null);
|
|
237
|
+
|
|
238
|
+
await expect(
|
|
239
|
+
twitchOutbound.sendText!({
|
|
240
|
+
cfg: mockConfig,
|
|
241
|
+
to: "#testchannel",
|
|
242
|
+
text: "Hello!",
|
|
243
|
+
accountId: "nonexistent",
|
|
244
|
+
}),
|
|
245
|
+
).rejects.toThrow("Twitch account not found: nonexistent");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("should throw when no channel specified", async () => {
|
|
249
|
+
const { getAccountConfig } = await import("./config.js");
|
|
250
|
+
|
|
251
|
+
const accountWithoutChannel = { ...mockAccount, channel: undefined as unknown as string };
|
|
252
|
+
vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannel);
|
|
253
|
+
|
|
254
|
+
await expect(
|
|
255
|
+
twitchOutbound.sendText!({
|
|
256
|
+
cfg: mockConfig,
|
|
257
|
+
to: "",
|
|
258
|
+
text: "Hello!",
|
|
259
|
+
accountId: "default",
|
|
260
|
+
}),
|
|
261
|
+
).rejects.toThrow("No channel specified");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("should use account channel when target not provided", async () => {
|
|
265
|
+
const { getAccountConfig } = await import("./config.js");
|
|
266
|
+
const { sendMessageTwitchInternal } = await import("./send.js");
|
|
267
|
+
|
|
268
|
+
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
|
269
|
+
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
|
270
|
+
ok: true,
|
|
271
|
+
messageId: "msg-456",
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await twitchOutbound.sendText!({
|
|
275
|
+
cfg: mockConfig,
|
|
276
|
+
to: "",
|
|
277
|
+
text: "Hello!",
|
|
278
|
+
accountId: "default",
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
|
282
|
+
"testchannel",
|
|
283
|
+
"Hello!",
|
|
284
|
+
mockConfig,
|
|
285
|
+
"default",
|
|
286
|
+
true,
|
|
287
|
+
console,
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("should handle abort signal", async () => {
|
|
292
|
+
const abortController = new AbortController();
|
|
293
|
+
abortController.abort();
|
|
294
|
+
|
|
295
|
+
await expect(
|
|
296
|
+
twitchOutbound.sendText!({
|
|
297
|
+
cfg: mockConfig,
|
|
298
|
+
to: "#testchannel",
|
|
299
|
+
text: "Hello!",
|
|
300
|
+
accountId: "default",
|
|
301
|
+
signal: abortController.signal,
|
|
302
|
+
} as Parameters<NonNullable<typeof twitchOutbound.sendText>>[0]),
|
|
303
|
+
).rejects.toThrow("Outbound delivery aborted");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("should throw on send failure", async () => {
|
|
307
|
+
const { getAccountConfig } = await import("./config.js");
|
|
308
|
+
const { sendMessageTwitchInternal } = await import("./send.js");
|
|
309
|
+
|
|
310
|
+
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
|
311
|
+
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
|
312
|
+
ok: false,
|
|
313
|
+
messageId: "failed-msg",
|
|
314
|
+
error: "Connection lost",
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
await expect(
|
|
318
|
+
twitchOutbound.sendText!({
|
|
319
|
+
cfg: mockConfig,
|
|
320
|
+
to: "#testchannel",
|
|
321
|
+
text: "Hello!",
|
|
322
|
+
accountId: "default",
|
|
323
|
+
}),
|
|
324
|
+
).rejects.toThrow("Connection lost");
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe("sendMedia", () => {
|
|
329
|
+
it("should combine text and media URL", async () => {
|
|
330
|
+
const { sendMessageTwitchInternal } = await import("./send.js");
|
|
331
|
+
const { getAccountConfig } = await import("./config.js");
|
|
332
|
+
|
|
333
|
+
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
|
334
|
+
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
|
335
|
+
ok: true,
|
|
336
|
+
messageId: "media-msg-123",
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const result = await twitchOutbound.sendMedia!({
|
|
340
|
+
cfg: mockConfig,
|
|
341
|
+
to: "#testchannel",
|
|
342
|
+
text: "Check this:",
|
|
343
|
+
mediaUrl: "https://example.com/image.png",
|
|
344
|
+
accountId: "default",
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
expect(result.channel).toBe("twitch");
|
|
348
|
+
expect(result.messageId).toBe("media-msg-123");
|
|
349
|
+
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
|
350
|
+
expect.anything(),
|
|
351
|
+
"Check this: https://example.com/image.png",
|
|
352
|
+
expect.anything(),
|
|
353
|
+
expect.anything(),
|
|
354
|
+
expect.anything(),
|
|
355
|
+
expect.anything(),
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("should send media URL only when no text", async () => {
|
|
360
|
+
const { sendMessageTwitchInternal } = await import("./send.js");
|
|
361
|
+
const { getAccountConfig } = await import("./config.js");
|
|
362
|
+
|
|
363
|
+
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
|
364
|
+
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
|
365
|
+
ok: true,
|
|
366
|
+
messageId: "media-only-msg",
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
await twitchOutbound.sendMedia!({
|
|
370
|
+
cfg: mockConfig,
|
|
371
|
+
to: "#testchannel",
|
|
372
|
+
text: "",
|
|
373
|
+
mediaUrl: "https://example.com/image.png",
|
|
374
|
+
accountId: "default",
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
|
378
|
+
expect.anything(),
|
|
379
|
+
"https://example.com/image.png",
|
|
380
|
+
expect.anything(),
|
|
381
|
+
expect.anything(),
|
|
382
|
+
expect.anything(),
|
|
383
|
+
expect.anything(),
|
|
384
|
+
);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("should handle abort signal", async () => {
|
|
388
|
+
const abortController = new AbortController();
|
|
389
|
+
abortController.abort();
|
|
390
|
+
|
|
391
|
+
await expect(
|
|
392
|
+
twitchOutbound.sendMedia!({
|
|
393
|
+
cfg: mockConfig,
|
|
394
|
+
to: "#testchannel",
|
|
395
|
+
text: "Check this:",
|
|
396
|
+
mediaUrl: "https://example.com/image.png",
|
|
397
|
+
accountId: "default",
|
|
398
|
+
signal: abortController.signal,
|
|
399
|
+
} as Parameters<NonNullable<typeof twitchOutbound.sendMedia>>[0]),
|
|
400
|
+
).rejects.toThrow("Outbound delivery aborted");
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
});
|
package/src/outbound.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
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 { DEFAULT_ACCOUNT_ID, getAccountConfig } 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 ?? DEFAULT_ACCOUNT_ID;
|
|
117
|
+
const account = getAccountConfig(cfg, resolvedAccountId);
|
|
118
|
+
if (!account) {
|
|
119
|
+
const availableIds = Object.keys(cfg.channels?.twitch?.accounts ?? {});
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Twitch account not found: ${resolvedAccountId}. ` +
|
|
122
|
+
`Available accounts: ${availableIds.join(", ") || "none"}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const channel = to || account.channel;
|
|
127
|
+
if (!channel) {
|
|
128
|
+
throw new Error("No channel specified and no default channel in account config");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const result = await sendMessageTwitchInternal(
|
|
132
|
+
normalizeTwitchChannel(channel),
|
|
133
|
+
text,
|
|
134
|
+
cfg,
|
|
135
|
+
resolvedAccountId,
|
|
136
|
+
true, // stripMarkdown
|
|
137
|
+
console,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
if (!result.ok) {
|
|
141
|
+
throw new Error(result.error ?? "Send failed");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
channel: "twitch",
|
|
146
|
+
messageId: result.messageId,
|
|
147
|
+
timestamp: Date.now(),
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Send media to a Twitch channel.
|
|
153
|
+
*
|
|
154
|
+
* Note: Twitch chat doesn't support direct media uploads.
|
|
155
|
+
* This sends the media URL as text instead.
|
|
156
|
+
*
|
|
157
|
+
* @param params - Send parameters including media URL
|
|
158
|
+
* @returns Delivery result with message ID and status
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* const result = await twitchOutbound.sendMedia({
|
|
162
|
+
* cfg: openclawConfig,
|
|
163
|
+
* to: "#mychannel",
|
|
164
|
+
* text: "Check this out!",
|
|
165
|
+
* mediaUrl: "https://example.com/image.png",
|
|
166
|
+
* accountId: "default",
|
|
167
|
+
* });
|
|
168
|
+
*/
|
|
169
|
+
sendMedia: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
|
|
170
|
+
const { text, mediaUrl } = params;
|
|
171
|
+
const signal = (params as { signal?: AbortSignal }).signal;
|
|
172
|
+
|
|
173
|
+
if (signal?.aborted) {
|
|
174
|
+
throw new Error("Outbound delivery aborted");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const message = mediaUrl ? `${text || ""} ${mediaUrl}`.trim() : text;
|
|
178
|
+
|
|
179
|
+
if (!twitchOutbound.sendText) {
|
|
180
|
+
throw new Error("sendText not implemented");
|
|
181
|
+
}
|
|
182
|
+
return twitchOutbound.sendText({
|
|
183
|
+
...params,
|
|
184
|
+
text: message,
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { twitchPlugin } from "./plugin.js";
|
|
4
|
+
|
|
5
|
+
describe("twitchPlugin.status.buildAccountSnapshot", () => {
|
|
6
|
+
it("uses the resolved account ID for multi-account configs", async () => {
|
|
7
|
+
const secondary = {
|
|
8
|
+
channel: "secondary-channel",
|
|
9
|
+
username: "secondary",
|
|
10
|
+
accessToken: "oauth:secondary-token",
|
|
11
|
+
clientId: "secondary-client",
|
|
12
|
+
enabled: true,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const cfg = {
|
|
16
|
+
channels: {
|
|
17
|
+
twitch: {
|
|
18
|
+
accounts: {
|
|
19
|
+
default: {
|
|
20
|
+
channel: "default-channel",
|
|
21
|
+
username: "default",
|
|
22
|
+
accessToken: "oauth:default-token",
|
|
23
|
+
clientId: "default-client",
|
|
24
|
+
enabled: true,
|
|
25
|
+
},
|
|
26
|
+
secondary,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
} as OpenClawConfig;
|
|
31
|
+
|
|
32
|
+
const snapshot = await twitchPlugin.status?.buildAccountSnapshot?.({
|
|
33
|
+
account: secondary,
|
|
34
|
+
cfg,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(snapshot?.accountId).toBe("secondary");
|
|
38
|
+
});
|
|
39
|
+
});
|