@openclaw/twitch 2026.5.2-beta.2 → 2026.5.3-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 (54) hide show
  1. package/dist/api.js +3 -0
  2. package/dist/channel-plugin-api.js +2 -0
  3. package/dist/index.js +18 -0
  4. package/dist/markdown-MRdI1sR7.js +306 -0
  5. package/dist/monitor-DS0YTAPB.js +333 -0
  6. package/dist/plugin-BQX9GiIn.js +878 -0
  7. package/dist/runtime-QZ5I3GlI.js +8 -0
  8. package/dist/runtime-api.js +1 -0
  9. package/dist/setup-entry.js +11 -0
  10. package/dist/setup-plugin-api.js +2 -0
  11. package/dist/setup-surface-yArVgckI.js +400 -0
  12. package/dist/twitch-CklAMZL5.js +131 -0
  13. package/package.json +20 -3
  14. package/api.ts +0 -21
  15. package/channel-plugin-api.ts +0 -1
  16. package/index.test.ts +0 -13
  17. package/index.ts +0 -16
  18. package/runtime-api.ts +0 -22
  19. package/setup-entry.ts +0 -9
  20. package/setup-plugin-api.ts +0 -3
  21. package/src/access-control.test.ts +0 -388
  22. package/src/access-control.ts +0 -173
  23. package/src/actions.test.ts +0 -74
  24. package/src/actions.ts +0 -175
  25. package/src/client-manager-registry.ts +0 -87
  26. package/src/config-schema.test.ts +0 -46
  27. package/src/config-schema.ts +0 -88
  28. package/src/config.test.ts +0 -233
  29. package/src/config.ts +0 -177
  30. package/src/monitor.ts +0 -314
  31. package/src/outbound.test.ts +0 -468
  32. package/src/outbound.ts +0 -186
  33. package/src/plugin.test.ts +0 -77
  34. package/src/plugin.ts +0 -208
  35. package/src/probe.test.ts +0 -196
  36. package/src/probe.ts +0 -130
  37. package/src/resolver.ts +0 -139
  38. package/src/runtime.ts +0 -9
  39. package/src/send.test.ts +0 -309
  40. package/src/send.ts +0 -139
  41. package/src/setup-surface.test.ts +0 -511
  42. package/src/setup-surface.ts +0 -520
  43. package/src/status.test.ts +0 -237
  44. package/src/status.ts +0 -179
  45. package/src/test-fixtures.ts +0 -30
  46. package/src/token.test.ts +0 -192
  47. package/src/token.ts +0 -93
  48. package/src/twitch-client.test.ts +0 -582
  49. package/src/twitch-client.ts +0 -276
  50. package/src/types.ts +0 -104
  51. package/src/utils/markdown.ts +0 -98
  52. package/src/utils/twitch.ts +0 -84
  53. package/test/setup.ts +0 -7
  54. package/tsconfig.json +0 -16
