@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/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
|
+
});
|