@wu529778790/open-im 1.11.10 → 1.11.11-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -74,6 +74,8 @@ open-im start
74
74
  | `/context` | 查看上下文用量 |
75
75
  | `/plugins` | 查看已安装插件 |
76
76
  | `/status` | 显示状态信息 |
77
+ | `/a` | 查看当前 AI 工具及可选列表 |
78
+ | `/a <工具名>` | 切换当前平台的 AI 工具(claude/codex/codebuddy/opencode) |
77
79
  | `/cd <路径>` / `/pwd` | 切换/查看工作目录 |
78
80
 
79
81
  ### 快捷命令
@@ -90,29 +92,26 @@ open-im start
90
92
 
91
93
  ### 权限与确认
92
94
 
93
- Claude 使用 Agent SDK 集成,open-im 默认不替 Claude 做额外的允许/拒绝协议。Claude 需要用户确认时,会按它自己的原生交互语义发问;你在 IM 里回复选项、确认或补充说明即可继续同一个会话。
95
+ ClaudeAgent SDK)、Codex、CodeBuddy、OpenCode 默认都进入**自动执行模式**:AI 工具需要权限时直接放行,不会在 IM 里卡住等待确认。
94
96
 
95
- 如果你希望 Claude 也进入自动执行模式,可以在 Web 控制台的 **AI 工具配置 → Claude Code 跳过 Claude 权限确认** 打开,或在配置文件里设置:
97
+ > ⚠️ open-im 目前没有把 Claude SDK 的权限确认弹窗转发到 IM。一旦开启原生确认流程,请求会因无人应答而卡死,所以默认全部跳过。未来接入权限 hook 后会提供细粒度控制。
98
+
99
+ 如果需要恢复 Claude 的原生确认流程,可在配置文件里设置:
96
100
 
97
101
  ```json
98
102
  {
99
103
  "tools": {
100
104
  "claude": {
101
- "skipPermissions": true
105
+ "skipPermissions": false
102
106
  }
103
107
  }
104
108
  }
105
109
  ```
106
110
 
107
- 也可以用环境变量临时覆盖:
108
-
109
111
  ```bash
110
- OPEN_IM_SKIP_PERMISSIONS=true open-im start # 跳过权限确认
111
- OPEN_IM_SKIP_PERMISSIONS=false open-im start # 使用 Claude 原生确认
112
+ OPEN_IM_SKIP_PERMISSIONS=false open-im start # 恢复 Claude 原生确认
112
113
  ```
113
114
 
114
- Codex、CodeBuddy、OpenCode 仍保持原来的自动执行默认行为。
115
-
116
115
  ## 会话接力
117
116
 
118
117
  open-im 和 Claude Code CLI 共享 session 存储。同一目录下,手机和电脑无缝切换:
@@ -136,11 +135,14 @@ claude -c # 接上手机端的对话
136
135
 
137
136
  - 配置所有平台凭证
138
137
  - 启动/停止桥接服务
139
- - 编辑配置文件
138
+ - 编辑配置文件(open-im / Claude / Codex / CodeBuddy / OpenCode,含 Codex 的 `auth.json` 与 `config.toml`)
139
+ - API 保活设置(定期发送请求延续 5 小时滚动配额,避免 token 浪费)
140
140
  - 首次运行自动弹出设置向导
141
141
  - 平台卡片支持展开/折叠
142
142
  - 一键保存并启动
143
143
 
144
+ > 💡 通过 IM 命令 `/a <工具名>` 切换 AI 工具,或 Web 控制台修改平台配置后,**下一条消息立即生效,无需重启**桥接服务。
145
+
144
146
  局域网访问:`export OPEN_IM_WEB_HOST=0.0.0.0`
145
147
 
146
148
  ## CLI 命令
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { main, needsSetup, runInteractiveSetup } from "./index.js";
2
+ import { main, needsSetup } from "./index.js";
3
3
  import { loadConfig } from "./config.js";
4
4
  import { checkAndUpdate } from "./check-update.js";
5
5
  import { getPublicWebDashboardUrl } from "./constants.js";
@@ -20,23 +20,6 @@ function logWebDashboardAndApi() {
20
20
  }
21
21
  }
