@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/src/config.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import type { TwitchAccountConfig } from "./types.js";
3
+
4
+ /**
5
+ * Default account ID for Twitch
6
+ */
7
+ export const DEFAULT_ACCOUNT_ID = "default";
8
+
9
+ /**
10
+ * Get account config from core config
11
+ *
12
+ * Handles two patterns:
13
+ * 1. Simplified single-account: base-level properties create implicit "default" account
14
+ * 2. Multi-account: explicit accounts object
15
+ *
16
+ * For "default" account, base-level properties take precedence over accounts.default
17
+ * For other accounts, only the accounts object is checked
18
+ */
19
+ export function getAccountConfig(
20
+ coreConfig: unknown,
21
+ accountId: string,
22
+ ): TwitchAccountConfig | null {
23
+ if (!coreConfig || typeof coreConfig !== "object") {
24
+ return null;
25
+ }
26
+
27
+ const cfg = coreConfig as OpenClawConfig;
28
+ const twitch = cfg.channels?.twitch;
29
+ // Access accounts via unknown to handle union type (single-account vs multi-account)
30
+ const twitchRaw = twitch as Record<string, unknown> | undefined;
31
+ const accounts = twitchRaw?.accounts as Record<string, TwitchAccountConfig> | undefined;
32
+
33
+ // For default account, check base-level config first
34
+ if (accountId === DEFAULT_ACCOUNT_ID) {
35
+ const accountFromAccounts = accounts?.[DEFAULT_ACCOUNT_ID];
36
+
37
+ // Base-level properties that can form an implicit default account
38
+ const baseLevel = {
39
+ username: typeof twitchRaw?.username === "string" ? twitchRaw.username : undefined,
40
+ accessToken: typeof twitchRaw?.accessToken === "string" ? twitchRaw.accessToken : undefined,
41
+ clientId: typeof twitchRaw?.clientId === "string" ? twitchRaw.clientId : undefined,
42
+ channel: typeof twitchRaw?.channel === "string" ? twitchRaw.channel : undefined,
43
+ enabled: typeof twitchRaw?.enabled === "boolean" ? twitchRaw.enabled : undefined,
44
+ allowFrom: Array.isArray(twitchRaw?.allowFrom) ? twitchRaw.allowFrom : undefined,
45
+ allowedRoles: Array.isArray(twitchRaw?.allowedRoles) ? twitchRaw.allowedRoles : undefined,
46
+ requireMention:
47
+ typeof twitchRaw?.requireMention === "boolean" ? twitchRaw.requireMention : undefined,
48
+ clientSecret:
49
+ typeof twitchRaw?.clientSecret === "string" ? twitchRaw.clientSecret : undefined,
50
+ refreshToken:
51
+ typeof twitchRaw?.refreshToken === "string" ? twitchRaw.refreshToken : undefined,
52
+ expiresIn: typeof twitchRaw?.expiresIn === "number" ? twitchRaw.expiresIn : undefined,
53
+ obtainmentTimestamp:
54
+ typeof twitchRaw?.obtainmentTimestamp === "number"
55
+ ? twitchRaw.obtainmentTimestamp
56
+ : undefined,
57
+ };
58
+
59
+ // Merge: base-level takes precedence over accounts.default
60
+ const merged: Partial<TwitchAccountConfig> = {
61
+ ...accountFromAccounts,
62
+ ...baseLevel,
63
+ } as Partial<TwitchAccountConfig>;
64
+
65
+ // Only return if we have at least username
66
+ if (merged.username) {
67
+ return merged as TwitchAccountConfig;
68
+ }
69
+
70
+ // Fall through to accounts.default if no base-level username
71
+ if (accountFromAccounts) {
72
+ return accountFromAccounts;
73
+ }
74
+
75
+ return null;
76
+ }
77
+
78
+ // For non-default accounts, only check accounts object
79
+ if (!accounts || !accounts[accountId]) {
80
+ return null;
81
+ }
82
+
83
+ return accounts[accountId] as TwitchAccountConfig | null;
84
+ }
85
+
86
+ /**
87
+ * List all configured account IDs
88
+ *
89
+ * Includes both explicit accounts and implicit "default" from base-level config
90
+ */
91
+ export function listAccountIds(cfg: OpenClawConfig): string[] {
92
+ const twitch = cfg.channels?.twitch;
93
+ // Access accounts via unknown to handle union type (single-account vs multi-account)
94
+ const twitchRaw = twitch as Record<string, unknown> | undefined;
95
+ const accountMap = twitchRaw?.accounts as Record<string, unknown> | undefined;
96
+
97
+ const ids: string[] = [];
98
+
99
+ // Add explicit accounts
100
+ if (accountMap) {
101
+ ids.push(...Object.keys(accountMap));
102
+ }
103
+
104
+ // Add implicit "default" if base-level config exists and "default" not already present
105
+ const hasBaseLevelConfig =
106
+ twitchRaw &&
107
+ (typeof twitchRaw.username === "string" ||
108
+ typeof twitchRaw.accessToken === "string" ||
109
+ typeof twitchRaw.channel === "string");
110
+
111
+ if (hasBaseLevelConfig && !ids.includes(DEFAULT_ACCOUNT_ID)) {
112
+ ids.push(DEFAULT_ACCOUNT_ID);
113
+ }
114
+
115
+ return ids;
116
+ }
package/src/monitor.ts ADDED
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Twitch message monitor - processes incoming messages and routes to agents.
3
+ *
4
+ * This monitor connects to the Twitch client manager, processes incoming messages,
5
+ * resolves agent routes, and handles replies.
6
+ */
7
+
8
+ import type { ReplyPayload, OpenClawConfig } from "openclaw/plugin-sdk";
9
+ import { createReplyPrefixOptions } from "openclaw/plugin-sdk";
10
+ import { checkTwitchAccessControl } from "./access-control.js";
11
+ import { getOrCreateClientManager } from "./client-manager-registry.js";
12
+ import { getTwitchRuntime } from "./runtime.js";
13
+ import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
14
+ import { stripMarkdownForTwitch } from "./utils/markdown.js";
15
+
16
+ export type TwitchRuntimeEnv = {
17
+ log?: (message: string) => void;
18
+ error?: (message: string) => void;
19
+ };
20
+
21
+ export type TwitchMonitorOptions = {
22
+ account: TwitchAccountConfig;
23
+ accountId: string;
24
+ config: unknown; // OpenClawConfig
25
+ runtime: TwitchRuntimeEnv;
26
+ abortSignal: AbortSignal;
27
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
28
+ };
29
+
30
+ export type TwitchMonitorResult = {
31
+ stop: () => void;
32
+ };
33
+
34
+ type TwitchCoreRuntime = ReturnType<typeof getTwitchRuntime>;
35
+
36
+ /**
37
+ * Process an incoming Twitch message and dispatch to agent.
38
+ */
39
+ async function processTwitchMessage(params: {
40
+ message: TwitchChatMessage;
41
+ account: TwitchAccountConfig;
42
+ accountId: string;
43
+ config: unknown;
44
+ runtime: TwitchRuntimeEnv;
45
+ core: TwitchCoreRuntime;
46
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
47
+ }): Promise<void> {
48
+ const { message, account, accountId, config, runtime, core, statusSink } = params;
49
+ const cfg = config as OpenClawConfig;
50
+
51
+ const route = core.channel.routing.resolveAgentRoute({
52
+ cfg,
53
+ channel: "twitch",
54
+ accountId,
55
+ peer: {
56
+ kind: "group", // Twitch chat is always group-like
57
+ id: message.channel,
58
+ },
59
+ });
60
+
61
+ const rawBody = message.message;
62
+ const body = core.channel.reply.formatAgentEnvelope({
63
+ channel: "Twitch",
64
+ from: message.displayName ?? message.username,
65
+ timestamp: message.timestamp?.getTime(),
66
+ envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
67
+ body: rawBody,
68
+ });
69
+
70
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
71
+ Body: body,
72
+ BodyForAgent: rawBody,
73
+ RawBody: rawBody,
74
+ CommandBody: rawBody,
75
+ From: `twitch:user:${message.userId}`,
76
+ To: `twitch:channel:${message.channel}`,
77
+ SessionKey: route.sessionKey,
78
+ AccountId: route.accountId,
79
+ ChatType: "group",
80
+ ConversationLabel: message.channel,
81
+ SenderName: message.displayName ?? message.username,
82
+ SenderId: message.userId,
83
+ SenderUsername: message.username,
84
+ Provider: "twitch",
85
+ Surface: "twitch",
86
+ MessageSid: message.id,
87
+ OriginatingChannel: "twitch",
88
+ OriginatingTo: `twitch:channel:${message.channel}`,
89
+ });
90
+
91
+ const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
92
+ agentId: route.agentId,
93
+ });
94
+ await core.channel.session.recordInboundSession({
95
+ storePath,
96
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
97
+ ctx: ctxPayload,
98
+ onRecordError: (err) => {
99
+ runtime.error?.(`Failed updating session meta: ${String(err)}`);
100
+ },
101
+ });
102
+
103
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
104
+ cfg,
105
+ channel: "twitch",
106
+ accountId,
107
+ });
108
+ const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
109
+ cfg,
110
+ agentId: route.agentId,
111
+ channel: "twitch",
112
+ accountId,
113
+ });
114
+
115
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
116
+ ctx: ctxPayload,
117
+ cfg,
118
+ dispatcherOptions: {
119
+ ...prefixOptions,
120
+ deliver: async (payload) => {
121
+ await deliverTwitchReply({
122
+ payload,
123
+ channel: message.channel,
124
+ account,
125
+ accountId,
126
+ config,
127
+ tableMode,
128
+ runtime,
129
+ statusSink,
130
+ });
131
+ },
132
+ },
133
+ replyOptions: {
134
+ onModelSelected,
135
+ },
136
+ });
137
+ }
138
+
139
+ /**
140
+ * Deliver a reply to Twitch chat.
141
+ */
142
+ async function deliverTwitchReply(params: {
143
+ payload: ReplyPayload;
144
+ channel: string;
145
+ account: TwitchAccountConfig;
146
+ accountId: string;
147
+ config: unknown;
148
+ tableMode: "off" | "plain" | "markdown" | "bullets" | "code";
149
+ runtime: TwitchRuntimeEnv;
150
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
151
+ }): Promise<void> {
152
+ const { payload, channel, account, accountId, config, runtime, statusSink } = params;
153
+
154
+ try {
155
+ const clientManager = getOrCreateClientManager(accountId, {
156
+ info: (msg) => runtime.log?.(msg),
157
+ warn: (msg) => runtime.log?.(msg),
158
+ error: (msg) => runtime.error?.(msg),
159
+ debug: (msg) => runtime.log?.(msg),
160
+ });
161
+
162
+ const client = await clientManager.getClient(
163
+ account,
164
+ config as Parameters<typeof clientManager.getClient>[1],
165
+ accountId,
166
+ );
167
+ if (!client) {
168
+ runtime.error?.(`No client available for sending reply`);
169
+ return;
170
+ }
171
+
172
+ // Send the reply
173
+ if (!payload.text) {
174
+ runtime.error?.(`No text to send in reply payload`);
175
+ return;
176
+ }
177
+
178
+ const textToSend = stripMarkdownForTwitch(payload.text);
179
+
180
+ await client.say(channel, textToSend);
181
+ statusSink?.({ lastOutboundAt: Date.now() });
182
+ } catch (err) {
183
+ runtime.error?.(`Failed to send reply: ${String(err)}`);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Main monitor provider for Twitch.
189
+ *
190
+ * Sets up message handlers and processes incoming messages.
191
+ */
192
+ export async function monitorTwitchProvider(
193
+ options: TwitchMonitorOptions,
194
+ ): Promise<TwitchMonitorResult> {
195
+ const { account, accountId, config, runtime, abortSignal, statusSink } = options;
196
+
197
+ const core = getTwitchRuntime();
198
+ let stopped = false;
199
+
200
+ const coreLogger = core.logging.getChildLogger({ module: "twitch" });
201
+ const logVerboseMessage = (message: string) => {
202
+ if (!core.logging.shouldLogVerbose()) {
203
+ return;
204
+ }
205
+ coreLogger.debug?.(message);
206
+ };
207
+ const logger = {
208
+ info: (msg: string) => coreLogger.info(msg),
209
+ warn: (msg: string) => coreLogger.warn(msg),
210
+ error: (msg: string) => coreLogger.error(msg),
211
+ debug: logVerboseMessage,
212
+ };
213
+
214
+ const clientManager = getOrCreateClientManager(accountId, logger);
215
+
216
+ try {
217
+ await clientManager.getClient(
218
+ account,
219
+ config as Parameters<typeof clientManager.getClient>[1],
220
+ accountId,
221
+ );
222
+ } catch (error) {
223
+ const errorMsg = error instanceof Error ? error.message : String(error);
224
+ runtime.error?.(`Failed to connect: ${errorMsg}`);
225
+ throw error;
226
+ }
227
+
228
+ const unregisterHandler = clientManager.onMessage(account, (message) => {
229
+ if (stopped) {
230
+ return;
231
+ }
232
+
233
+ // Access control check
234
+ const botUsername = account.username.toLowerCase();
235
+ if (message.username.toLowerCase() === botUsername) {
236
+ return; // Ignore own messages
237
+ }
238
+
239
+ const access = checkTwitchAccessControl({
240
+ message,
241
+ account,
242
+ botUsername,
243
+ });
244
+
245
+ if (!access.allowed) {
246
+ return;
247
+ }
248
+
249
+ statusSink?.({ lastInboundAt: Date.now() });
250
+
251
+ // Fire-and-forget: process message without blocking
252
+ void processTwitchMessage({
253
+ message,
254
+ account,
255
+ accountId,
256
+ config,
257
+ runtime,
258
+ core,
259
+ statusSink,
260
+ }).catch((err) => {
261
+ runtime.error?.(`Message processing failed: ${String(err)}`);
262
+ });
263
+ });
264
+
265
+ const stop = () => {
266
+ stopped = true;
267
+ unregisterHandler();
268
+ };
269
+
270
+ abortSignal.addEventListener("abort", stop, { once: true });
271
+
272
+ return { stop };
273
+ }
@@ -0,0 +1,316 @@
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
+ });