@@ -1,77 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import type { OpenClawConfig } from "../api.js";
3
- import { twitchPlugin } from "./plugin.js";
4
-
5
- describe("twitchPlugin pairing", () => {
6
- it("normalizes trimmed twitch user prefixes in allow entries", () => {
7
- expect(twitchPlugin.pairing?.normalizeAllowEntry?.(" twitch:user:123456 ")).toBe("123456");
8
- expect(twitchPlugin.pairing?.normalizeAllowEntry?.(" user789012 ")).toBe("789012");
9
- });
10
- });
11
-
12
- describe("twitchPlugin.status.buildAccountSnapshot", () => {
13
- it("uses the resolved account ID for multi-account configs", async () => {
14
- const secondary = {
15
- channel: "secondary-channel",
16
- username: "secondary",
17
- accessToken: "oauth:secondary-token",
18
- clientId: "secondary-client",
19
- enabled: true,
20
- };
21
-
22
- const cfg = {
23
- channels: {
24
- twitch: {
25
- accounts: {
26
- default: {
27
- channel: "default-channel",
28
- username: "default",
29
- accessToken: "oauth:default-token",
30
- clientId: "default-client",
31
- enabled: true,
32
- },
33
- secondary,
34
- },
35
- },
36
- },
37
- } as OpenClawConfig;
38
-
39
- const snapshot = await twitchPlugin.status?.buildAccountSnapshot?.({
40
- account: secondary,
41
- cfg,
42
- });
43
-
44
- expect(snapshot?.accountId).toBe("secondary");
45
- });
46
- });
47
-
48
- describe("twitchPlugin.config", () => {
49
- it("uses configured defaultAccount for omitted-account plugin resolution", () => {
50
- const cfg = {
51
- channels: {
52
- twitch: {
53
- defaultAccount: "secondary",
54
- accounts: {
55
- default: {
56
- channel: "default-channel",
57
- username: "default",
58
- accessToken: "oauth:default-token",
59
- clientId: "default-client",
60
- enabled: true,
61
- },
62
- secondary: {
63
- channel: "secondary-channel",
64
- username: "secondary",
65
- accessToken: "oauth:secondary-token",
66
- clientId: "secondary-client",
67
- enabled: true,
68
- },
69
- },
70
- },
71
- },
72
- } as OpenClawConfig;
73
-
74
- expect(twitchPlugin.config.defaultAccountId?.(cfg)).toBe("secondary");
75
- expect(twitchPlugin.config.resolveAccount(cfg).accountId).toBe("secondary");
76
- });
77
- });
package/src/plugin.ts DELETED
@@ -1,208 +0,0 @@
1
- /**
2
- * Twitch channel plugin for OpenClaw.
3
- *
4
- * Main plugin export combining all adapters (outbound, actions, status, gateway).
5
- * This is the primary entry point for the Twitch channel integration.
6
- */
7
-
8
- import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
9
- import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
10
- import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
11
- import {
12
- createLoggedPairingApprovalNotifier,
13
- createPairingPrefixStripper,
14
- } from "openclaw/plugin-sdk/channel-pairing";
15
- import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
16
- import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
17
- import {
18
- createComputedAccountStatusAdapter,
19
- createDefaultChannelRuntimeState,
20
- } from "openclaw/plugin-sdk/status-helpers";
21
- import { twitchMessageActions } from "./actions.js";
22
- import { removeClientManager } from "./client-manager-registry.js";
23
- import { TwitchConfigSchema } from "./config-schema.js";
24
- import {
25
- DEFAULT_ACCOUNT_ID,
26
- getAccountConfig,
27
- listAccountIds,
28
- resolveDefaultTwitchAccountId,
29
- resolveTwitchAccountContext,
30
- resolveTwitchSnapshotAccountId,
31
- } from "./config.js";
32
- import { twitchOutbound } from "./outbound.js";
33
- import { probeTwitch } from "./probe.js";
34
- import { resolveTwitchTargets } from "./resolver.js";
35
- import { twitchSetupAdapter, twitchSetupWizard } from "./setup-surface.js";
36
- import { collectTwitchStatusIssues } from "./status.js";
37
- import type {
38
- ChannelLogSink,
39
- ChannelPlugin,
40
- ChannelResolveKind,
41
- ChannelResolveResult,
42
- TwitchAccountConfig,
43
- } from "./types.js";
44
- import { isAccountConfigured } from "./utils/twitch.js";
45
-
46
- type ResolvedTwitchAccount = TwitchAccountConfig & { accountId?: string | null };
47
-
48
- /**
49
- * Twitch channel plugin.
50
- *
51
- * Implements the ChannelPlugin interface to provide Twitch chat integration
52
- * for OpenClaw. Supports message sending, receiving, access control, and
53
- * status monitoring.
54
- */
55
- export const twitchPlugin: ChannelPlugin<ResolvedTwitchAccount> =
56
- createChatChannelPlugin<ResolvedTwitchAccount>({
57
- pairing: {
58
- idLabel: "twitchUserId",
59
- normalizeAllowEntry: createPairingPrefixStripper(/^(twitch:)?user:?/i),
60
- notifyApproval: createLoggedPairingApprovalNotifier(
61
- ({ id }) => `Pairing approved for user ${id} (notification sent via chat if possible)`,
62
- console.warn,
63
- ),
64
- },
65
- outbound: twitchOutbound,
66
- base: {
67
- id: "twitch",
68
- meta: {
69
- id: "twitch",
70
- label: "Twitch",
71
- selectionLabel: "Twitch (Chat)",
72
- docsPath: "/channels/twitch",
73
- blurb: "Twitch chat integration",
74
- aliases: ["twitch-chat"],
75
- },
76
- setup: twitchSetupAdapter,
77
- setupWizard: twitchSetupWizard,
78
- capabilities: {
79
- chatTypes: ["group"],
80
- },
81
- configSchema: buildChannelConfigSchema(TwitchConfigSchema),
82
- config: {
83
- listAccountIds: (cfg: OpenClawConfig): string[] => listAccountIds(cfg),
84
- resolveAccount: (cfg: OpenClawConfig, accountId?: string | null): ResolvedTwitchAccount => {
85
- const resolvedAccountId = accountId ?? resolveDefaultTwitchAccountId(cfg);
86
- const account = getAccountConfig(cfg, resolvedAccountId);
87
- if (!account) {
88
- return {
89
- accountId: resolvedAccountId,
90
- channel: "",
91
- username: "",
92
- accessToken: "",
93
- clientId: "",
94
- enabled: false,
95
- };
96
- }
97
- return {
98
- accountId: resolvedAccountId,
99
- ...account,
100
- };
101
- },
102
- defaultAccountId: (cfg: OpenClawConfig): string => resolveDefaultTwitchAccountId(cfg),
103
- isConfigured: (_account: unknown, cfg: OpenClawConfig): boolean =>
104
- resolveTwitchAccountContext(cfg).configured,
105
- isEnabled: (account: ResolvedTwitchAccount | undefined): boolean =>
106
- account?.enabled !== false,
107
- describeAccount: (account: TwitchAccountConfig | undefined) =>
108
- account
109
- ? describeAccountSnapshot({
110
- account,
111
- configured: isAccountConfigured(account, account.accessToken),
112
- })
113
- : {
114
- accountId: DEFAULT_ACCOUNT_ID,
115
- enabled: false,
116
- configured: false,
117
- },
118
- },
119
- actions: twitchMessageActions,
120
- resolver: {
121
- resolveTargets: async ({
122
- cfg,
123
- accountId,
124
- inputs,
125
- kind,
126
- runtime,
127
- }: {
128
- cfg: OpenClawConfig;
129
- accountId?: string | null;
130
- inputs: string[];
131
- kind: ChannelResolveKind;
132
- runtime: import("openclaw/plugin-sdk/runtime-env").RuntimeEnv;
133
- }): Promise<ChannelResolveResult[]> => {
134
- const account = getAccountConfig(cfg, accountId ?? resolveDefaultTwitchAccountId(cfg));
135
- if (!account) {
136
- return inputs.map((input) => ({
137
- input,
138
- resolved: false,
139
- note: "account not configured",
140
- }));
141
- }
142
-
143
- const log: ChannelLogSink = {
144
- info: (msg) => runtime.log(msg),
145
- warn: (msg) => runtime.log(msg),
146
- error: (msg) => runtime.error(msg),
147
- debug: (msg) => runtime.log(msg),
148
- };
149
- return await resolveTwitchTargets(inputs, account, kind, log);
150
- },
151
- },
152
- status: createComputedAccountStatusAdapter<ResolvedTwitchAccount>({
153
- defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
154
- buildChannelSummary: ({ snapshot }) => buildPassiveProbedChannelStatusSummary(snapshot),
155
- probeAccount: async ({ account, timeoutMs }) => await probeTwitch(account, timeoutMs),
156
- collectStatusIssues: collectTwitchStatusIssues,
157
- resolveAccountSnapshot: ({ account, cfg }) => {
158
- const resolvedAccountId =
159
- account.accountId || resolveTwitchSnapshotAccountId(cfg, account);
160
- const { configured } = resolveTwitchAccountContext(cfg, resolvedAccountId);
161
- return {
162
- accountId: resolvedAccountId,
163
- enabled: account.enabled !== false,
164
- configured,
165
- };
166
- },
167
- }),
168
- gateway: {
169
- startAccount: async (ctx): Promise<void> => {
170
- const account = ctx.account;
171
- const accountId = ctx.accountId;
172
-
173
- ctx.setStatus?.({
174
- accountId,
175
- running: true,
176
- lastStartAt: Date.now(),
177
- lastError: null,
178
- });
179
-
180
- ctx.log?.info(`Starting Twitch connection for ${account.username}`);
181
-
182
- // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
183
- const { monitorTwitchProvider } = await import("./monitor.js");
184
- await monitorTwitchProvider({
185
- account,
186
- accountId,
187
- config: ctx.cfg,
188
- runtime: ctx.runtime,
189
- abortSignal: ctx.abortSignal,
190
- });
191
- },
192
- stopAccount: async (ctx): Promise<void> => {
193
- const account = ctx.account;
194
- const accountId = ctx.accountId;
195
-
196
- await removeClientManager(accountId);
197
-
198
- ctx.setStatus?.({
199
- accountId,
200
- running: false,
201
- lastStopAt: Date.now(),
202
- });
203
-
204
- ctx.log?.info(`Stopped Twitch connection for ${account.username}`);
205
- },
206
- },
207
- },
208
- });
package/src/probe.test.ts DELETED
@@ -1,196 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
- import { probeTwitch } from "./probe.js";
3
- import type { TwitchAccountConfig } from "./types.js";
4
-
5
- // Mock Twurple modules - Vitest v4 compatible mocking
6
- const mockUnbind = vi.fn();
7
-
8
- // Event handler storage
9
- let connectHandler: (() => void) | null = null;
10
- let disconnectHandler: ((manually: boolean, reason?: Error) => void) | null = null;
11
-
12
- // Event listener mocks that store handlers and return unbind function
13
- const mockOnConnect = vi.fn((handler: () => void) => {
14
- connectHandler = handler;
15
- return { unbind: mockUnbind };
16
- });
17
-
18
- const mockOnDisconnect = vi.fn((handler: (manually: boolean, reason?: Error) => void) => {
19
- disconnectHandler = handler;
20
- return { unbind: mockUnbind };
21
- });
22
-
23
- const mockOnAuthenticationFailure = vi.fn((_handler: () => void) => {
24
- return { unbind: mockUnbind };
25
- });
26
-
27
- // Connect mock that triggers the registered handler
28
- const defaultConnectImpl = async () => {
29
- // Simulate successful connection by calling the handler immediately.
30
- if (connectHandler) {
31
- connectHandler();
32
- }
33
- };
34
-
35
- const mockConnect = vi.fn().mockImplementation(defaultConnectImpl);
36
-
37
- const mockQuit = vi.fn().mockResolvedValue(undefined);
38
-
39
- vi.mock("@twurple/chat", () => ({
40
- ChatClient: class {
41
- connect = mockConnect;
42
- quit = mockQuit;
43
- onConnect = mockOnConnect;
44
- onDisconnect = mockOnDisconnect;
45
- onAuthenticationFailure = mockOnAuthenticationFailure;
46
- },
47
- }));
48
-
49
- vi.mock("@twurple/auth", () => ({
50
- StaticAuthProvider: function StaticAuthProvider() {},
51
- }));
52
-
53
- describe("probeTwitch", () => {
54
- const mockAccount: TwitchAccountConfig = {
55
- username: "testbot",
56
- accessToken: "oauth:test123456789",
57
- clientId: "test-client-id",
58
- channel: "testchannel",
59
- };
60
-
61
- beforeEach(() => {
62
- vi.clearAllMocks();
63
- // Reset handlers
64
- connectHandler = null;
65
- disconnectHandler = null;
66
- });
67
-
68
- it("returns error when username is missing", async () => {
69
- const account = { ...mockAccount, username: "" };
70
- const result = await probeTwitch(account, 5000);
71
-
72
- expect(result.ok).toBe(false);
73
- expect(result.error).toContain("missing credentials");
74
- });
75
-
76
- it("returns error when token is missing", async () => {
77
- const account = { ...mockAccount, accessToken: "" };
78
- const result = await probeTwitch(account, 5000);
79
-
80
- expect(result.ok).toBe(false);
81
- expect(result.error).toContain("missing credentials");
82
- });
83
-
84
- it("attempts connection regardless of token prefix", async () => {
85
- // Note: probeTwitch doesn't validate token format - it tries to connect with whatever token is provided
86
- // The actual connection would fail in production with an invalid token
87
- const account = { ...mockAccount, accessToken: "raw_token_no_prefix" };
88
- const result = await probeTwitch(account, 5000);
89
-
90
- // With mock, connection succeeds even without oauth: prefix
91
- expect(result.ok).toBe(true);
92
- });
93
-
94
- it("successfully connects with valid credentials", async () => {
95
- const result = await probeTwitch(mockAccount, 5000);
96
-
97
- expect(result.ok).toBe(true);
98
- expect(result.connected).toBe(true);
99
- expect(result.username).toBe("testbot");
100
- expect(result.channel).toBe("testchannel"); // uses account's configured channel
101
- });
102
-
103
- it("uses custom channel when specified", async () => {
104
- const account: TwitchAccountConfig = {
105
- ...mockAccount,
106
- channel: "customchannel",
107
- };
108
-
109
- const result = await probeTwitch(account, 5000);
110
-
111
- expect(result.ok).toBe(true);
112
- expect(result.channel).toBe("customchannel");
113
- });
114
-
115
- it("times out when connection takes too long", async () => {
116
- vi.useFakeTimers();
117
- try {
118
- mockConnect.mockImplementationOnce(() => new Promise(() => {})); // Never resolves
119
- const resultPromise = probeTwitch(mockAccount, 100);
120
- await vi.advanceTimersByTimeAsync(100);
121
- const result = await resultPromise;
122
-
123
- expect(result.ok).toBe(false);
124
- expect(result.error).toContain("timeout");
125
- } finally {
126
- vi.useRealTimers();
127
- mockConnect.mockImplementation(defaultConnectImpl);
128
- }
129
- });
130
-
131
- it("cleans up client even on failure", async () => {
132
- mockConnect.mockImplementationOnce(async () => {
133
- // Simulate connection failure by calling disconnect handler
134
- // onDisconnect signature: (manually: boolean, reason?: Error) => void
135
- if (disconnectHandler) {
136
- disconnectHandler(false, new Error("Connection failed"));
137
- }
138
- });
139
-
140
- const result = await probeTwitch(mockAccount, 5000);
141
-
142
- expect(result.ok).toBe(false);
143
- expect(result.error).toContain("Connection failed");
144
- expect(mockQuit).toHaveBeenCalled();
145
-
146
- // Reset mocks
147
- mockConnect.mockImplementation(defaultConnectImpl);
148
- });
149
-
150
- it("handles connection errors gracefully", async () => {
151
- mockConnect.mockImplementationOnce(async () => {
152
- // Simulate connection failure by calling disconnect handler
153
- // onDisconnect signature: (manually: boolean, reason?: Error) => void
154
- if (disconnectHandler) {
155
- disconnectHandler(false, new Error("Network error"));
156
- }
157
- });
158
-
159
- const result = await probeTwitch(mockAccount, 5000);
160
-
161
- expect(result.ok).toBe(false);
162
- expect(result.error).toContain("Network error");
163
-
164
- // Reset mock
165
- mockConnect.mockImplementation(defaultConnectImpl);
166
- });
167
-
168
- it("trims token before validation", async () => {
169
- const account: TwitchAccountConfig = {
170
- ...mockAccount,
171
- accessToken: " oauth:test123456789 ",
172
- };
173
-
174
- const result = await probeTwitch(account, 5000);
175
-
176
- expect(result.ok).toBe(true);
177
- });
178
-
179
- it("handles non-Error objects in catch block", async () => {
180
- mockConnect.mockImplementationOnce(async () => {
181
- // Simulate connection failure by calling disconnect handler
182
- // onDisconnect signature: (manually: boolean, reason?: Error) => void
183
- if (disconnectHandler) {
184
- disconnectHandler(false, "String error" as unknown as Error);
185
- }
186
- });
187
-
188
- const result = await probeTwitch(mockAccount, 5000);
189
-
190
- expect(result.ok).toBe(false);
191
- expect(result.error).toBe("String error");
192
-
193
- // Reset mock
194
- mockConnect.mockImplementation(defaultConnectImpl);
195
- });
196
- });
package/src/probe.ts DELETED
@@ -1,130 +0,0 @@
1
- import { StaticAuthProvider } from "@twurple/auth";
2
- import { ChatClient } from "@twurple/chat";
3
- import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
4
- import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
5
- import type { TwitchAccountConfig } from "./types.js";
6
- import { normalizeToken } from "./utils/twitch.js";
7
-
8
- /**
9
- * Result of probing a Twitch account
10
- */
11
- type ProbeTwitchResult = BaseProbeResult<string> & {
12
- username?: string;
13
- elapsedMs: number;
14
- connected?: boolean;
15
- channel?: string;
16
- };
17
-
18
- /**
19
- * Probe a Twitch account to verify the connection is working
20
- *
21
- * This tests the Twitch OAuth token by attempting to connect
22
- * to the chat server and verify the bot's username.
23
- */
24
- export async function probeTwitch(
25
- account: TwitchAccountConfig,
26
- timeoutMs: number,
27
- ): Promise<ProbeTwitchResult> {
28
- const started = Date.now();
29
-
30
- if (!account.accessToken || !account.username) {
31
- return {
32
- ok: false,
33
- error: "missing credentials (accessToken, username)",
34
- username: account.username,
35
- elapsedMs: Date.now() - started,
36
- };
37
- }
38
-
39
- const rawToken = normalizeToken(account.accessToken.trim());
40
-
41
- let client: ChatClient | undefined;
42
-
43
- try {
44
- const authProvider = new StaticAuthProvider(account.clientId ?? "", rawToken);
45
-
46
- client = new ChatClient({
47
- authProvider,
48
- });
49
-
50
- // Create a promise that resolves when connected
51
- const connectionPromise = new Promise<void>((resolve, reject) => {
52
- let settled = false;
53
- let connectListener: ReturnType<ChatClient["onConnect"]> | undefined;
54
- let disconnectListener: ReturnType<ChatClient["onDisconnect"]> | undefined;
55
- let authFailListener: ReturnType<ChatClient["onAuthenticationFailure"]> | undefined;
56
-
57
- const cleanup = () => {
58
- if (settled) {
59
- return;
60
- }
61
- settled = true;
62
- connectListener?.unbind();
63
- disconnectListener?.unbind();
64
- authFailListener?.unbind();
65
- };
66
-
67
- // Success: connection established
68
- connectListener = client?.onConnect(() => {
69
- cleanup();
70
- resolve();
71
- });
72
-
73
- // Failure: disconnected (e.g., auth failed)
74
- disconnectListener = client?.onDisconnect((_manually, reason) => {
75
- cleanup();
76
- reject(reason || new Error("Disconnected"));
77
- });
78
-
79
- // Failure: authentication failed
80
- authFailListener = client?.onAuthenticationFailure(() => {
81
- cleanup();
82
- reject(new Error("Authentication failed"));
83
- });
84
- });
85
-
86
- let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
87
- const timeout = new Promise<never>((_, reject) => {
88
- timeoutHandle = setTimeout(
89
- () => reject(new Error(`timeout after ${timeoutMs}ms`)),
90
- timeoutMs,
91
- );
92
- });
93
-
94
- client.connect();
95
- try {
96
- await Promise.race([connectionPromise, timeout]);
97
- } finally {
98
- if (timeoutHandle) {
99
- clearTimeout(timeoutHandle);
100
- }
101
- }
102
-
103
- client.quit();
104
- client = undefined;
105
-
106
- return {
107
- ok: true,
108
- connected: true,
109
- username: account.username,
110
- channel: account.channel,
111
- elapsedMs: Date.now() - started,
112
- };
113
- } catch (error) {
114
- return {
115
- ok: false,
116
- error: formatErrorMessage(error),
117
- username: account.username,
118
- channel: account.channel,
119
- elapsedMs: Date.now() - started,
120
- };
121
- } finally {
122
- if (client) {
123
- try {
124
- client.quit();
125
- } catch {
126
- // Ignore cleanup errors
127
- }
128
- }
129
- }
130
- }