@openclaw/twitch 2026.2.21 → 2026.5.1-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.
- package/README.md +2 -2
- package/api.ts +21 -0
- package/channel-plugin-api.ts +1 -0
- package/index.test.ts +13 -0
- package/index.ts +12 -16
- package/openclaw.plugin.json +6 -0
- package/package.json +29 -7
- package/runtime-api.ts +22 -0
- package/setup-entry.ts +9 -0
- package/setup-plugin-api.ts +3 -0
- package/src/access-control.test.ts +136 -239
- package/src/access-control.ts +11 -4
- package/src/actions.test.ts +74 -0
- package/src/actions.ts +7 -6
- package/src/client-manager-registry.ts +0 -28
- package/src/config-schema.ts +2 -2
- package/src/config.test.ts +147 -1
- package/src/config.ts +76 -15
- package/src/monitor.ts +126 -85
- package/src/outbound.test.ts +140 -75
- package/src/outbound.ts +4 -5
- package/src/plugin.test.ts +39 -1
- package/src/plugin.ts +176 -242
- package/src/probe.test.ts +1 -1
- package/src/probe.ts +16 -5
- package/src/resolver.ts +5 -3
- package/src/runtime.ts +8 -13
- package/src/send.test.ts +92 -59
- package/src/send.ts +18 -15
- package/src/setup-surface.test.ts +511 -0
- package/src/setup-surface.ts +520 -0
- package/src/status.test.ts +120 -153
- package/src/status.ts +1 -1
- package/src/test-fixtures.ts +1 -1
- package/src/token.test.ts +22 -1
- package/src/token.ts +8 -6
- package/src/twitch-client.test.ts +18 -25
- package/src/twitch-client.ts +5 -6
- package/src/types.ts +7 -46
- package/src/utils/twitch.ts +8 -2
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -21
- package/src/onboarding.test.ts +0 -316
- 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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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:
|
|
83
|
+
messageId: params.messageId,
|
|
68
84
|
}),
|
|
69
85
|
} as unknown as ReturnType<typeof getClientManager>);
|
|
70
|
-
vi.mocked(stripMarkdownForTwitch).mockImplementation(
|
|
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 {
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
11
|
-
import { resolveTwitchToken } from "./token.js";
|
|
11
|
+
import { resolveTwitchAccountContext } from "./config.js";
|
|
12
12
|
import { stripMarkdownForTwitch } from "./utils/markdown.js";
|
|
13
|
-
import { generateMessageId,
|
|
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
|
|
55
|
+
accountId?: string,
|
|
56
56
|
stripMarkdown: boolean = true,
|
|
57
57
|
logger: Console = console,
|
|
58
58
|
): Promise<SendMessageResult> {
|
|
59
|
-
const
|
|
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: ${
|
|
69
|
+
error: `Account not found: ${accountId ?? "(default)"}. Available accounts: ${availableAccountIds.join(", ") || "none"}`,
|
|
66
70
|
};
|
|
67
71
|
}
|
|
68
72
|
|
|
69
|
-
|
|
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 ${
|
|
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(
|
|
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: ${
|
|
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
|
-
|
|
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 =
|
|
131
|
+
const errorMsg = formatErrorMessage(error);
|
|
129
132
|
logger.error(`Failed to send message: ${errorMsg}`);
|
|
130
133
|
return {
|
|
131
134
|
ok: false,
|