@bajoseek/openclaw-bajoseek 0.1.0

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/gateway.ts ADDED
@@ -0,0 +1,400 @@
1
+ import WebSocket from "ws";
2
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
+ import type { ResolvedBajoseekAccount, InboundUserMessage } from "./types.js";
4
+ import { getBajoseekRuntime } from "./runtime.js";
5
+ import { sendStreamChunk, sendStreamEnd, sendChunkedEnd } from "./outbound.js";
6
+
7
+ /**
8
+ * 非 block streaming 模式下,我们自行拆分的块大小(字符数)。
9
+ * 可根据 WebSocket 服务端缓冲区大小调整,默认 4000 字符。
10
+ */
11
+ const FALLBACK_CHUNK_SIZE = 10000;
12
+
13
+ // ============ 全局连接池 ============
14
+ const wsConnections = new Map<string, WebSocket>();
15
+
16
+ export function getWsConnection(accountId: string): WebSocket | undefined {
17
+ return wsConnections.get(accountId);
18
+ }
19
+
20
+ // ============ Gateway 上下文 ============
21
+ export interface GatewayContext {
22
+ account: ResolvedBajoseekAccount;
23
+ abortSignal: AbortSignal;
24
+ cfg: OpenClawConfig;
25
+ onReady?: () => void;
26
+ onError?: (error: Error) => void;
27
+ log?: {
28
+ info: (msg: string) => void;
29
+ error: (msg: string) => void;
30
+ debug?: (msg: string) => void;
31
+ };
32
+ }
33
+
34
+ // ============ 消息队列 ============
35
+ const PER_USER_QUEUE_SIZE = 20;
36
+ const MAX_CONCURRENT_USERS = 10;
37
+
38
+ interface QueuedMessage {
39
+ accountId: string;
40
+ event: InboundUserMessage;
41
+ }
42
+
43
+ /**
44
+ * 启动 Gateway WebSocket 连接(带自动重连)
45
+ */
46
+ export async function startGateway(ctx: GatewayContext): Promise<void> {
47
+ const { account, abortSignal, cfg, onReady, onError, log } = ctx;
48
+
49
+ if (!account.botId || !account.token) {
50
+ throw new Error("Bajoseek not configured (missing botId or token)");
51
+ }
52
+
53
+ let reconnectAttempts = 0;
54
+ let isAborted = false;
55
+ let currentWs: WebSocket | null = null;
56
+ let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
57
+
58
+ // 监听 abortSignal 优雅关闭
59
+ abortSignal.addEventListener("abort", () => {
60
+ isAborted = true;
61
+ if (heartbeatInterval) {
62
+ clearInterval(heartbeatInterval);
63
+ heartbeatInterval = null;
64
+ }
65
+ if (currentWs) {
66
+ currentWs.close(1000, "shutdown");
67
+ currentWs = null;
68
+ }
69
+ wsConnections.delete(account.accountId);
70
+ log?.info(`[bajoseek:${account.accountId}] Gateway shut down via abort signal`);
71
+ });
72
+
73
+ // 消息队列(per-user 隔离)
74
+ const userQueues = new Map<string, QueuedMessage[]>();
75
+ const activeUsers = new Set<string>();
76
+
77
+ const enqueueMessage = (msg: QueuedMessage): void => {
78
+ const peerId = `dm:${msg.event.userId}`;
79
+ let queue = userQueues.get(peerId);
80
+ if (!queue) {
81
+ queue = [];
82
+ userQueues.set(peerId, queue);
83
+ }
84
+
85
+ if (queue.length >= PER_USER_QUEUE_SIZE) {
86
+ queue.shift();
87
+ log?.error(`[bajoseek:${account.accountId}] Per-user queue full for ${peerId}, dropping oldest`);
88
+ }
89
+
90
+ queue.push(msg);
91
+ drainUserQueue(peerId);
92
+ };
93
+
94
+ const drainUserQueue = async (peerId: string): Promise<void> => {
95
+ if (activeUsers.has(peerId) || activeUsers.size >= MAX_CONCURRENT_USERS) {
96
+ return;
97
+ }
98
+
99
+ const queue = userQueues.get(peerId);
100
+ if (!queue || queue.length === 0) {
101
+ userQueues.delete(peerId);
102
+ return;
103
+ }
104
+
105
+ activeUsers.add(peerId);
106
+ try {
107
+ while (queue.length > 0 && !isAborted) {
108
+ const msg = queue.shift()!;
109
+ await handleInboundMessage(msg);
110
+ }
111
+ } finally {
112
+ activeUsers.delete(peerId);
113
+ // 尝试排空其他等待中的用户
114
+ for (const [pid, q] of userQueues) {
115
+ if (q.length > 0 && !activeUsers.has(pid)) {
116
+ drainUserQueue(pid);
117
+ }
118
+ }
119
+ }
120
+ };
121
+
122
+ // ============ 处理收到的用户消息 ============
123
+ const handleInboundMessage = async (msg: QueuedMessage): Promise<void> => {
124
+ const { event } = msg;
125
+ const pluginRuntime = getBajoseekRuntime();
126
+
127
+ log?.info(`[bajoseek:${account.accountId}] Processing message from userId=${event.userId}, text=${event.text.slice(0, 100)}`);
128
+
129
+ const fromAddress = `bajoseek:user:${event.userId}`;
130
+ const toAddress = `bajoseek:bot:${account.botId}`;
131
+ const sessionKey = `bajoseek:dm:${event.userId}:${account.accountId}:${event.conversationId}`;
132
+
133
+ // 构建 body
134
+ const body = event.text;
135
+ const agentBody = event.text;
136
+
137
+ const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({
138
+ Body: body,
139
+ BodyForAgent: agentBody,
140
+ RawBody: event.text,
141
+ CommandBody: event.text,
142
+ CommandAuthorized: true,
143
+ From: fromAddress,
144
+ To: toAddress,
145
+ SessionKey: sessionKey,
146
+ AccountId: account.accountId,
147
+ ChatType: "direct" as const,
148
+ SenderId: event.userId,
149
+ SenderName: event.userId,
150
+ Provider: "bajoseek",
151
+ Surface: "bajoseek",
152
+ MessageSid: event.messageId,
153
+ Timestamp: event.timestamp,
154
+ OriginatingChannel: "bajoseek",
155
+ OriginatingTo: toAddress,
156
+ });
157
+
158
+ try {
159
+ const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, "");
160
+
161
+ // 使用服务端传来的 conversationId
162
+ const conversationId = event.conversationId;
163
+ let hasResponse = false;
164
+ let pendingText: string | null = null;
165
+ let receivedBlocks = false;
166
+
167
+ // 读取 blockStreaming 配置,传给框架
168
+ const blockStreamingCfg = account.config?.blockStreaming;
169
+ const replyOptions: Record<string, unknown> = {};
170
+ if (typeof blockStreamingCfg === "boolean") {
171
+ replyOptions.disableBlockStreaming = !blockStreamingCfg;
172
+ }
173
+
174
+ const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
175
+ ctx: ctxPayload,
176
+ cfg,
177
+ replyOptions,
178
+ dispatcherOptions: {
179
+ responsePrefix: messagesConfig.responsePrefix,
180
+ deliver: async (payload: { text?: string }, info: { kind: string }) => {
181
+ hasResponse = true;
182
+
183
+ const text = payload.text ?? "";
184
+ log?.info(`[bajoseek:${account.accountId}] deliver: kind=${info.kind}, text.length=${text.length}, text.preview=${JSON.stringify(text.slice(0, 200))}`);
185
+
186
+ // 跳过 tool 中间结果
187
+ if (info.kind === "tool") {
188
+ log?.info(`[bajoseek:${account.accountId}] Skipping tool result, text.preview=${JSON.stringify(text.slice(0, 500))}`);
189
+ return;
190
+ }
191
+
192
+ if (!text.trim()) return;
193
+
194
+ const ws = wsConnections.get(account.accountId);
195
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
196
+ log?.error(`[bajoseek:${account.accountId}] WebSocket not available for delivery`);
197
+ return;
198
+ }
199
+
200
+ if (info.kind === "block") {
201
+ // 框架 block streaming 模式:每个 block 是增量分块
202
+ receivedBlocks = true;
203
+ if (pendingText !== null) {
204
+ sendStreamChunk(ws, conversationId, pendingText);
205
+ }
206
+ pendingText = text;
207
+ return;
208
+ }
209
+
210
+ // kind === "final"
211
+ if (receivedBlocks) {
212
+ // block streaming 模式下 final 包含完整文本,
213
+ // 内容已通过 blocks 送达,忽略 final 避免重复
214
+ log?.info(`[bajoseek:${account.accountId}] Block streaming active, skipping final (content already delivered via blocks)`);
215
+ return;
216
+ }
217
+
218
+ // 非 block streaming:可能有多次 final(agent 多步调用)
219
+ if (pendingText !== null) {
220
+ sendStreamChunk(ws, conversationId, pendingText);
221
+ }
222
+ pendingText = text;
223
+ },
224
+ },
225
+ });
226
+
227
+ await dispatchPromise;
228
+
229
+ const ws = wsConnections.get(account.accountId);
230
+ if (ws && ws.readyState === WebSocket.OPEN) {
231
+ if (!hasResponse) {
232
+ log?.error(`[bajoseek:${account.accountId}] No response from AI for messageId=${event.messageId}`);
233
+ sendStreamEnd(ws, conversationId, "[系统提示] AI 未生成回复,请稍后重试");
234
+ } else if (receivedBlocks) {
235
+ // block streaming 模式:框架已分块,直接发 stream_end(不二次拆分)
236
+ sendStreamEnd(ws, conversationId, pendingText ?? "");
237
+ log?.info(`[bajoseek:${account.accountId}] Sent stream_end (block streaming) for conversationId=${conversationId}`);
238
+ } else {
239
+ // 非 block streaming:我们按 FALLBACK_CHUNK_SIZE 拆分
240
+ sendChunkedEnd(ws, conversationId, pendingText ?? "", FALLBACK_CHUNK_SIZE);
241
+ log?.info(`[bajoseek:${account.accountId}] Sent chunked stream_end for conversationId=${conversationId}`);
242
+ }
243
+ }
244
+ } catch (err) {
245
+ log?.error(`[bajoseek:${account.accountId}] Error processing message: ${err}`);
246
+ // 发送错误提示给服务端
247
+ try {
248
+ const ws = wsConnections.get(account.accountId);
249
+ if (ws && ws.readyState === WebSocket.OPEN) {
250
+ const conversationId = event.conversationId;
251
+ sendStreamEnd(ws, conversationId, "[系统提示] 处理消息时出错,请稍后重试");
252
+ }
253
+ } catch {
254
+ // 发送错误提示也失败,忽略
255
+ }
256
+ }
257
+ };
258
+
259
+ // ============ 重连退避 ============
260
+ const BACKOFF_SCHEDULE = [1000, 2000, 5000, 10000, 30000, 60000];
261
+
262
+ const getBackoffDelay = (): number => {
263
+ const idx = Math.min(reconnectAttempts, BACKOFF_SCHEDULE.length - 1);
264
+ return BACKOFF_SCHEDULE[idx];
265
+ };
266
+
267
+ const sleep = (ms: number): Promise<void> =>
268
+ new Promise((resolve) => setTimeout(resolve, ms));
269
+
270
+ // ============ 连接循环 ============
271
+ const connect = async (): Promise<void> => {
272
+ while (!isAborted) {
273
+ try {
274
+ await connectOnce();
275
+ } catch (err) {
276
+ if (isAborted) break;
277
+ const delay = getBackoffDelay();
278
+ reconnectAttempts++;
279
+ log?.error(`[bajoseek:${account.accountId}] Connection failed: ${err}. Reconnecting in ${delay}ms (attempt #${reconnectAttempts})`);
280
+ onError?.(err instanceof Error ? err : new Error(String(err)));
281
+ await sleep(delay);
282
+ }
283
+ }
284
+ };
285
+
286
+ const connectOnce = (): Promise<void> => {
287
+ return new Promise<void>((resolve, reject) => {
288
+ if (isAborted) {
289
+ resolve();
290
+ return;
291
+ }
292
+
293
+ const wsEndpoint = `${account.wsUrl.replace(/\/+$/, "")}/ws/bot`;
294
+ log?.info(`[bajoseek:${account.accountId}] Connecting to ${wsEndpoint}...`);
295
+ const ws = new WebSocket(wsEndpoint, {
296
+ headers: {
297
+ "X-Bot-Id": account.botId,
298
+ "Authorization": `Bearer ${account.token}`,
299
+ },
300
+ });
301
+ currentWs = ws;
302
+
303
+ ws.on("open", () => {
304
+ // 握手认证通过(interceptor 已校验),直接视为认证成功
305
+ reconnectAttempts = 0;
306
+ wsConnections.set(account.accountId, ws);
307
+ log?.info(`[bajoseek:${account.accountId}] WebSocket connected and authenticated`);
308
+ onReady?.();
309
+
310
+ // 启动心跳
311
+ heartbeatInterval = setInterval(() => {
312
+ if (ws.readyState === WebSocket.OPEN) {
313
+ ws.send(JSON.stringify({ type: "ping" }));
314
+ }
315
+ }, 30000);
316
+ });
317
+
318
+ ws.on("message", (data: WebSocket.RawData) => {
319
+ let parsed: Record<string, unknown>;
320
+ try {
321
+ parsed = JSON.parse(data.toString());
322
+ } catch {
323
+ log?.error(`[bajoseek:${account.accountId}] Failed to parse message: ${data.toString().slice(0, 200)}`);
324
+ return;
325
+ }
326
+
327
+ const msgType = parsed.type as string;
328
+
329
+ // 处理 pong
330
+ if (msgType === "pong") {
331
+ return;
332
+ }
333
+
334
+ // 处理服务端错误通知
335
+ if (msgType === "error") {
336
+ log?.error(`[bajoseek:${account.accountId}] Server error: code=${parsed.code}, message=${parsed.message}`);
337
+ return;
338
+ }
339
+
340
+ // 处理用户消息
341
+ if (msgType === "message") {
342
+ const event: InboundUserMessage = {
343
+ type: "message",
344
+ messageId: parsed.messageId as string,
345
+ userId: parsed.userId as string,
346
+ conversationId: parsed.conversationId as string,
347
+ text: parsed.text as string,
348
+ timestamp: (parsed.timestamp as number) || Date.now(),
349
+ };
350
+
351
+ log?.info(`[bajoseek:${account.accountId}] Received message: userId=${event.userId}, messageId=${event.messageId}`);
352
+
353
+ enqueueMessage({
354
+ accountId: account.accountId,
355
+ event,
356
+ });
357
+ return;
358
+ }
359
+
360
+ log?.debug?.(`[bajoseek:${account.accountId}] Unhandled message type: ${msgType}`);
361
+ });
362
+
363
+ ws.on("close", (code, reason) => {
364
+ log?.info(`[bajoseek:${account.accountId}] WebSocket closed: code=${code}, reason=${reason.toString()}`);
365
+ wsConnections.delete(account.accountId);
366
+
367
+ if (heartbeatInterval) {
368
+ clearInterval(heartbeatInterval);
369
+ heartbeatInterval = null;
370
+ }
371
+
372
+ currentWs = null;
373
+
374
+ if (!isAborted) {
375
+ // 正常关闭后通过循环重连
376
+ resolve();
377
+ }
378
+ });
379
+
380
+ ws.on("error", (err) => {
381
+ log?.error(`[bajoseek:${account.accountId}] WebSocket error: ${err.message}`);
382
+ // error 事件后会紧跟 close 事件,不需要在这里 reject
383
+ });
384
+
385
+ // 处理握手认证失败(HTTP 401)
386
+ ws.on("unexpected-response", (_req, res) => {
387
+ const statusCode = res.statusCode;
388
+ log?.error(`[bajoseek:${account.accountId}] WebSocket handshake rejected: HTTP ${statusCode}`);
389
+ ws.close();
390
+ if (statusCode === 401) {
391
+ reject(new Error(`Authentication failed (HTTP 401): check botId and token`));
392
+ } else {
393
+ reject(new Error(`WebSocket handshake failed: HTTP ${statusCode}`));
394
+ }
395
+ });
396
+ });
397
+ };
398
+
399
+ await connect();
400
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Bajoseek CLI Onboarding Adapter
3
+ *
4
+ * 提供 openclaw onboard 命令的交互式配置支持,
5
+ * 引导用户输入 botId、token 和可选的 wsUrl。
6
+ */
7
+ import type {
8
+ ChannelOnboardingAdapter,
9
+ ChannelOnboardingStatus,
10
+ ChannelOnboardingStatusContext,
11
+ ChannelOnboardingConfigureContext,
12
+ ChannelOnboardingResult,
13
+ OpenClawConfig,
14
+ WizardPrompter,
15
+ } from "openclaw/plugin-sdk";
16
+
17
+ import {
18
+ DEFAULT_ACCOUNT_ID,
19
+ listBajoseekAccountIds,
20
+ resolveBajoseekAccount,
21
+ } from "./config.js";
22
+
23
+ import type { BajoseekAccountConfig } from "./types.js";
24
+
25
+ interface BajoseekChannelConfig extends BajoseekAccountConfig {
26
+ accounts?: Record<string, BajoseekAccountConfig>;
27
+ }
28
+
29
+ /**
30
+ * 解析默认账户 ID
31
+ */
32
+ function resolveDefaultAccountId(cfg: OpenClawConfig): string {
33
+ const ids = listBajoseekAccountIds(cfg);
34
+ return ids[0] ?? DEFAULT_ACCOUNT_ID;
35
+ }
36
+
37
+ /**
38
+ * Bajoseek Onboarding Adapter
39
+ */
40
+ export const bajoseekOnboardingAdapter: ChannelOnboardingAdapter = {
41
+ channel: "bajoseek" as any,
42
+
43
+ /**
44
+ * 获取当前 Bajoseek 配置状态
45
+ */
46
+ getStatus: async (ctx: ChannelOnboardingStatusContext): Promise<ChannelOnboardingStatus> => {
47
+ const cfg = ctx.cfg;
48
+ const configured = listBajoseekAccountIds(cfg).some((accountId) => {
49
+ const account = resolveBajoseekAccount(cfg, accountId);
50
+ return Boolean(account.botId && account.token);
51
+ });
52
+
53
+ return {
54
+ channel: "bajoseek" as any,
55
+ configured,
56
+ statusLines: [`Bajoseek: ${configured ? "Configured(已配置)" : "Requires BotID and Token(需要 BotID 和 Token)"}`],
57
+ selectionHint: configured ? "Configured(已配置)" : "Connect to Bajoseek App via WebSocket(通过 WebSocket 连接 Bajoseek App)",
58
+ quickstartScore: configured ? 1 : 20,
59
+ };
60
+ },
61
+
62
+ /**
63
+ * 交互式配置 Bajoseek
64
+ */
65
+ configure: async (ctx: ChannelOnboardingConfigureContext): Promise<ChannelOnboardingResult> => {
66
+ const cfg = ctx.cfg;
67
+ const prompter = ctx.prompter;
68
+ const accountOverrides = ctx.accountOverrides as Record<string, string> | undefined;
69
+ const shouldPromptAccountIds = ctx.shouldPromptAccountIds;
70
+
71
+ const bajoseekOverride = accountOverrides?.bajoseek?.trim();
72
+ const defaultAccountId = resolveDefaultAccountId(cfg);
73
+ let accountId = bajoseekOverride ?? defaultAccountId;
74
+
75
+ // 多账户时提示选择
76
+ if (shouldPromptAccountIds && !bajoseekOverride) {
77
+ const existingIds = listBajoseekAccountIds(cfg);
78
+ if (existingIds.length > 1) {
79
+ accountId = await prompter.select({
80
+ message: "Select Bajoseek account(选择 Bajoseek 账户)",
81
+ options: existingIds.map((id) => ({
82
+ value: id,
83
+ label: id === DEFAULT_ACCOUNT_ID ? "Default account(默认账户)" : id,
84
+ })),
85
+ initialValue: accountId,
86
+ });
87
+ }
88
+ }
89
+
90
+ let next: OpenClawConfig = cfg;
91
+ const resolvedAccount = resolveBajoseekAccount(next, accountId);
92
+ const accountConfigured = Boolean(resolvedAccount.botId && resolvedAccount.token);
93
+ const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
94
+ const envBotId = typeof process !== "undefined" ? process.env?.BAJOSEEK_BOT_ID?.trim() : undefined;
95
+ const envToken = typeof process !== "undefined" ? process.env?.BAJOSEEK_TOKEN?.trim() : undefined;
96
+ const canUseEnv = allowEnv && Boolean(envBotId && envToken);
97
+ const hasConfigCredentials = Boolean(resolvedAccount.config.botId && resolvedAccount.config.token);
98
+
99
+ let botId: string | null = null;
100
+ let token: string | null = null;
101
+ let wsUrl: string | null = null;
102
+
103
+ // 显示帮助信息
104
+ if (!accountConfigured) {
105
+ await prompter.note(
106
+ [
107
+ "1) Create a bot in Bajoseek App to get BotID and Token(在 Bajoseek App 中创建机器人,获取 BotID 和 Token)",
108
+ "2) You can also set environment variables BAJOSEEK_BOT_ID and BAJOSEEK_TOKEN(也可设置环境变量)",
109
+ "",
110
+ "Default WebSocket URL: wss://ws.bajoseek.com(默认 WebSocket 地址)",
111
+ "You can configure a custom URL (e.g. for testing) in the next step(如需自定义地址可在后续步骤配置)",
112
+ ].join("\n"),
113
+ "Bajoseek Setup(Bajoseek 配置)",
114
+ );
115
+ }
116
+
117
+ // 检测环境变量
118
+ if (canUseEnv && !hasConfigCredentials) {
119
+ const keepEnv = await prompter.confirm({
120
+ message: "Detected BAJOSEEK_BOT_ID and BAJOSEEK_TOKEN in environment. Use them?(检测到环境变量,是否使用?)",
121
+ initialValue: true,
122
+ });
123
+ if (keepEnv) {
124
+ next = applyConfig(next, accountId, resolvedAccount, {});
125
+ } else {
126
+ ({ botId, token } = await promptCredentials(prompter, resolvedAccount));
127
+ }
128
+ } else if (hasConfigCredentials) {
129
+ // 已有配置
130
+ const keep = await prompter.confirm({
131
+ message: "Bajoseek is already configured. Keep current settings?(已配置,是否保留当前配置?)",
132
+ initialValue: true,
133
+ });
134
+ if (!keep) {
135
+ ({ botId, token } = await promptCredentials(prompter, resolvedAccount));
136
+ }
137
+ } else {
138
+ // 没有配置,需要输入
139
+ ({ botId, token } = await promptCredentials(prompter, resolvedAccount));
140
+ }
141
+
142
+ // 可选:自定义 WebSocket URL
143
+ let blockStreaming = true;
144
+ if (botId && token) {
145
+ const useCustomUrl = await prompter.confirm({
146
+ message: "Modify WebSocket URL? Select 'No' to use default: wss://ws.bajoseek.com(是否修改 WebSocket 地址?选否使用默认地址)",
147
+ initialValue: false,
148
+ });
149
+ if (useCustomUrl) {
150
+ wsUrl = String(
151
+ await prompter.text({
152
+ message: "Enter WebSocket URL(请输入 WebSocket 地址)",
153
+ placeholder: "e.g. ws://localhost:9093/ws/bot",
154
+ initialValue: resolvedAccount.config.wsUrl || undefined,
155
+ validate: (value: string) => {
156
+ if (!value?.trim()) return "WebSocket URL cannot be empty(地址不能为空)";
157
+ if (!value.startsWith("ws://") && !value.startsWith("wss://"))
158
+ return "URL must start with ws:// or wss://(地址必须以 ws:// 或 wss:// 开头)";
159
+ return undefined;
160
+ },
161
+ }),
162
+ ).trim();
163
+ }
164
+
165
+ // 可选:开启分块流式回复
166
+ blockStreaming = await prompter.confirm({
167
+ message: "Enable block streaming? Long replies will be sent in chunks for better responsiveness(是否开启分块流式回复?开启后长回复将分段发送,提升响应体验)",
168
+ initialValue: true,
169
+ });
170
+ }
171
+
172
+ // 应用配置
173
+ if (botId && token) {
174
+ next = applyConfig(next, accountId, resolvedAccount, { botId, token, wsUrl, blockStreaming });
175
+ }
176
+
177
+ return { cfg: next as any, accountId };
178
+ },
179
+
180
+ /**
181
+ * 禁用 Bajoseek 频道
182
+ */
183
+ disable: (cfg: unknown) => {
184
+ const config = cfg as OpenClawConfig;
185
+ return {
186
+ ...config,
187
+ channels: {
188
+ ...config.channels,
189
+ bajoseek: { ...(config.channels?.bajoseek as Record<string, unknown> || {}), enabled: false },
190
+ },
191
+ } as any;
192
+ },
193
+ };
194
+
195
+ /**
196
+ * 交互式提示输入 BotID 和 Token
197
+ */
198
+ async function promptCredentials(
199
+ prompter: WizardPrompter,
200
+ resolvedAccount: { botId: string; config: BajoseekAccountConfig },
201
+ ): Promise<{ botId: string; token: string }> {
202
+ const botId = String(
203
+ await prompter.text({
204
+ message: "Enter Bajoseek BotID(请输入 Bajoseek BotID)",
205
+ placeholder: "e.g. a1b2c3d4-e5f6-7890-abcd-ef1234567890",
206
+ initialValue: resolvedAccount.botId || undefined,
207
+ validate: (value: string) => (value?.trim() ? undefined : "BotID cannot be empty(BotID 不能为空)"),
208
+ }),
209
+ ).trim();
210
+
211
+ const token = String(
212
+ await prompter.text({
213
+ message: "Enter Bajoseek Token(请输入 Bajoseek Token)",
214
+ placeholder: "Your Bot Token",
215
+ validate: (value: string) => (value?.trim() ? undefined : "Token cannot be empty(Token 不能为空)"),
216
+ }),
217
+ ).trim();
218
+
219
+ return { botId, token };
220
+ }
221
+
222
+ /**
223
+ * 将凭证和 URL 应用到配置
224
+ */
225
+ function applyConfig(
226
+ cfg: OpenClawConfig,
227
+ accountId: string,
228
+ resolvedAccount: { config: BajoseekAccountConfig },
229
+ input: { botId?: string | null; token?: string | null; wsUrl?: string | null; blockStreaming?: boolean },
230
+ ): OpenClawConfig {
231
+ const allowFrom: string[] = resolvedAccount.config?.allowFrom ?? ["*"];
232
+ const blockStreaming = input.blockStreaming ?? true;
233
+
234
+ if (accountId === DEFAULT_ACCOUNT_ID) {
235
+ const existing = (cfg.channels?.bajoseek as Record<string, unknown>) || {};
236
+ return {
237
+ ...cfg,
238
+ channels: {
239
+ ...cfg.channels,
240
+ bajoseek: {
241
+ ...existing,
242
+ enabled: true,
243
+ blockStreaming,
244
+ allowFrom,
245
+ ...(input.botId ? { botId: input.botId } : {}),
246
+ ...(input.token ? { token: input.token } : {}),
247
+ ...(input.wsUrl ? { wsUrl: input.wsUrl } : {}),
248
+ },
249
+ },
250
+ };
251
+ }
252
+
253
+ const existingChannel = (cfg.channels?.bajoseek as BajoseekChannelConfig) || {};
254
+ const existingAccounts = existingChannel.accounts || {};
255
+ const existingAccount = existingAccounts[accountId] || {};
256
+
257
+ return {
258
+ ...cfg,
259
+ channels: {
260
+ ...cfg.channels,
261
+ bajoseek: {
262
+ ...(cfg.channels?.bajoseek as Record<string, unknown> || {}),
263
+ enabled: true,
264
+ accounts: {
265
+ ...existingAccounts,
266
+ [accountId]: {
267
+ ...existingAccount,
268
+ enabled: true,
269
+ blockStreaming,
270
+ allowFrom,
271
+ ...(input.botId ? { botId: input.botId } : {}),
272
+ ...(input.token ? { token: input.token } : {}),
273
+ ...(input.wsUrl ? { wsUrl: input.wsUrl } : {}),
274
+ },
275
+ },
276
+ },
277
+ },
278
+ };
279
+ }