@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
package/src/token.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twitch access token resolution with environment variable support.
|
|
3
|
+
*
|
|
4
|
+
* Supports reading Twitch OAuth access tokens from config or environment variable.
|
|
5
|
+
* The OPENCLAW_TWITCH_ACCESS_TOKEN env var is only used for the default account.
|
|
6
|
+
*
|
|
7
|
+
* Token resolution priority:
|
|
8
|
+
* 1. Account access token from merged config (accounts.{id} or base-level for default)
|
|
9
|
+
* 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { OpenClawConfig } from "../../../src/config/config.js";
|
|
13
|
+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
|
|
14
|
+
|
|
15
|
+
export type TwitchTokenSource = "env" | "config" | "none";
|
|
16
|
+
|
|
17
|
+
export type TwitchTokenResolution = {
|
|
18
|
+
token: string;
|
|
19
|
+
source: TwitchTokenSource;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Normalize a Twitch OAuth token - ensure it has the oauth: prefix
|
|
24
|
+
*/
|
|
25
|
+
function normalizeTwitchToken(raw?: string | null): string | undefined {
|
|
26
|
+
if (!raw) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
const trimmed = raw.trim();
|
|
30
|
+
if (!trimmed) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
// Twitch tokens should have oauth: prefix
|
|
34
|
+
return trimmed.startsWith("oauth:") ? trimmed : `oauth:${trimmed}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve Twitch access token from config or environment variable.
|
|
39
|
+
*
|
|
40
|
+
* Priority:
|
|
41
|
+
* 1. Account access token (from merged config - base-level for default, or accounts.{accountId})
|
|
42
|
+
* 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only)
|
|
43
|
+
*
|
|
44
|
+
* The getAccountConfig function handles merging base-level config with accounts.default,
|
|
45
|
+
* so this logic works for both simplified and multi-account patterns.
|
|
46
|
+
*
|
|
47
|
+
* @param cfg - OpenClaw config
|
|
48
|
+
* @param opts - Options including accountId and optional envToken override
|
|
49
|
+
* @returns Token resolution with source
|
|
50
|
+
*/
|
|
51
|
+
export function resolveTwitchToken(
|
|
52
|
+
cfg?: OpenClawConfig,
|
|
53
|
+
opts: { accountId?: string | null; envToken?: string | null } = {},
|
|
54
|
+
): TwitchTokenResolution {
|
|
55
|
+
const accountId = normalizeAccountId(opts.accountId);
|
|
56
|
+
|
|
57
|
+
// Get merged account config (handles both simplified and multi-account patterns)
|
|
58
|
+
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
|
+
|
|
64
|
+
// For default account, also check base-level config
|
|
65
|
+
let token: string | undefined;
|
|
66
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
67
|
+
// Base-level config takes precedence
|
|
68
|
+
token = normalizeTwitchToken(
|
|
69
|
+
(typeof twitchCfg?.accessToken === "string" ? twitchCfg.accessToken : undefined) ||
|
|
70
|
+
(accountCfg?.accessToken as string | undefined),
|
|
71
|
+
);
|
|
72
|
+
} else {
|
|
73
|
+
// Non-default accounts only use accounts object
|
|
74
|
+
token = normalizeTwitchToken(accountCfg?.accessToken as string | undefined);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (token) {
|
|
78
|
+
return { token, source: "config" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Environment variable (default account only)
|
|
82
|
+
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
|
83
|
+
const envToken = allowEnv
|
|
84
|
+
? normalizeTwitchToken(opts.envToken ?? process.env.OPENCLAW_TWITCH_ACCESS_TOKEN)
|
|
85
|
+
: undefined;
|
|
86
|
+
if (envToken) {
|
|
87
|
+
return { token: envToken, source: "env" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { token: "", source: "none" };
|
|
91
|
+
}
|
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for TwitchClientManager class
|
|
3
|
+
*
|
|
4
|
+
* Tests cover:
|
|
5
|
+
* - Client connection and reconnection
|
|
6
|
+
* - Message handling (chat)
|
|
7
|
+
* - Message sending with rate limiting
|
|
8
|
+
* - Disconnection scenarios
|
|
9
|
+
* - Error handling and edge cases
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
13
|
+
import { TwitchClientManager } from "./twitch-client.js";
|
|
14
|
+
import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
|
15
|
+
|
|
16
|
+
// Mock @twurple dependencies
|
|
17
|
+
const mockConnect = vi.fn().mockResolvedValue(undefined);
|
|
18
|
+
const mockJoin = vi.fn().mockResolvedValue(undefined);
|
|
19
|
+
const mockSay = vi.fn().mockResolvedValue({ messageId: "test-msg-123" });
|
|
20
|
+
const mockQuit = vi.fn();
|
|
21
|
+
const mockUnbind = vi.fn();
|
|
22
|
+
|
|
23
|
+
// Event handler storage for testing
|
|
24
|
+
// oxlint-disable-next-line typescript/no-explicit-any
|
|
25
|
+
const messageHandlers: Array<(channel: string, user: string, message: string, msg: any) => void> =
|
|
26
|
+
[];
|
|
27
|
+
|
|
28
|
+
// Mock functions that track handlers and return unbind objects
|
|
29
|
+
// oxlint-disable-next-line typescript/no-explicit-any
|
|
30
|
+
const mockOnMessage = vi.fn((handler: any) => {
|
|
31
|
+
messageHandlers.push(handler);
|
|
32
|
+
return { unbind: mockUnbind };
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const mockAddUserForToken = vi.fn().mockResolvedValue("123456");
|
|
36
|
+
const mockOnRefresh = vi.fn();
|
|
37
|
+
const mockOnRefreshFailure = vi.fn();
|
|
38
|
+
|
|
39
|
+
vi.mock("@twurple/chat", () => ({
|
|
40
|
+
ChatClient: class {
|
|
41
|
+
onMessage = mockOnMessage;
|
|
42
|
+
connect = mockConnect;
|
|
43
|
+
join = mockJoin;
|
|
44
|
+
say = mockSay;
|
|
45
|
+
quit = mockQuit;
|
|
46
|
+
},
|
|
47
|
+
LogLevel: {
|
|
48
|
+
CRITICAL: "CRITICAL",
|
|
49
|
+
ERROR: "ERROR",
|
|
50
|
+
WARNING: "WARNING",
|
|
51
|
+
INFO: "INFO",
|
|
52
|
+
DEBUG: "DEBUG",
|
|
53
|
+
TRACE: "TRACE",
|
|
54
|
+
},
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
const mockAuthProvider = {
|
|
58
|
+
constructor: vi.fn(),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
vi.mock("@twurple/auth", () => ({
|
|
62
|
+
StaticAuthProvider: class {
|
|
63
|
+
constructor(...args: unknown[]) {
|
|
64
|
+
mockAuthProvider.constructor(...args);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
RefreshingAuthProvider: class {
|
|
68
|
+
addUserForToken = mockAddUserForToken;
|
|
69
|
+
onRefresh = mockOnRefresh;
|
|
70
|
+
onRefreshFailure = mockOnRefreshFailure;
|
|
71
|
+
},
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
// Mock token resolution - must be after @twurple/auth mock
|
|
75
|
+
vi.mock("./token.js", () => ({
|
|
76
|
+
resolveTwitchToken: vi.fn(() => ({
|
|
77
|
+
token: "oauth:mock-token-from-tests",
|
|
78
|
+
source: "config" as const,
|
|
79
|
+
})),
|
|
80
|
+
DEFAULT_ACCOUNT_ID: "default",
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
describe("TwitchClientManager", () => {
|
|
84
|
+
let manager: TwitchClientManager;
|
|
85
|
+
let mockLogger: ChannelLogSink;
|
|
86
|
+
|
|
87
|
+
const testAccount: TwitchAccountConfig = {
|
|
88
|
+
username: "testbot",
|
|
89
|
+
accessToken: "test123456",
|
|
90
|
+
clientId: "test-client-id",
|
|
91
|
+
channel: "testchannel",
|
|
92
|
+
enabled: true,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const testAccount2: TwitchAccountConfig = {
|
|
96
|
+
username: "testbot2",
|
|
97
|
+
accessToken: "test789",
|
|
98
|
+
clientId: "test-client-id-2",
|
|
99
|
+
channel: "testchannel2",
|
|
100
|
+
enabled: true,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
beforeEach(async () => {
|
|
104
|
+
// Clear all mocks first
|
|
105
|
+
vi.clearAllMocks();
|
|
106
|
+
|
|
107
|
+
// Clear handler arrays
|
|
108
|
+
messageHandlers.length = 0;
|
|
109
|
+
|
|
110
|
+
// Re-set up the default token mock implementation after clearing
|
|
111
|
+
const { resolveTwitchToken } = await import("./token.js");
|
|
112
|
+
vi.mocked(resolveTwitchToken).mockReturnValue({
|
|
113
|
+
token: "oauth:mock-token-from-tests",
|
|
114
|
+
source: "config" as const,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Create mock logger
|
|
118
|
+
mockLogger = {
|
|
119
|
+
info: vi.fn(),
|
|
120
|
+
warn: vi.fn(),
|
|
121
|
+
error: vi.fn(),
|
|
122
|
+
debug: vi.fn(),
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Create manager instance
|
|
126
|
+
manager = new TwitchClientManager(mockLogger);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
afterEach(() => {
|
|
130
|
+
// Clean up manager to avoid side effects
|
|
131
|
+
manager._clearForTest();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("getClient", () => {
|
|
135
|
+
it("should create a new client connection", async () => {
|
|
136
|
+
const _client = await manager.getClient(testAccount);
|
|
137
|
+
|
|
138
|
+
// New implementation: connect is called, channels are passed to constructor
|
|
139
|
+
expect(mockConnect).toHaveBeenCalledTimes(1);
|
|
140
|
+
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
141
|
+
expect.stringContaining("Connected to Twitch as testbot"),
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("should use account username as default channel when channel not specified", async () => {
|
|
146
|
+
const accountWithoutChannel: TwitchAccountConfig = {
|
|
147
|
+
...testAccount,
|
|
148
|
+
channel: "",
|
|
149
|
+
} as unknown as TwitchAccountConfig;
|
|
150
|
+
|
|
151
|
+
await manager.getClient(accountWithoutChannel);
|
|
152
|
+
|
|
153
|
+
// New implementation: channel (testbot) is passed to constructor, not via join()
|
|
154
|
+
expect(mockConnect).toHaveBeenCalledTimes(1);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("should reuse existing client for same account", async () => {
|
|
158
|
+
const client1 = await manager.getClient(testAccount);
|
|
159
|
+
const client2 = await manager.getClient(testAccount);
|
|
160
|
+
|
|
161
|
+
expect(client1).toBe(client2);
|
|
162
|
+
expect(mockConnect).toHaveBeenCalledTimes(1);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("should create separate clients for different accounts", async () => {
|
|
166
|
+
await manager.getClient(testAccount);
|
|
167
|
+
await manager.getClient(testAccount2);
|
|
168
|
+
|
|
169
|
+
expect(mockConnect).toHaveBeenCalledTimes(2);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("should normalize token by removing oauth: prefix", async () => {
|
|
173
|
+
const accountWithPrefix: TwitchAccountConfig = {
|
|
174
|
+
...testAccount,
|
|
175
|
+
accessToken: "oauth:actualtoken123",
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Override the mock to return a specific token for this test
|
|
179
|
+
const { resolveTwitchToken } = await import("./token.js");
|
|
180
|
+
vi.mocked(resolveTwitchToken).mockReturnValue({
|
|
181
|
+
token: "oauth:actualtoken123",
|
|
182
|
+
source: "config" as const,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await manager.getClient(accountWithPrefix);
|
|
186
|
+
|
|
187
|
+
expect(mockAuthProvider.constructor).toHaveBeenCalledWith("test-client-id", "actualtoken123");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("should use token directly when no oauth: prefix", async () => {
|
|
191
|
+
// Override the mock to return a token without oauth: prefix
|
|
192
|
+
const { resolveTwitchToken } = await import("./token.js");
|
|
193
|
+
vi.mocked(resolveTwitchToken).mockReturnValue({
|
|
194
|
+
token: "oauth:mock-token-from-tests",
|
|
195
|
+
source: "config" as const,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await manager.getClient(testAccount);
|
|
199
|
+
|
|
200
|
+
// Implementation strips oauth: prefix from all tokens
|
|
201
|
+
expect(mockAuthProvider.constructor).toHaveBeenCalledWith(
|
|
202
|
+
"test-client-id",
|
|
203
|
+
"mock-token-from-tests",
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("should throw error when clientId is missing", async () => {
|
|
208
|
+
const accountWithoutClientId: TwitchAccountConfig = {
|
|
209
|
+
...testAccount,
|
|
210
|
+
clientId: "" as unknown as string,
|
|
211
|
+
} as unknown as TwitchAccountConfig;
|
|
212
|
+
|
|
213
|
+
await expect(manager.getClient(accountWithoutClientId)).rejects.toThrow(
|
|
214
|
+
"Missing Twitch client ID",
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
218
|
+
expect.stringContaining("Missing Twitch client ID"),
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("should throw error when token is missing", async () => {
|
|
223
|
+
// Override the mock to return empty token
|
|
224
|
+
const { resolveTwitchToken } = await import("./token.js");
|
|
225
|
+
vi.mocked(resolveTwitchToken).mockReturnValue({
|
|
226
|
+
token: "",
|
|
227
|
+
source: "none" as const,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await expect(manager.getClient(testAccount)).rejects.toThrow("Missing Twitch token");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("should set up message handlers on client connection", async () => {
|
|
234
|
+
await manager.getClient(testAccount);
|
|
235
|
+
|
|
236
|
+
expect(mockOnMessage).toHaveBeenCalled();
|
|
237
|
+
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("Set up handlers for"));
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("should create separate clients for same account with different channels", async () => {
|
|
241
|
+
const account1: TwitchAccountConfig = {
|
|
242
|
+
...testAccount,
|
|
243
|
+
channel: "channel1",
|
|
244
|
+
};
|
|
245
|
+
const account2: TwitchAccountConfig = {
|
|
246
|
+
...testAccount,
|
|
247
|
+
channel: "channel2",
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
await manager.getClient(account1);
|
|
251
|
+
await manager.getClient(account2);
|
|
252
|
+
|
|
253
|
+
expect(mockConnect).toHaveBeenCalledTimes(2);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("onMessage", () => {
|
|
258
|
+
it("should register message handler for account", () => {
|
|
259
|
+
const handler = vi.fn();
|
|
260
|
+
manager.onMessage(testAccount, handler);
|
|
261
|
+
|
|
262
|
+
expect(handler).not.toHaveBeenCalled();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("should replace existing handler for same account", () => {
|
|
266
|
+
const handler1 = vi.fn();
|
|
267
|
+
const handler2 = vi.fn();
|
|
268
|
+
|
|
269
|
+
manager.onMessage(testAccount, handler1);
|
|
270
|
+
manager.onMessage(testAccount, handler2);
|
|
271
|
+
|
|
272
|
+
// Check the stored handler is handler2
|
|
273
|
+
const key = manager.getAccountKey(testAccount);
|
|
274
|
+
// oxlint-disable-next-line typescript/no-explicit-any
|
|
275
|
+
expect((manager as any).messageHandlers.get(key)).toBe(handler2);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("disconnect", () => {
|
|
280
|
+
it("should disconnect a connected client", async () => {
|
|
281
|
+
await manager.getClient(testAccount);
|
|
282
|
+
await manager.disconnect(testAccount);
|
|
283
|
+
|
|
284
|
+
expect(mockQuit).toHaveBeenCalledTimes(1);
|
|
285
|
+
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("Disconnected"));
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("should clear client and message handler", async () => {
|
|
289
|
+
const handler = vi.fn();
|
|
290
|
+
await manager.getClient(testAccount);
|
|
291
|
+
manager.onMessage(testAccount, handler);
|
|
292
|
+
|
|
293
|
+
await manager.disconnect(testAccount);
|
|
294
|
+
|
|
295
|
+
const key = manager.getAccountKey(testAccount);
|
|
296
|
+
// oxlint-disable-next-line typescript/no-explicit-any
|
|
297
|
+
expect((manager as any).clients.has(key)).toBe(false);
|
|
298
|
+
// oxlint-disable-next-line typescript/no-explicit-any
|
|
299
|
+
expect((manager as any).messageHandlers.has(key)).toBe(false);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("should handle disconnecting non-existent client gracefully", async () => {
|
|
303
|
+
// disconnect doesn't throw, just does nothing
|
|
304
|
+
await manager.disconnect(testAccount);
|
|
305
|
+
expect(mockQuit).not.toHaveBeenCalled();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("should only disconnect specified account when multiple accounts exist", async () => {
|
|
309
|
+
await manager.getClient(testAccount);
|
|
310
|
+
await manager.getClient(testAccount2);
|
|
311
|
+
|
|
312
|
+
await manager.disconnect(testAccount);
|
|
313
|
+
|
|
314
|
+
expect(mockQuit).toHaveBeenCalledTimes(1);
|
|
315
|
+
|
|
316
|
+
const key2 = manager.getAccountKey(testAccount2);
|
|
317
|
+
// oxlint-disable-next-line typescript/no-explicit-any
|
|
318
|
+
expect((manager as any).clients.has(key2)).toBe(true);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
describe("disconnectAll", () => {
|
|
323
|
+
it("should disconnect all connected clients", async () => {
|
|
324
|
+
await manager.getClient(testAccount);
|
|
325
|
+
await manager.getClient(testAccount2);
|
|
326
|
+
|
|
327
|
+
await manager.disconnectAll();
|
|
328
|
+
|
|
329
|
+
expect(mockQuit).toHaveBeenCalledTimes(2);
|
|
330
|
+
// oxlint-disable-next-line typescript/no-explicit-any
|
|
331
|
+
expect((manager as any).clients.size).toBe(0);
|
|
332
|
+
// oxlint-disable-next-line typescript/no-explicit-any
|
|
333
|
+
expect((manager as any).messageHandlers.size).toBe(0);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("should handle empty client list gracefully", async () => {
|
|
337
|
+
// disconnectAll doesn't throw, just does nothing
|
|
338
|
+
await manager.disconnectAll();
|
|
339
|
+
expect(mockQuit).not.toHaveBeenCalled();
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe("sendMessage", () => {
|
|
344
|
+
beforeEach(async () => {
|
|
345
|
+
await manager.getClient(testAccount);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("should send message successfully", async () => {
|
|
349
|
+
const result = await manager.sendMessage(testAccount, "testchannel", "Hello, world!");
|
|
350
|
+
|
|
351
|
+
expect(result.ok).toBe(true);
|
|
352
|
+
expect(result.messageId).toBeDefined();
|
|
353
|
+
expect(mockSay).toHaveBeenCalledWith("testchannel", "Hello, world!");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("should generate unique message ID for each message", async () => {
|
|
357
|
+
const result1 = await manager.sendMessage(testAccount, "testchannel", "First message");
|
|
358
|
+
const result2 = await manager.sendMessage(testAccount, "testchannel", "Second message");
|
|
359
|
+
|
|
360
|
+
expect(result1.messageId).not.toBe(result2.messageId);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("should handle sending to account's default channel", async () => {
|
|
364
|
+
const result = await manager.sendMessage(
|
|
365
|
+
testAccount,
|
|
366
|
+
testAccount.channel || testAccount.username,
|
|
367
|
+
"Test message",
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
// Should use the account's channel or username
|
|
371
|
+
expect(result.ok).toBe(true);
|
|
372
|
+
expect(mockSay).toHaveBeenCalled();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("should return error on send failure", async () => {
|
|
376
|
+
mockSay.mockRejectedValueOnce(new Error("Rate limited"));
|
|
377
|
+
|
|
378
|
+
const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
|
|
379
|
+
|
|
380
|
+
expect(result.ok).toBe(false);
|
|
381
|
+
expect(result.error).toBe("Rate limited");
|
|
382
|
+
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
383
|
+
expect.stringContaining("Failed to send message"),
|
|
384
|
+
);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("should handle unknown error types", async () => {
|
|
388
|
+
mockSay.mockRejectedValueOnce("String error");
|
|
389
|
+
|
|
390
|
+
const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
|
|
391
|
+
|
|
392
|
+
expect(result.ok).toBe(false);
|
|
393
|
+
expect(result.error).toBe("String error");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("should create client if not already connected", async () => {
|
|
397
|
+
// Clear the existing client
|
|
398
|
+
// oxlint-disable-next-line typescript/no-explicit-any
|
|
399
|
+
(manager as any).clients.clear();
|
|
400
|
+
|
|
401
|
+
// Reset connect call count for this specific test
|
|
402
|
+
const connectCallCountBefore = mockConnect.mock.calls.length;
|
|
403
|
+
|
|
404
|
+
const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
|
|
405
|
+
|
|
406
|
+
expect(result.ok).toBe(true);
|
|
407
|
+
expect(mockConnect.mock.calls.length).toBeGreaterThan(connectCallCountBefore);
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
describe("message handling integration", () => {
|
|
412
|
+
let capturedMessage: TwitchChatMessage | null = null;
|
|
413
|
+
|
|
414
|
+
beforeEach(() => {
|
|
415
|
+
capturedMessage = null;
|
|
416
|
+
|
|
417
|
+
// Set up message handler before connecting
|
|
418
|
+
manager.onMessage(testAccount, (message) => {
|
|
419
|
+
capturedMessage = message;
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("should handle incoming chat messages", async () => {
|
|
424
|
+
await manager.getClient(testAccount);
|
|
425
|
+
|
|
426
|
+
// Get the onMessage callback
|
|
427
|
+
const onMessageCallback = messageHandlers[0];
|
|
428
|
+
if (!onMessageCallback) {
|
|
429
|
+
throw new Error("onMessageCallback not found");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Simulate Twitch message
|
|
433
|
+
onMessageCallback("#testchannel", "testuser", "Hello bot!", {
|
|
434
|
+
userInfo: {
|
|
435
|
+
userName: "testuser",
|
|
436
|
+
displayName: "TestUser",
|
|
437
|
+
userId: "12345",
|
|
438
|
+
isMod: false,
|
|
439
|
+
isBroadcaster: false,
|
|
440
|
+
isVip: false,
|
|
441
|
+
isSubscriber: false,
|
|
442
|
+
},
|
|
443
|
+
id: "msg123",
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
expect(capturedMessage).not.toBeNull();
|
|
447
|
+
expect(capturedMessage?.username).toBe("testuser");
|
|
448
|
+
expect(capturedMessage?.displayName).toBe("TestUser");
|
|
449
|
+
expect(capturedMessage?.userId).toBe("12345");
|
|
450
|
+
expect(capturedMessage?.message).toBe("Hello bot!");
|
|
451
|
+
expect(capturedMessage?.channel).toBe("testchannel");
|
|
452
|
+
expect(capturedMessage?.chatType).toBe("group");
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("should normalize channel names without # prefix", async () => {
|
|
456
|
+
await manager.getClient(testAccount);
|
|
457
|
+
|
|
458
|
+
const onMessageCallback = messageHandlers[0];
|
|
459
|
+
|
|
460
|
+
onMessageCallback("testchannel", "testuser", "Test", {
|
|
461
|
+
userInfo: {
|
|
462
|
+
userName: "testuser",
|
|
463
|
+
displayName: "TestUser",
|
|
464
|
+
userId: "123",
|
|
465
|
+
isMod: false,
|
|
466
|
+
isBroadcaster: false,
|
|
467
|
+
isVip: false,
|
|
468
|
+
isSubscriber: false,
|
|
469
|
+
},
|
|
470
|
+
id: "msg1",
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
expect(capturedMessage?.channel).toBe("testchannel");
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("should include user role flags in message", async () => {
|
|
477
|
+
await manager.getClient(testAccount);
|
|
478
|
+
|
|
479
|
+
const onMessageCallback = messageHandlers[0];
|
|
480
|
+
|
|
481
|
+
onMessageCallback("#testchannel", "moduser", "Test", {
|
|
482
|
+
userInfo: {
|
|
483
|
+
userName: "moduser",
|
|
484
|
+
displayName: "ModUser",
|
|
485
|
+
userId: "456",
|
|
486
|
+
isMod: true,
|
|
487
|
+
isBroadcaster: false,
|
|
488
|
+
isVip: true,
|
|
489
|
+
isSubscriber: true,
|
|
490
|
+
},
|
|
491
|
+
id: "msg2",
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
expect(capturedMessage?.isMod).toBe(true);
|
|
495
|
+
expect(capturedMessage?.isVip).toBe(true);
|
|
496
|
+
expect(capturedMessage?.isSub).toBe(true);
|
|
497
|
+
expect(capturedMessage?.isOwner).toBe(false);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("should handle broadcaster messages", async () => {
|
|
501
|
+
await manager.getClient(testAccount);
|
|
502
|
+
|
|
503
|
+
const onMessageCallback = messageHandlers[0];
|
|
504
|
+
|
|
505
|
+
onMessageCallback("#testchannel", "broadcaster", "Test", {
|
|
506
|
+
userInfo: {
|
|
507
|
+
userName: "broadcaster",
|
|
508
|
+
displayName: "Broadcaster",
|
|
509
|
+
userId: "789",
|
|
510
|
+
isMod: false,
|
|
511
|
+
isBroadcaster: true,
|
|
512
|
+
isVip: false,
|
|
513
|
+
isSubscriber: false,
|
|
514
|
+
},
|
|
515
|
+
id: "msg3",
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
expect(capturedMessage?.isOwner).toBe(true);
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
describe("edge cases", () => {
|
|
523
|
+
it("should handle multiple message handlers for different accounts", async () => {
|
|
524
|
+
const messages1: TwitchChatMessage[] = [];
|
|
525
|
+
const messages2: TwitchChatMessage[] = [];
|
|
526
|
+
|
|
527
|
+
manager.onMessage(testAccount, (msg) => messages1.push(msg));
|
|
528
|
+
manager.onMessage(testAccount2, (msg) => messages2.push(msg));
|
|
529
|
+
|
|
530
|
+
await manager.getClient(testAccount);
|
|
531
|
+
await manager.getClient(testAccount2);
|
|
532
|
+
|
|
533
|
+
// Simulate message for first account
|
|
534
|
+
const onMessage1 = messageHandlers[0];
|
|
535
|
+
if (!onMessage1) {
|
|
536
|
+
throw new Error("onMessage1 not found");
|
|
537
|
+
}
|
|
538
|
+
onMessage1("#testchannel", "user1", "msg1", {
|
|
539
|
+
userInfo: {
|
|
540
|
+
userName: "user1",
|
|
541
|
+
displayName: "User1",
|
|
542
|
+
userId: "1",
|
|
543
|
+
isMod: false,
|
|
544
|
+
isBroadcaster: false,
|
|
545
|
+
isVip: false,
|
|
546
|
+
isSubscriber: false,
|
|
547
|
+
},
|
|
548
|
+
id: "1",
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// Simulate message for second account
|
|
552
|
+
const onMessage2 = messageHandlers[1];
|
|
553
|
+
if (!onMessage2) {
|
|
554
|
+
throw new Error("onMessage2 not found");
|
|
555
|
+
}
|
|
556
|
+
onMessage2("#testchannel2", "user2", "msg2", {
|
|
557
|
+
userInfo: {
|
|
558
|
+
userName: "user2",
|
|
559
|
+
displayName: "User2",
|
|
560
|
+
userId: "2",
|
|
561
|
+
isMod: false,
|
|
562
|
+
isBroadcaster: false,
|
|
563
|
+
isVip: false,
|
|
564
|
+
isSubscriber: false,
|
|
565
|
+
},
|
|
566
|
+
id: "2",
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
expect(messages1).toHaveLength(1);
|
|
570
|
+
expect(messages2).toHaveLength(1);
|
|
571
|
+
expect(messages1[0]?.message).toBe("msg1");
|
|
572
|
+
expect(messages2[0]?.message).toBe("msg2");
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("should handle rapid client creation requests", async () => {
|
|
576
|
+
const promises = [
|
|
577
|
+
manager.getClient(testAccount),
|
|
578
|
+
manager.getClient(testAccount),
|
|
579
|
+
manager.getClient(testAccount),
|
|
580
|
+
];
|
|
581
|
+
|
|
582
|
+
await Promise.all(promises);
|
|
583
|
+
|
|
584
|
+
// Note: The implementation doesn't handle concurrent getClient calls,
|
|
585
|
+
// so multiple connections may be created. This is expected behavior.
|
|
586
|
+
expect(mockConnect).toHaveBeenCalled();
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
});
|