@wu529778790/open-im 1.11.8-beta.2 → 1.11.8-beta.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.
Files changed (60) hide show
  1. package/dist/adapters/codebuddy-adapter.js +3 -17
  2. package/dist/adapters/codex-adapter.js +3 -16
  3. package/dist/adapters/opencode-adapter.d.ts +0 -5
  4. package/dist/adapters/opencode-adapter.js +10 -25
  5. package/dist/adapters/registry.js +10 -1
  6. package/dist/clawbot/client.js +72 -78
  7. package/dist/clawbot/qr-login.d.ts +1 -0
  8. package/dist/clawbot/qr-login.js +7 -0
  9. package/dist/codebuddy/cli-runner.d.ts +2 -22
  10. package/dist/codebuddy/cli-runner.js +9 -27
  11. package/dist/codex/cli-runner.d.ts +2 -22
  12. package/dist/codex/cli-runner.js +24 -75
  13. package/dist/commands/handler.d.ts +1 -0
  14. package/dist/commands/handler.js +34 -0
  15. package/dist/config/types.d.ts +23 -0
  16. package/dist/config-web-auth.d.ts +13 -0
  17. package/dist/config-web-auth.js +114 -0
  18. package/dist/config-web-browser.d.ts +4 -0
  19. package/dist/config-web-browser.js +48 -0
  20. package/dist/config-web-cors.d.ts +5 -0
  21. package/dist/config-web-cors.js +48 -0
  22. package/dist/config-web-health.d.ts +7 -0
  23. package/dist/config-web-health.js +65 -0
  24. package/dist/config-web-http.d.ts +3 -0
  25. package/dist/config-web-http.js +31 -0
  26. package/dist/config-web-page-i18n.d.ts +36 -2
  27. package/dist/config-web-page-i18n.js +36 -2
  28. package/dist/config-web-payload.d.ts +84 -0
  29. package/dist/config-web-payload.js +260 -0
  30. package/dist/config-web-probes.d.ts +2 -0
  31. package/dist/config-web-probes.js +271 -0
  32. package/dist/config-web.d.ts +3 -11
  33. package/dist/config-web.js +45 -815
  34. package/dist/config.js +22 -1
  35. package/dist/constants.d.ts +10 -0
  36. package/dist/constants.js +11 -0
  37. package/dist/opencode/cli-runner.d.ts +2 -22
  38. package/dist/opencode/cli-runner.js +47 -68
  39. package/dist/opencode/sdk-manager.d.ts +11 -0
  40. package/dist/opencode/sdk-manager.js +46 -0
  41. package/dist/opencode/sdk-runner.d.ts +7 -0
  42. package/dist/opencode/sdk-runner.js +225 -0
  43. package/dist/platform/handle-ai-request.js +18 -1
  44. package/dist/qq/client.js +45 -53
  45. package/dist/shared/ai-task.d.ts +15 -0
  46. package/dist/shared/ai-task.js +174 -2
  47. package/dist/shared/cli-runner-base.d.ts +39 -0
  48. package/dist/shared/cli-runner-base.js +108 -0
  49. package/dist/shared/connection-manager.d.ts +111 -0
  50. package/dist/shared/connection-manager.js +157 -0
  51. package/dist/shared/session-invalid-detector.d.ts +5 -0
  52. package/dist/shared/session-invalid-detector.js +20 -0
  53. package/dist/wework/client.js +79 -127
  54. package/dist/workbuddy/client.js +40 -74
  55. package/package.json +4 -1
  56. package/web/dist/assets/index-6e51Wtaa.css +1 -0
  57. package/web/dist/assets/index-Da9qKWA2.js +57 -0
  58. package/web/dist/index.html +2 -2
  59. package/web/dist/assets/index-XKVTb-0p.css +0 -1
  60. package/web/dist/assets/index-yDVGC_84.js +0 -57
@@ -6,8 +6,9 @@ import { existsSync, readFileSync } from 'node:fs';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { createInterface } from 'node:readline';
8
8
  import { createLogger } from '../logger.js';
9
- import { processEnvForNonClaudeCliChild } from '../config/file-io.js';
10
9
  import { killProcessTree, trackChild } from '../shared/process-kill.js';
10
+ import { isSessionInvalidMessage } from '../shared/session-invalid-detector.js';
11
+ import { createStderrBuffer, appendStderr, reconstructStderr, createFinalizeGate, isFinalizeReady, clearWallClockTimeout, buildBaseSpawnOptions, } from '../shared/cli-runner-base.js';
11
12
  const log = createLogger('CodexCli');
12
13
  const windowsCodexLaunchCache = new Map();