22
22
  async function ensureConfigured(mode) {
23
- if (mode === "init") {
24
- if (!process.stdin.isTTY) {
25
- console.error("CLI setup requires an interactive terminal.");
26
- return false;
27
- }
28
- const saved = await runInteractiveSetup();
29
- if (!saved)
30
- return false;
31
- try {
32
- loadConfig();
33
- return true;
34
- }
35
- catch (error) {
36
- console.error(error instanceof Error ? error.message : String(error));
37
- return false;
38
- }
39
- }
40
23
  if (!needsSetup()) {
41
24
  try {
42
25
  loadConfig();
@@ -123,18 +106,6 @@ async function cmdRestart() {
123
106
  logWebDashboardAndApi();
124
107
  process.exit(0);
125
108
  }
126
- async function cmdInit() {
127
- console.log("\nopen-im CLI setup\n");
128
- const saved = await ensureConfigured("init");
129
- if (!saved) {
130
- console.log("\nConfiguration was not completed.");
131
- process.exit(1);
132
- }
133
- console.log("\nConfiguration saved.");
134
- console.log("\nYou can start the app with:");
135
- console.log(" open-im start");
136
- console.log(" open-im dev");
137
- }
138
109
  async function cmdDev() {
139
110
  if (!(await ensureConfigured("dev"))) {
140
111
  console.log("Configuration was not completed.");
@@ -165,7 +136,6 @@ Commands:
165
136
  start Run the full app in the background and serve the dashboard
166
137
  stop Stop the full app
167
138
  restart Restart the full app in the background
168
- init Run CLI setup
169
139
  dev Run in the foreground for debugging
170
140
  dashboard Open the web dashboard (keeps running until Ctrl+C)
171
141
 
@@ -185,7 +155,6 @@ const commands = {
185
155
  start: cmdStart,
186
156
  stop: cmdStop,
187
157
  restart: cmdRestart,
188
- init: cmdInit,
189
158
  dev: cmdDev,
190
159
  dashboard: cmdDashboard,
191
160
  };
@@ -9,5 +9,5 @@ export declare function consumeLoginToken(loginToken: string): LoginTokenInfo |
9
9
  export declare function createLoginToken(ttlMs: number): string;
10
10
  export declare function createSession(request: IncomingMessage, ttlMs: number): string;
11
11
  export declare function isSessionValid(request: IncomingMessage): boolean;
12
- export declare function buildSessionCookie(sessionId: string, ttlMs: number): string;
12
+ export declare function buildSessionCookie(sessionId: string, ttlMs: number, isHttps?: boolean): string;
13
13
  export declare function generateLoginUrl(host: string, port: number, loginTtlMs: number): string;
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import { parse, serialize } from "cookie";
2
3
  const pendingLogins = new Map();
3
4
  const activeSessions = new Map();
4
5
  export function getWebConfigHost() {
@@ -58,17 +59,16 @@ function parseCookies(request) {
58
59
  const header = request.headers.cookie;
59
60
  if (!header)
60
61
  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);
62
+ // 使用成熟的 cookie 库,更安全可靠
63
+ const cookies = parse(header);
64
+ // 转换为 Record<string, string>,过滤掉 undefined
65
+ const result = {};
66
+ for (const [key, value] of Object.entries(cookies)) {
67
+ if (value !== undefined) {
68
+ result[key] = value;
69
+ }
70
70
  }
71
- return cookies;
71
+ return result;
72
72
  }
73
73
  function getSessionIdFromRequest(request) {
74
74
  const cookies = parseCookies(request);
@@ -94,17 +94,21 @@ export function isSessionValid(request) {
94
94
  }
95
95
  return true;
96
96
  }
97
- export function buildSessionCookie(sessionId, ttlMs) {
97
+ export function buildSessionCookie(sessionId, ttlMs, isHttps = false) {
98
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("; ");
99
+ const options = {
100
+ path: "/",
101
+ httpOnly: true,
102
+ sameSite: "lax",
103
+ maxAge: maxAgeSec,
104
+ };
105
+ // 根据请求协议动态设置 Secure 标志
106
+ // 在生产环境(HTTPS 反代后)应该设置为 true
107
+ if (isHttps || process.env.NODE_ENV === "production") {
108
+ options.secure = true;
109
+ }
110
+ // 使用成熟的 cookie 库的 serialize 函数
111
+ return serialize("openim_session", sessionId, options);
108
112
  }
109
113
  export function generateLoginUrl(host, port, loginTtlMs) {
110
114
  const loginToken = createLoginToken(loginTtlMs);
@@ -68,7 +68,9 @@ export async function startWebConfigServer(options) {
68
68
  // 有效的一次性登录 token:创建会话,设置 Cookie,并重定向到去掉 login_token 的 URL
69
69
  const sessionTtlMs = 24 * 60 * 60 * 1000; // 24 小时
70
70
  const sessionId = createSession(request, sessionTtlMs);
71
- const cookie = buildSessionCookie(sessionId, sessionTtlMs);
71
+ // 检查是否通过 HTTPS 反代访问
72
+ const isHttps = request.headers["x-forwarded-proto"] === "https";
73
+ const cookie = buildSessionCookie(sessionId, sessionTtlMs, isHttps);
72
74
  requestUrl.searchParams.delete("login_token");
73
75
  const redirectPath = requestUrl.pathname + (requestUrl.search ? requestUrl.search : "");
74
76
  response.writeHead(302, mergeCors(request, {
package/dist/index.js CHANGED
@@ -370,8 +370,8 @@ export async function main() {
370
370
  if (existsSync(portFile))
371
371
  unlinkSync(portFile);
372
372
  }
373
- catch {
374
- /* ignore */
373
+ catch (err) {
374
+ log.debug('Failed to remove port file:', err);
375
375
  }
376
376
  // Stop each platform: abort running tasks, then handle.stop() then module.stop()
377
377
  for (const platform of successfulPlatforms) {
package/dist/logger.js CHANGED
@@ -1,10 +1,13 @@
1
- import { createWriteStream, mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs';
1
+ import { createWriteStream, mkdirSync, existsSync, readdirSync, statSync, unlinkSync, createReadStream } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { finished } from 'node:stream/promises';
4
+ import { pipeline } from 'node:stream';
5
+ import { createGzip } from 'node:zlib';
4
6
  import { sanitize } from './sanitize.js';
5
7
  import { APP_HOME } from './constants.js';
6
8
  const DEFAULT_LOG_DIR = join(APP_HOME, 'logs');
7
9
  const MAX_LOG_FILES = 10;
10
+ const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10MB
8
11
  const LOG_LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
9
12
  let logDir = DEFAULT_LOG_DIR;
10
13
  let minLevel = LOG_LEVELS.DEBUG;
@@ -17,10 +20,6 @@ function getLogFileName() {
17
20
  const d = new Date();
18
21
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}.log`;
19
22
  }
20
- function getEventsFileName() {
21
- const d = new Date();
22
- return `events-${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}.jsonl`;
23
- }
24
23
  function rotateOldLogs() {
25
24
  try {
26
25
  const files = readdirSync(logDir)
@@ -31,37 +30,71 @@ function rotateOldLogs() {
31
30
  unlinkSync(join(logDir, files[i].name));
32
31
  }
33
32
  }
34
- catch {
35
- /* ignore */
33
+ catch (err) {
34
+ // 日志轮转失败不影响主流程
35
+ console.error('Failed to rotate log files:', err);
36
36
  }
37
37
  }
38
- function rotateOldJsonl() {
38
+ /**
39
+ * 压缩日志文件
40
+ */
41
+ function compressFile(filePath) {
39
42
  try {
40
- const files = readdirSync(logDir)
41
- .filter((f) => f.endsWith('.jsonl'))
42
- .map((f) => ({ name: f, time: statSync(join(logDir, f)).mtimeMs }))
43
- .sort((a, b) => b.time - a.time);
44
- for (let i = MAX_LOG_FILES; i < files.length; i++) {
45
- unlinkSync(join(logDir, files[i].name));
43
+ const gzip = createGzip();
44
+ const source = createReadStream(filePath);
45
+ const destination = createWriteStream(filePath + '.gz');
46
+ pipeline(source, gzip, destination, (err) => {
47
+ if (err) {
48
+ console.error('Failed to compress log file:', err);
49
+ }
50
+ else {
51
+ // 压缩成功,删除原文件
52
+ try {
53
+ unlinkSync(filePath);
54
+ }
55
+ catch (e) {
56
+ console.error('Failed to delete original log file:', e);
57
+ }
58
+ }
59
+ });
60
+ }
61
+ catch (err) {
62
+ console.error('Failed to compress log file:', err);
63
+ }
64
+ }
65
+ /**
66
+ * 检查日志文件大小,必要时轮转
67
+ */
68
+ function checkLogSize() {
69
+ if (!logStream)
70
+ return;
71
+ try {
72
+ const currentLogPath = join(logDir, getLogFileName());
73
+ const stats = statSync(currentLogPath);
74
+ if (stats.size > MAX_LOG_SIZE) {
75
+ // 关闭当前日志流
76
+ logStream.end();
77
+ logStream = undefined;
78
+ // 压缩当前日志文件
79
+ compressFile(currentLogPath);
80
+ // 创建新的日志文件
81
+ logStream = createWriteStream(join(logDir, getLogFileName()), { flags: 'a' });
46
82
  }
47
83
  }
48
- catch {
49
- /* ignore */
84
+ catch (err) {
85
+ console.error('Failed to check log size:', err);
50
86
  }
51
87
  }
52
88
  export function initLogger(dirOrOpts, level, telemetry) {
53
89
  let dir;
54
90
  let lev;
55
- let tel;
56
91
  if (dirOrOpts && typeof dirOrOpts === 'object' && !Array.isArray(dirOrOpts)) {
57
92
  dir = dirOrOpts.logDir;
58
93
  lev = dirOrOpts.logLevel;
59
- tel = dirOrOpts.telemetry;
60
94
  }
61
95
  else {
62
96
  dir = dirOrOpts;
63
97
  lev = level;
64
- tel = telemetry;
65
98
  }
66
99
  if (dir)
67
100
  logDir = dir;
@@ -90,6 +123,8 @@ function write(level, tag, msg, ...args) {
90
123
  else
91
124
  process.stdout.write(line);
92
125
  logStream?.write(line);
126
+ // 检查日志文件大小
127
+ checkLogSize();
93
128
  }
94
129
  export function createLogger(tag) {
95
130
  return {
@@ -20,24 +20,22 @@ import { setChatUser } from '../shared/chat-user-map.js';
20
20
  import { createLogger, auditLog } from '../logger.js';
21
21
  import { handleEnqueueResult, DEFAULT_QUEUE_FULL_MESSAGE, DEFAULT_QUEUED_MESSAGE } from '../shared/utils.js';
22
22
  import { walWrite, walCommit } from '../shared/message-wal.js';
23
+ import { TTLCache } from '@isaacs/ttlcache';
23
24
  /* ── 幂等性:消息去重 ── */
24
25
  const DEDUP_TTL_MS = 60_000; // 1 分钟内的相同 msgId 视为重复
25
- const dedupCache = new Map(); // msgId → timestamp
26
+ const DEDUP_MAX_SIZE = 1000; // 最大缓存条目数
27
+ // 使用 TTLCache 自动管理过期条目,避免内存泄漏
28
+ const dedupCache = new TTLCache({
29
+ max: DEDUP_MAX_SIZE,
30
+ ttl: DEDUP_TTL_MS,
31
+ updateAgeOnGet: false, // 访问时不更新过期时间
32
+ });
26
33
  function isDuplicate(msgId) {
27
34
  if (!msgId)
28
35
  return false;
29
- const now = Date.now();
30
- const prev = dedupCache.get(msgId);
31
- if (prev && now - prev < DEDUP_TTL_MS)
36
+ if (dedupCache.has(msgId))
32
37
  return true;
33
- dedupCache.set(msgId, now);
34
- // 清理过期条目
35
- if (dedupCache.size > 1000) {
36
- for (const [k, v] of dedupCache) {
37
- if (now - v > DEDUP_TTL_MS)
38
- dedupCache.delete(k);
39
- }
40
- }
38
+ dedupCache.set(msgId, true);
41
39
  return false;
42
40
  }
43
41
  const log = createLogger('TextFlow');
package/dist/sanitize.js CHANGED
@@ -1,7 +1,30 @@
1
1
  const PATTERNS = [
2
- [/\b(sk|pk|bot)[-_][a-zA-Z0-9_-]{8,}/gi, (m) => (m.match(/^[a-zA-Z]+/)?.[0] || m.slice(0, 2)) + '_****'],
3
- [/\b(AIza|AKIA)[a-zA-Z0-9]{12,}/g, (m) => m.slice(0, 4) + '****'],
4
- [/\b[a-zA-Z0-9]{40,}\b/g, (m) => m.slice(0, 6) + '****'],
2
+ // OpenAI / Anthropic / Claude API keys
3
+ [/\bsk-[a-zA-Z0-9]{32,}\b/g, (m) => 'sk-****' + m.slice(-4)],
4
+ // AWS Access Key
5
+ [/\b(AKIA|ASIA)[A-Z0-9]{16}\b/g, (m) => m.slice(0, 4) + '****'],
6
+ // GitHub Personal Access Token
7
+ [/\b(ghp|github_pat)_[a-zA-Z0-9_]{36,}\b/g, (m) => m.slice(0, 4) + '_****'],
8
+ // GitLab Personal Access Token
9
+ [/\bglpat-[a-zA-Z0-9_-]{20,}\b/g, (m) => 'glpat-****'],
10
+ // Telegram Bot Token
11
+ [/\b[0-9]{8,}:[a-zA-Z0-9_-]{35,}\b/g, (m) => m.split(':')[0] + ':****'],
12
+ // Anthropic API Key
13
+ [/\bant-[a-zA-Z0-9]{32,}\b/g, (m) => 'ant-****' + m.slice(-4)],
14
+ // OpenRouter API Key
15
+ [/\bsk-or-[a-zA-Z0-9]{32,}\b/g, (m) => 'sk-or-****' + m.slice(-4)],
16
+ // Google API Key
17
+ [/\bAIza[a-zA-Z0-9_-]{35}\b/g, (m) => 'AIza****'],
18
+ // Azure API Key
19
+ [/\b[a-f0-9]{32}:[a-zA-Z0-9]{44}\b/g, (m) => m.split(':')[0] + ':****'],
20
+ // 飞书/钉钉等 Bot Token
21
+ [/\b(bot)[-_][a-zA-Z0-9_-]{20,}\b/gi, (m) => 'bot_****'],
22
+ // 通用 API Key (更严格的匹配,避免误报)
23
+ [/\b(api_key|apikey|api-key|API_KEY|APIKEY)[\s]*[:=][\s]*[a-zA-Z0-9_-]{16,}\b/gi,
24
+ (m) => m.split(/[:=]/)[0] + '=****'],
25
+ // 密码模式
26
+ [/\b(password|passwd|pwd)[\s]*[:=][\s]*[^\s]{8,}\b/gi,
27
+ (m) => m.split(/[:=]/)[0] + '=****'],
5
28
  ];
6
29
  export function sanitize(text) {
7
30
  let result = text;
@@ -51,7 +51,9 @@ async function ping(target, workDir) {
51
51
  try {
52
52
  handle.abort();
53
53
  }
54
- catch { }
54
+ catch (err) {
55
+ // 中止失败,忽略错误
56
+ }
55
57
  settle(false, `timeout after ${PING_TIMEOUT_MS}ms`);
56
58
  }, PING_TIMEOUT_MS);
57
59
  });
@@ -9,7 +9,9 @@ import * as Sentry from '@sentry/node';
9
9
  import { createLogger } from '../logger.js';
10
10
  const log = createLogger('Sentry');
11
11
  // 开发者的 Sentry DSN(所有 open-im 实例共享)
12
- const DEFAULT_DSN = 'https://cc5ad094c1229b2a2ff23ab54b0fd807@o4508612762861568.ingest.us.sentry.io/4511583989727232';
12
+ // 注意:此 DSN 仅用于 open-im 自身的错误追踪
13
+ // 用户可在环境变量中配置自己的 DSN
14
+ const DEFAULT_DSN = process.env.OPEN_IM_SENTRY_DSN ?? 'https://cc5ad094c1229b2a2ff23ab54b0fd807@o4508612762861568.ingest.us.sentry.io/4511583989727232';
13
15
  let initialized = false;
14
16
  /**
15
17
  * 清理 PII(用户数据)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.11.10",
3
+ "version": "1.11.11-beta.1",
4
4
  "description": "Your AI coding assistant, in every chat app. Multi-platform IM bridge for Claude Code, Codex, and CodeBuddy.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -54,11 +54,13 @@
54
54
  "@anthropic-ai/claude-agent-sdk": "^0.3.179",
55
55
  "@anthropic-ai/sdk": "^0.104.2",
56
56
  "@aws-sdk/client-s3": "^3.1035.0",
57
- "@larksuiteoapi/node-sdk": "^1.59.0",
57
+ "@isaacs/ttlcache": "^2.1.5",
58
+ "@larksuiteoapi/node-sdk": "^1.67.0",
58
59
  "@opencode-ai/sdk": "^1.17.9",
59
60
  "@sentry/node": "^10.58.0",
60
61
  "@types/qrcode": "^1.5.6",
61
62
  "centrifuge": "^5.5.3",
63
+ "cookie": "^1.1.1",
62
64
  "dingtalk-stream": "^2.1.4",
63
65
  "edge-tts": "^1.0.1",
64
66
  "https-proxy-agent": "^9.1.0",
@@ -74,15 +76,20 @@
74
76
  "@types/node": "^20.0.0",
75
77
  "@types/prompts": "^2.4.9",
76
78
  "@types/ws": "^8.5.13",
79
+ "axios": "^1.18.1",
77
80
  "dotenv": "^17.3.1",
78
81
  "eslint": "^9.15.0",
79
82
  "globals": "^15.12.0",
80
83
  "tsx": "^4.0.0",
81
84
  "typescript": "^5.0.0",
82
85
  "typescript-eslint": "^8.58.0",
86
+ "vite": "^8.1.0",
83
87
  "vitest": "^4.1.2"
84
88
  },
85
89
  "engines": {
86
90
  "node": ">=20"
91
+ },
92
+ "overrides": {
93
+ "axios": "^1.18.1"
87
94
  }
88
95
  }