13
14
  const SUPPORTED_IMAGE_EXTENSIONS = new Set([
@@ -163,22 +164,18 @@ function resolveWindowsCodexLaunch(cliPath, args) {
163
164
  }
164
165
  export function runCodex(cliPath, prompt, sessionId, workDir, callbacks, options) {
165
166
  const args = buildCodexArgs(prompt, sessionId, workDir, options);
166
- const env = processEnvForNonClaudeCliChild();
167
+ const extraEnv = {};
167
168
  if (options?.chatId)
168
- env.CC_IM_CHAT_ID = options.chatId;
169
+ extraEnv.CC_IM_CHAT_ID = options.chatId;
169
170
  if (options?.hookPort)
170
- env.CC_IM_HOOK_PORT = String(options.hookPort);
171
+ extraEnv.CC_IM_HOOK_PORT = String(options.hookPort);
171
172
  if (options?.proxy) {
172
- env.HTTPS_PROXY = options.proxy;
173
- env.HTTP_PROXY = options.proxy;
174
- env.https_proxy = options.proxy;
175
- env.http_proxy = options.proxy;
176
- env.ALL_PROXY = options.proxy;
177
- env.all_proxy = options.proxy;
178
- }
179
- if (process.platform === 'win32') {
180
- env.LANG = env.LANG || 'C.UTF-8';
181
- env.LC_ALL = env.LC_ALL || 'C.UTF-8';
173
+ extraEnv.HTTPS_PROXY = options.proxy;
174
+ extraEnv.HTTP_PROXY = options.proxy;
175
+ extraEnv.https_proxy = options.proxy;
176
+ extraEnv.http_proxy = options.proxy;
177
+ extraEnv.ALL_PROXY = options.proxy;
178
+ extraEnv.all_proxy = options.proxy;
182
179
  }
183
180
  const argsForLog = args.join(' ');
184
181
  log.info(`Spawning Codex CLI: path=${cliPath}, cwd=${workDir}, session=${sessionId ?? 'new'}, args=${argsForLog}`);
@@ -200,14 +197,7 @@ export function runCodex(cliPath, prompt, sessionId, workDir, callbacks, options
200
197
  `chcp 65001>nul && ${formatWindowsCommandName(cliPath)} ${args.map(quoteForWindowsCmd).join(' ')}`,
201
198
  ]
202
199
  : args;
203
- const child = spawn(spawnCmd, spawnArgs, {
204
- cwd: workDir,
205
- // Unix 上放入独立进程组,使 abort/关停能用 process.kill(-pid) 杀掉整棵子树(含 MCP/shell 孙进程)
206
- detached: process.platform !== 'win32',
207
- stdio: ['pipe', 'pipe', 'pipe'],
208
- env,
209
- windowsHide: process.platform === 'win32',
210
- });
200
+ const child = spawn(spawnCmd, spawnArgs, buildBaseSpawnOptions(workDir, ['pipe', 'pipe', 'pipe'], extraEnv));
211
201
  trackChild(child);
212
202
  child.stdin?.write(prompt);
213
203
  child.stdin?.end();
@@ -217,27 +207,10 @@ export function runCodex(cliPath, prompt, sessionId, workDir, callbacks, options
217
207
  const toolStats = {};
218
208
  const startTime = Date.now();
219
209
  const rl = createInterface({ input: child.stdout });
220
- const MAX_STDERR_HEAD = 4 * 1024;
221
- const MAX_STDERR_TAIL = 6 * 1024;
222
- let stderrHead = '';
223
- let stderrTail = '';
224
- let stderrTotal = 0;
225
- let stderrHeadFull = false;
210
+ const stderrBuf = createStderrBuffer();
226
211
  child.stderr?.on('data', (chunk) => {
227
212
  const text = chunk.toString();
228
- stderrTotal += text.length;
229
- if (!stderrHeadFull) {
230
- const room = MAX_STDERR_HEAD - stderrHead.length;
231
- if (room > 0) {
232
- stderrHead += text.slice(0, room);
233
- if (stderrHead.length >= MAX_STDERR_HEAD)
234
- stderrHeadFull = true;
235
- }
236
- }
237
- stderrTail += text;
238
- if (stderrTail.length > MAX_STDERR_TAIL) {
239
- stderrTail = stderrTail.slice(-MAX_STDERR_TAIL);
240
- }
213
+ appendStderr(stderrBuf, text);
241
214
  log.debug(`[stderr] ${text.trimEnd()}`);
242
215
  });
243
216
  rl.on('line', (line) => {
@@ -329,38 +302,18 @@ export function runCodex(cliPath, prompt, sessionId, workDir, callbacks, options
329
302
  }
330
303
  });
331
304
  let exitCode = null;
332
- let rlClosed = false;
333
- let childClosed = false;
305
+ const gate = createFinalizeGate();
334
306
  let cliTimeoutHandle = null;
335
307
  const finalize = () => {
336
- if (cliTimeoutHandle) {
337
- clearTimeout(cliTimeoutHandle);
338
- cliTimeoutHandle = null;
339
- }
340
- if (!rlClosed || !childClosed)
308
+ clearWallClockTimeout(cliTimeoutHandle);
309
+ cliTimeoutHandle = null;
310
+ if (!isFinalizeReady(gate))
341
311
  return;
342
312
  if (completed)
343
313
  return;
344
314
  if (exitCode !== null && exitCode !== 0) {
345
- let errMsg = '';
346
- if (stderrTotal > 0) {
347
- if (!stderrHeadFull) {
348
- errMsg = stderrHead;
349
- }
350
- else if (stderrTotal <= MAX_STDERR_HEAD + MAX_STDERR_TAIL) {
351
- errMsg = stderrHead + stderrTail.slice(stderrTail.length - (stderrTotal - MAX_STDERR_HEAD));
352
- }
353
- else {
354
- errMsg =
355
- stderrHead +
356
- `\n\n... (omitted ${stderrTotal - MAX_STDERR_HEAD - MAX_STDERR_TAIL} bytes) ...\n\n` +
357
- stderrTail;
358
- }
359
- }
360
- if (sessionId &&
361
- (errMsg.includes('No session found') ||
362
- errMsg.includes('No conversation found') ||
363
- errMsg.includes('Unable to find session'))) {
315
+ const errMsg = reconstructStderr(stderrBuf);
316
+ if (sessionId && isSessionInvalidMessage(errMsg)) {
364
317
  callbacks.onSessionInvalid?.();
365
318
  }
366
319
  callbacks.onError(errMsg || `Codex CLI exited with code ${exitCode}`);
@@ -379,11 +332,11 @@ export function runCodex(cliPath, prompt, sessionId, workDir, callbacks, options
379
332
  child.on('close', (code) => {
380
333
  log.info(`Codex CLI closed: exitCode=${code}, pid=${child.pid}`);
381
334
  exitCode = code;
382
- childClosed = true;
335
+ gate.childClosed = true;
383
336
  finalize();
384
337
  });
385
338
  rl.on('close', () => {
386
- rlClosed = true;
339
+ gate.rlClosed = true;
387
340
  finalize();
388
341
  });
389
342
  child.on('error', (err) => {
@@ -393,7 +346,7 @@ export function runCodex(cliPath, prompt, sessionId, workDir, callbacks, options
393
346
  completed = true;
394
347
  callbacks.onError(`Failed to start Codex CLI: ${err.message}`);
395
348
  }
396
- childClosed = true;
349
+ gate.childClosed = true;
397
350
  finalize();
398
351
  });
399
352
  // 墙钟超时:防止 CLI 挂死(网络卡住、工具死循环等)永久占用用户队列槽
@@ -401,7 +354,6 @@ export function runCodex(cliPath, prompt, sessionId, workDir, callbacks, options
401
354
  cliTimeoutHandle = setTimeout(() => {
402
355
  if (completed)
403
356
  return;
404
- log.warn(`Codex CLI 超过 ${cliTimeoutMs}ms,强制终止 (pid=${child.pid})`);
405
357
  completed = true;
406
358
  rl.close();
407
359
  killProcessTree(child);
@@ -413,10 +365,7 @@ export function runCodex(cliPath, prompt, sessionId, workDir, callbacks, options
413
365
  if (completed)
414
366
  return;
415
367
  completed = true;
416
- if (cliTimeoutHandle) {
417
- clearTimeout(cliTimeoutHandle);
418
- cliTimeoutHandle = null;
419
- }
368
+ clearWallClockTimeout(cliTimeoutHandle);
420
369
  rl.close();
421
370
  killProcessTree(child);
422
371
  },
@@ -23,6 +23,7 @@ export declare class CommandHandler {
23
23
  dispatch(text: string, chatId: string, userId: string, platform: Platform, handleClaudeRequest: ClaudeRequestHandler,
24
24
  /** 若提供,本条消息的斜杠命令回复走此 sender(须与 handleTextFlow 的 sendTextReply 一致,如带 msgId)。 */
25
25
  senderOverride?: MessageSender): Promise<boolean>;
26
+ private handleAutopilotStatus;
26
27
  private handleHelp;
27
28
  private handlePlugins;
28
29
  private handleSessions;
@@ -1,5 +1,6 @@
1
1
  import { resolvePlatformAiCommand } from '../config.js';
2
2
  import { escapePathForMarkdown } from '../shared/utils.js';
3
+ import { getAutopilotPendingStatus } from '../shared/ai-task.js';
3
4
  import { TERMINAL_ONLY_COMMANDS } from '../constants.js';
4
5
  import { createLogger } from '../logger.js';
5
6
  import { ClaudeSDKAdapter } from '../adapters/claude-sdk-adapter.js';
@@ -103,6 +104,8 @@ export class CommandHandler {
103
104
  return this.handlePwd(chatId, userId);
104
105
  if (t === '/status')
105
106
  return this.handleStatus(chatId, userId, platform);
107
+ if (t === '/autopilot')
108
+ return this.handleAutopilotStatus(chatId, userId);
106
109
  // 快捷命令 — 直接发送预设 prompt 给 AI
107
110
  if (t === '/git commit')
108
111
  return this.handleQuickCommand(chatId, userId, 'git commit -m "AI generated commit"', platform);
@@ -133,6 +136,36 @@ export class CommandHandler {
133
136
  }
134
137
  return runBody();
135
138
  }
139
+ async handleAutopilotStatus(chatId, userId) {
140
+ const ap = this.deps.config.autopilot;
141
+ const lines = [
142
+ '🤖 限流自动恢复 (Autopilot)',
143
+ '',
144
+ `状态: ${ap.enabled ? '✅ 已启用' : '❌ 已禁用'}`,
145
+ `最大重试: ${ap.maxRetries} 次`,
146
+ `默认等待: ${ap.defaultIntervalHours} 小时`,
147
+ `短延迟: ${ap.shortRetrySeconds} 秒`,
148
+ `恢复提示: "${ap.autoResumePrompt}"`,
149
+ ];
150
+ const pending = getAutopilotPendingStatus(userId);
151
+ if (pending) {
152
+ const remaining = Math.max(pending.retryAt.getTime() - Date.now(), 0);
153
+ const hours = Math.floor(remaining / 3600000);
154
+ const minutes = Math.floor((remaining % 3600000) / 60000);
155
+ lines.push('');
156
+ lines.push('⏳ 当前等待中:');
157
+ lines.push(` 类型: ${pending.type}`);
158
+ lines.push(` 恢复时间: ${pending.retryAt.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`);
159
+ lines.push(` 剩余: ${hours > 0 ? `${hours}小时` : ''}${minutes}分钟`);
160
+ lines.push(` 重试次数: ${pending.retryCount}/${ap.maxRetries}`);
161
+ }
162
+ else {
163
+ lines.push('');
164
+ lines.push('当前无等待中的限流恢复任务。');
165
+ }
166
+ await this.replySender().sendTextReply(chatId, lines.join('\n'));
167
+ return true;
168
+ }
136
169
  async handleHelp(chatId) {
137
170
  const help = [
138
171
  '📋 可用命令:',
@@ -151,6 +184,7 @@ export class CommandHandler {
151
184
  '/status - 显示状态',
152
185
  '/cd <路径> - 切换工作目录',
153
186
  '/pwd - 当前工作目录',
187
+ '/autopilot - 查看限流自动恢复状态',
154
188
  '',
155
189
  '⚡ 快捷命令:',
156
190
  '/git commit - 提交代码',
@@ -26,6 +26,8 @@ export interface Config {
26
26
  codexCliPath: string;
27
27
  codebuddyCliPath: string;
28
28
  opencodeCliPath: string;
29
+ /** OpenCode SDK 使用的模型(格式: providerID/modelID,如 zhipuai-coding-plan/glm-5.1) */
30
+ opencodeModel?: string;
29
31
  /** Claude 访问 API 的代理(如 http://127.0.0.1:7890) */
30
32
  claudeProxy?: string;
31
33
  /** Codex 访问 chatgpt.com 的代理(如 http://127.0.0.1:7890) */
@@ -51,6 +53,17 @@ export interface Config {
51
53
  /** 可选 Bearer,与采集端一致 */
52
54
  token?: string;
53
55
  };
56
+ /**
57
+ * 限流自动恢复(Autopilot)。
58
+ * 当 AI 工具遇到限流错误时,自动等待并在恢复后重试。
59
+ */
60
+ autopilot: {
61
+ enabled: boolean;
62
+ maxRetries: number;
63
+ defaultIntervalHours: number;
64
+ shortRetrySeconds: number;
65
+ autoResumePrompt: string;
66
+ };
54
67
  platforms: {
55
68
  telegram?: {
56
69
  enabled: boolean;
@@ -174,6 +187,14 @@ export interface FileToolClaude {
174
187
  proxy?: string;
175
188
  /** Claude API 配置(优先级:环境变量 > tools.claude.env > ~/.claude/settings.json) */
176
189
  env?: Record<string, string>;
190
+ /** 限流自动恢复配置 */
191
+ autopilot?: {
192
+ enabled?: boolean;
193
+ maxRetries?: number;
194
+ defaultIntervalHours?: number;
195
+ shortRetrySeconds?: number;
196
+ autoResumePrompt?: string;
197
+ };
177
198
  }
178
199
  export interface FileToolCodex {
179
200
  cliPath?: string;
@@ -186,6 +207,8 @@ export interface FileToolCodeBuddy {
186
207
  }
187
208
  export interface FileToolOpenCode {
188
209
  cliPath?: string;
210
+ /** 模型(格式: providerID/modelID) */
211
+ model?: string;
189
212
  }
190
213
  export interface FileConfig {
191
214
  telegramBotToken?: string;
@@ -0,0 +1,13 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ export interface LoginTokenInfo {
3
+ expiresAt: number;
4
+ }
5
+ export declare function getWebConfigHost(): string;
6
+ /** 设为 true 时,非本机绑定的 Web 配置服务跳过登录 Cookie 校验(仅适用于受信网络;生产建议配合 HTTPS 反向代理) */
7
+ export declare function allowRemoteApiWithoutAuth(): boolean;
8
+ export declare function consumeLoginToken(loginToken: string): LoginTokenInfo | undefined;
9
+ export declare function createLoginToken(ttlMs: number): string;
10
+ export declare function createSession(request: IncomingMessage, ttlMs: number): string;
11
+ export declare function isSessionValid(request: IncomingMessage): boolean;
12
+ export declare function buildSessionCookie(sessionId: string, ttlMs: number): string;
13
+ export declare function generateLoginUrl(host: string, port: number, loginTtlMs: number): string;
@@ -0,0 +1,114 @@
1
+ import { randomBytes } from "node:crypto";
2
+ const pendingLogins = new Map();
3
+ const activeSessions = new Map();
4
+ export function getWebConfigHost() {
5
+ const envHost = process.env.OPEN_IM_WEB_HOST?.trim();
6
+ if (envHost)
7
+ return envHost;
8
+ return "127.0.0.1";
9
+ }
10
+ /** 设为 true 时,非本机绑定的 Web 配置服务跳过登录 Cookie 校验(仅适用于受信网络;生产建议配合 HTTPS 反向代理) */
11
+ export function allowRemoteApiWithoutAuth() {
12
+ return process.env.OPEN_IM_ALLOW_REMOTE_API === "true";
13
+ }
14
+ function generateRandomToken(bytes = 32) {
15
+ return randomBytes(bytes).toString("base64url");
16
+ }
17
+ function cleanupExpiredAuth(now) {
18
+ for (const [token, info] of pendingLogins) {
19
+ if (info.expiresAt <= now)
20
+ pendingLogins.delete(token);
21
+ }
22
+ for (const [sessionId, info] of activeSessions) {
23
+ if (info.expiresAt <= now)
24
+ activeSessions.delete(sessionId);
25
+ }
26
+ }
27
+ export function consumeLoginToken(loginToken) {
28
+ const info = pendingLogins.get(loginToken);
29
+ if (!info)
30
+ return undefined;
31
+ pendingLogins.delete(loginToken);
32
+ const now = Date.now();
33
+ if (info.expiresAt <= now)
34
+ return undefined;
35
+ return info;
36
+ }
37
+ export function createLoginToken(ttlMs) {
38
+ const now = Date.now();
39
+ cleanupExpiredAuth(now);
40
+ const token = generateRandomToken(32);
41
+ pendingLogins.set(token, { expiresAt: now + ttlMs });
42
+ return token;
43
+ }
44
+ export function createSession(request, ttlMs) {
45
+ const now = Date.now();
46
+ cleanupExpiredAuth(now);
47
+ const sessionId = generateRandomToken(32);
48
+ const remoteAddr = request.socket.remoteAddress;
49
+ const userAgent = typeof request.headers["user-agent"] === "string" ? request.headers["user-agent"] : undefined;
50
+ activeSessions.set(sessionId, {
51
+ expiresAt: now + ttlMs,
52
+ remoteAddr,
53
+ userAgent,
54
+ });
55
+ return sessionId;
56
+ }
57
+ function parseCookies(request) {
58
+ const header = request.headers.cookie;
59
+ if (!header)
60
+ return {};
61
+ const cookies = {};
62
+ const parts = header.split(";");
63
+ for (const part of parts) {
64
+ const [rawKey, ...rest] = part.split("=");
65
+ const key = rawKey.trim();
66
+ if (!key)
67
+ continue;
68
+ const value = rest.join("=").trim();
69
+ cookies[key] = decodeURIComponent(value);
70
+ }
71
+ return cookies;
72
+ }
73
+ function getSessionIdFromRequest(request) {
74
+ const cookies = parseCookies(request);
75
+ const sessionId = cookies.openim_session;
76
+ return sessionId && typeof sessionId === "string" && sessionId.length > 0 ? sessionId : null;
77
+ }
78
+ export function isSessionValid(request) {
79
+ const sessionId = getSessionIdFromRequest(request);
80
+ if (!sessionId)
81
+ return false;
82
+ const info = activeSessions.get(sessionId);
83
+ if (!info)
84
+ return false;
85
+ const now = Date.now();
86
+ if (info.expiresAt <= now) {
87
+ activeSessions.delete(sessionId);
88
+ return false;
89
+ }
90
+ // Optional: tie session to basic client fingerprint (remote address)
91
+ const remoteAddr = request.socket.remoteAddress;
92
+ if (info.remoteAddr && remoteAddr && remoteAddr !== info.remoteAddr) {
93
+ return false;
94
+ }
95
+ return true;
96
+ }
97
+ export function buildSessionCookie(sessionId, ttlMs) {
98
+ const maxAgeSec = Math.floor(ttlMs / 1000);
99
+ const parts = [
100
+ `openim_session=${encodeURIComponent(sessionId)}`,
101
+ "Path=/",
102
+ "HttpOnly",
103
+ "SameSite=Lax",
104
+ `Max-Age=${maxAgeSec}`,
105
+ ];
106
+ // 不设置 Secure,方便本地 http 使用;如果放在 https 反代后,可以在代理层加 Secure
107
+ return parts.join("; ");
108
+ }
109
+ export function generateLoginUrl(host, port, loginTtlMs) {
110
+ const loginToken = createLoginToken(loginTtlMs);
111
+ const displayHost = host === "0.0.0.0" ? "127.0.0.1" : host;
112
+ const baseUrl = `http://${displayHost}:${port}`;
113
+ return `${baseUrl}/?login_token=${encodeURIComponent(loginToken)}`;
114
+ }
@@ -0,0 +1,4 @@
1
+ export declare function openBrowser(url: string): void;
2
+ export declare function getWebConfigPort(): number;
3
+ export declare function getWebConfigUrl(): string;
4
+ export declare function openWebConfigUrl(): void;
@@ -0,0 +1,48 @@
1
+ import { spawn } from "node:child_process";
2
+ import { WEB_CONFIG_PORT, getPublicWebDashboardUrl } from "./constants.js";
3
+ import { createLogger } from "./logger.js";
4
+ const log = createLogger("ConfigWeb");
5
+ export function openBrowser(url) {
6
+ // 显式关闭自动打开浏览器(服务器环境推荐设置)
7
+ if (process.env.OPEN_IM_NO_BROWSER === "1") {
8
+ return;
9
+ }
10
+ // 在无 TTY 且无图形环境(常见于服务器)时直接跳过,避免无意义的 xdg-open 调用
11
+ if (!process.stdout.isTTY && !process.env.DISPLAY) {
12
+ log.info(`Skipping browser launch for URL ${url} (no TTY/DISPLAY detected).`);
13
+ return;
14
+ }
15
+ const safeSpawn = (command, args) => {
16
+ try {
17
+ const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: process.platform === "win32" });
18
+ // 防止 ENOENT 之类的错误变成未捕获异常
19
+ child.on("error", (error) => {
20
+ log.warn(`Failed to launch browser command "${command}": ${error.code ?? error.message}`);
21
+ });
22
+ child.unref();
23
+ }
24
+ catch (error) {
25
+ log.warn(`Failed to spawn browser command "${command}": ${error instanceof Error ? error.message : String(error)}`);
26
+ }
27
+ };
28
+ if (process.platform === "win32") {
29
+ safeSpawn("cmd", ["/c", "start", "", url]);
30
+ return;
31
+ }
32
+ if (process.platform === "darwin") {
33
+ safeSpawn("open", [url]);
34
+ return;
35
+ }
36
+ // linux / 其他 UNIX 平台:优先尝试 xdg-open,失败时仅记录日志,不抛出
37
+ safeSpawn("xdg-open", [url]);
38
+ }
39
+ export function getWebConfigPort() {
40
+ const fromEnv = process.env.OPEN_IM_WEB_PORT ? parseInt(process.env.OPEN_IM_WEB_PORT, 10) : NaN;
41
+ return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : WEB_CONFIG_PORT;
42
+ }
43
+ export function getWebConfigUrl() {
44
+ return `http://127.0.0.1:${getWebConfigPort()}`;
45
+ }
46
+ export function openWebConfigUrl() {
47
+ openBrowser(getPublicWebDashboardUrl());
48
+ }
@@ -0,0 +1,5 @@
1
+ import type { IncomingMessage, OutgoingHttpHeaders } from "node:http";
2
+ export declare function splitCsv(value: string | undefined): string[];
3
+ /** 有 Origin 时返回 CORS 响应头;无 Origin(如本地 file:// 或同源内置页)不返回,行为与原来一致 */
4
+ export declare function corsHeadersFor(request: IncomingMessage): Record<string, string> | undefined;
5
+ export declare function mergeCors(request: IncomingMessage, headers: OutgoingHttpHeaders): OutgoingHttpHeaders;
@@ -0,0 +1,48 @@
1
+ import { URL } from "node:url";
2
+ import { getPublicWebDashboardUrl } from "./constants.js";
3
+ export function splitCsv(value) {
4
+ if (!value)
5
+ return [];
6
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
7
+ }
8
+ /** 是否允许该浏览器 Origin(与 getPublicWebDashboardUrl() 同源时始终允许,便于反代 / 自定义 OPEN_IM_PUBLIC_WEB_URL) */
9
+ function isCorsOriginAllowed(origin) {
10
+ const publicWeb = getPublicWebDashboardUrl();
11
+ try {
12
+ if (origin === new URL(publicWeb).origin)
13
+ return true;
14
+ }
15
+ catch {
16
+ if (origin === publicWeb)
17
+ return true;
18
+ }
19
+ const allowlist = splitCsv(process.env.OPEN_IM_CORS_ORIGINS);
20
+ if (allowlist.length === 0)
21
+ return true;
22
+ return allowlist.includes(origin);
23
+ }
24
+ /** 有 Origin 时返回 CORS 响应头;无 Origin(如本地 file:// 或同源内置页)不返回,行为与原来一致 */
25
+ export function corsHeadersFor(request) {
26
+ const originRaw = request.headers.origin;
27
+ if (!originRaw || typeof originRaw !== "string")
28
+ return undefined;
29
+ if (!isCorsOriginAllowed(originRaw))
30
+ return undefined;
31
+ const requestedHeaders = request.headers["access-control-request-headers"];
32
+ const allowHeaders = typeof requestedHeaders === "string" && requestedHeaders.trim()
33
+ ? requestedHeaders
34
+ : "Content-Type, Authorization";
35
+ return {
36
+ "Access-Control-Allow-Origin": originRaw,
37
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
38
+ "Access-Control-Allow-Headers": allowHeaders,
39
+ "Access-Control-Allow-Credentials": "true",
40
+ "Access-Control-Max-Age": "86400",
41
+ };
42
+ }
43
+ export function mergeCors(request, headers) {
44
+ const cors = corsHeadersFor(request);
45
+ if (!cors)
46
+ return headers;
47
+ return { ...headers, ...cors };
48
+ }
@@ -0,0 +1,7 @@
1
+ import type { FileConfig } from "./config.js";
2
+ export declare function getHealthPlatformSnapshot(file: FileConfig, env?: NodeJS.ProcessEnv): Record<string, {
3
+ configured: boolean;
4
+ enabled: boolean;
5
+ healthy: boolean;
6
+ message?: string;
7
+ }>;
@@ -0,0 +1,65 @@
1
+ export function getHealthPlatformSnapshot(file, env = process.env) {
2
+ const fileTelegram = file.platforms?.telegram;
3
+ const fileFeishu = file.platforms?.feishu;
4
+ const fileQQ = file.platforms?.qq;
5
+ const fileWework = file.platforms?.wework;
6
+ const fileDingtalk = file.platforms?.dingtalk;
7
+ const fileWorkbuddy = file.platforms?.workbuddy;
8
+ const fileClawbot = file.platforms?.clawbot;
9
+ const telegramBotToken = env.TELEGRAM_BOT_TOKEN ?? fileTelegram?.botToken ?? file.telegramBotToken;
10
+ const feishuAppId = env.FEISHU_APP_ID ?? fileFeishu?.appId ?? file.feishuAppId;
11
+ const feishuAppSecret = env.FEISHU_APP_SECRET ?? fileFeishu?.appSecret ?? file.feishuAppSecret;
12
+ const qqAppId = env.QQ_BOT_APPID ?? env.QQ_APP_ID ?? fileQQ?.appId;
13
+ const qqSecret = env.QQ_BOT_SECRET ?? env.QQ_SECRET ?? fileQQ?.secret;
14
+ const weworkCorpId = env.WEWORK_CORP_ID ?? fileWework?.corpId;
15
+ const weworkSecret = env.WEWORK_SECRET ?? fileWework?.secret;
16
+ const dingtalkClientId = env.DINGTALK_CLIENT_ID ?? fileDingtalk?.clientId;
17
+ const dingtalkClientSecret = env.DINGTALK_CLIENT_SECRET ?? fileDingtalk?.clientSecret;
18
+ const workbuddyAccessToken = fileWorkbuddy?.accessToken;
19
+ const workbuddyRefreshToken = fileWorkbuddy?.refreshToken;
20
+ const workbuddyUserId = fileWorkbuddy?.userId;
21
+ return {
22
+ telegram: {
23
+ configured: !!telegramBotToken,
24
+ enabled: !!telegramBotToken && fileTelegram?.enabled !== false,
25
+ healthy: !!telegramBotToken,
26
+ message: telegramBotToken ? "Token configured" : "Token not configured",
27
+ },
28
+ feishu: {
29
+ configured: !!(feishuAppId && feishuAppSecret),
30
+ enabled: !!(feishuAppId && feishuAppSecret) && fileFeishu?.enabled !== false,
31
+ healthy: !!(feishuAppId && feishuAppSecret),
32
+ message: feishuAppId && feishuAppSecret ? "App ID and Secret configured" : "Missing credentials",
33
+ },
34
+ qq: {
35
+ configured: !!(qqAppId && qqSecret),
36
+ enabled: !!(qqAppId && qqSecret) && fileQQ?.enabled !== false,
37
+ healthy: !!(qqAppId && qqSecret),
38
+ message: qqAppId && qqSecret ? "App ID and Secret configured" : "Missing credentials",
39
+ },
40
+ wework: {
41
+ configured: !!(weworkCorpId && weworkSecret),
42
+ enabled: !!(weworkCorpId && weworkSecret) && fileWework?.enabled !== false,
43
+ healthy: !!(weworkCorpId && weworkSecret),
44
+ message: weworkCorpId && weworkSecret ? "Corp ID and Secret configured" : "Missing credentials",
45
+ },
46
+ dingtalk: {
47
+ configured: !!(dingtalkClientId && dingtalkClientSecret),
48
+ enabled: !!(dingtalkClientId && dingtalkClientSecret) && fileDingtalk?.enabled !== false,
49
+ healthy: !!(dingtalkClientId && dingtalkClientSecret),
50
+ message: dingtalkClientId && dingtalkClientSecret ? "Client ID and Secret configured" : "Missing credentials",
51
+ },
52
+ workbuddy: {
53
+ configured: !!(workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId),
54
+ enabled: !!(workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId) && fileWorkbuddy?.enabled !== false,
55
+ healthy: !!(workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId),
56
+ message: workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId ? "OAuth credentials configured" : "Missing credentials",
57
+ },
58
+ clawbot: {
59
+ configured: !!fileClawbot?.apiToken,
60
+ enabled: !!fileClawbot?.apiToken && fileClawbot?.enabled !== false,
61
+ healthy: !!fileClawbot?.apiToken,
62
+ message: fileClawbot?.apiToken ? "API Token configured" : "Missing API Token",
63
+ },
64
+ };
65
+ }
@@ -0,0 +1,3 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ export declare function readJson<T>(request: IncomingMessage): Promise<T>;
3
+ export declare function jsonResponse(response: ServerResponse, statusCode: number, body: unknown, request: IncomingMessage): void;
@@ -0,0 +1,31 @@
1
+ import { mergeCors } from "./config-web-cors.js";
2
+ const MAX_REQUEST_BODY_BYTES = 1 * 1024 * 1024; // 1 MB
3
+ export function readJson(request) {
4
+ return new Promise((resolve, reject) => {
5
+ const chunks = [];
6
+ let totalBytes = 0;
7
+ request.on("data", (chunk) => {
8
+ totalBytes += chunk.length;
9
+ if (totalBytes > MAX_REQUEST_BODY_BYTES) {
10
+ reject(new Error("Request body too large (max 1 MB)"));
11
+ request.destroy();
12
+ return;
13
+ }
14
+ chunks.push(Buffer.from(chunk));
15
+ });
16
+ request.on("end", () => {
17
+ try {
18
+ const raw = Buffer.concat(chunks).toString("utf-8");
19
+ resolve((raw ? JSON.parse(raw) : {}));
20
+ }
21
+ catch (error) {
22
+ reject(error);
23
+ }
24
+ });
25
+ request.on("error", reject);
26
+ });
27
+ }
28
+ export function jsonResponse(response, statusCode, body, request) {
29
+ response.writeHead(statusCode, mergeCors(request, { "content-type": "application/json; charset=utf-8" }));
30
+ response.end(JSON.stringify(body));
31
+ }