@wu529778790/open-im 0.4.0 → 1.0.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
@@ -1,144 +1,114 @@
1
1
  # open-im
2
2
 
3
- > 🚀 把你的 AI 助手装进口袋里 - Telegram 随时随地使用 Claude Code
3
+ 多平台 IM 桥接,将 Telegram 和飞书 (Feishu/Lark) 连接到 AI CLI 工具(Claude Code、Codex、Cursor),实现移动端/远程访问 AI 编程助手。
4
4
 
5
- 还在受限于终端吗?用手机也能 Coding 了!
5
+ ## 功能特性
6
6
 
7
- open-im 是一个轻量级的 IM 桥接工具,让你通过 Telegram 就能使用 Claude Code、Codex、Cursor 等 AI CLI 工具。无论是在咖啡厅、地铁上,还是躺在床上,你的 AI 助手随时在线。
7
+ - **多平台**:支持 Telegram 和飞书,可同时启用
8
+ - **多 AI 工具**:通过配置切换 Claude Code / Codex / Cursor
9
+ - **流式输出**:节流更新,实时展示 AI 回复
10
+ - **会话管理**:每用户独立 session,`/new` 重置会话
11
+ - **命令支持**:`/help` `/new` `/cd` `/pwd` `/status` `/allow` `/deny`
8
12
 
9
- ## ✨ 为什么选择 open-im
10
-
11
- - **📱 移动友好** - 告别终端,用手机照样写代码
12
- - **⚡ 实时流式输出** - AI 思考过程实时可见,像在终端一样流畅
13
- - **🔒 安全可控** - 支持白名单,只有你能用
14
- - **🔄 独立会话** - 每个人独立 session,互不干扰
15
- - **🛠️ 多 AI 支持** - Claude / Codex / Cursor 随意切换
13
+ ## 环境要求
16
14
 
17
- ## 🚀 快速开始
15
+ - **Node.js** >= 20
16
+ - **AI CLI**:已安装 Claude Code CLI(或 Codex/Cursor)并加入 PATH
18
17
 
19
- ### 方式一:npx(无需安装)
18
+ ## 安装
20
19
 
21
20
  ```bash
22
- npx @wu529778790/open-im run
21
+ npm install @wu529778790/open-im -g
23
22
  ```
24
23
 
25
- ### 方式二:全局安装(推荐常用用户)
24
+ ## ✨ 为什么选择 open-im
26
25
 
27
26
  ```bash
28
- npm i @wu529778790/open-im -g
29
- open-im run
27
+ # 使用 npx 快速体验(无需全局安装)
28
+ npx @wu529778790/open-im start # 后台运行
29
+ npx @wu529778790/open-im stop # 停止后台进程
30
+ npx @wu529778790/open-im dev # 前台运行(调试),Ctrl+C 停止
30
31
  ```
31
32
 
32
- 首次运行会引导你完成配置,30 秒即可搞定。
33
-
34
- 如果配置引导未出现,可以手动运行:
33
+ 或全局安装后直接使用:
35
34
 
36
35
  ```bash
37
- npx @wu529778790/open-im init
36
+ npm install @wu529778790/open-im -g
37
+ open-im start
38
38
  ```
39
39
 
40
- ## ⚙️ 配置说明
40
+ 首次运行会进入交互式配置向导,按提示输入 Token 后自动启动。配置保存到 `~/.open-im/config.json`。
41
41
 
42
- 配置文件位置:`~/.open-im/config.json`
43
-
44
- 配置文件示例:
45
-
46
- ```json
47
- {
48
- "telegramBotToken": "你的Bot Token(从 @BotFather 获取)",
49
- "allowedUserIds": ["你的Telegram用户ID"],
50
- "claudeWorkDir": "/path/to/your/work/dir",
51
- "claudeSkipPermissions": true,
52
- "aiCommand": "claude",
53
- "platforms": {
54
- "telegram": {
55
- "proxy": "http://127.0.0.1:7890"
56
- }
57
- }
58
- }
59
- ```
42
+ ## 运行方式
60
43
 
61
- ### 🌐 代理配置
44
+ | 命令 | 说明 |
45
+ |------|------|
46
+ | `open-im start` | 后台运行,适合长期使用 |
47
+ | `open-im stop` | 停止后台进程 |
48
+ | `open-im dev` 或 `open-im` | 前台运行(调试),Ctrl+C 停止 |
62
49
 
63
- 如果你的网络环境无法直接访问 Telegram,需要配置代理。代理配置按平台独立设置,互不影响。
50
+ ## 会话说明
64
51
 
65
- **配置方式:**
52
+ **会话上下文存储在本地**(`~/.open-im/data/sessions.json`),与 IM 聊天记录无关。每用户在本地维护独立的 session 和 Claude 会话 ID,`/new` 可重置当前会话。
66
53
 
67
- 在 JSON 配置文件中添加:
68
- ```json
69
- {
70
- "platforms": {
71
- "telegram": {
72
- "proxy": "http://127.0.0.1:7890"
73
- }
74
- }
75
- }
54
+ ```bash
55
+ npm i @wu529778790/open-im -g
56
+ open-im run
76
57
  ```
77
58
 
78
- **支持的代理类型:**
79
- - HTTP 代理:`http://127.0.0.1:7890`
80
- - HTTPS 代理:`https://127.0.0.1:7890`
81
- - SOCKS5 代理:`socks5://127.0.0.1:1080`
82
-
83
- **注意:**
84
- - 代理仅用于访问 Telegram API,不会影响 AI 工具(Claude/Codex/Cursor)的网络请求
85
- - 飞书等其他国内 IM 平台无需配置代理
86
- - 如果你的网络能直接访问 Telegram,则无需配置代理
59
+ ### 环境变量
87
60
 
88
- ### 获取 Telegram Bot Token
89
- 1. 在 Telegram 中搜索 @BotFather
90
- 2. 发送 `/newbot` 创建新机器人
91
- 3. 按提示设置机器人名称
92
- 4. BotFather 会返回 Token,格式如:`123456789:ABCdefGHIjklMNOpqrsTUVwxyz`
61
+ | 变量 | 说明 |
62
+ |------|------|
63
+ | `TELEGRAM_BOT_TOKEN` | Telegram Bot Token(从 @BotFather 获取) |
64
+ | `FEISHU_APP_ID` | 飞书应用 App ID |
65
+ | `FEISHU_APP_SECRET` | 飞书应用 App Secret |
66
+ | `ALLOWED_USER_IDS` | 白名单用户 ID(逗号分隔,空=所有人) |
67
+ | `AI_COMMAND` | `claude` \| `codex` \| `cursor`,默认 `claude` |
68
+ | `CLAUDE_CLI_PATH` | Claude CLI 路径,默认 `claude` |
69
+ | `CLAUDE_WORK_DIR` | 工作目录 |
70
+ | `CLAUDE_SKIP_PERMISSIONS` | 跳过权限确认,默认 `true` |
71
+ | `CLAUDE_TIMEOUT_MS` | Claude 超时(毫秒),默认 600000 |
72
+ | `CLAUDE_MODEL` | Claude 模型(可选) |
73
+ | `ALLOWED_BASE_DIRS` | 允许访问的目录(逗号分隔) |
74
+ | `LOG_DIR` | 日志目录,默认 `~/.open-im/logs` |
75
+ | `LOG_LEVEL` | 日志级别:INFO/DEBUG/WARN/ERROR |
93
76
 
94
- 获取 Telegram 用户 ID(可选):
95
- 1. 在 Telegram 中搜索 @userinfobot
96
- 2. 发送任意消息
97
- 3. 机器人会返回你的用户 ID
98
- 4. 如不设置,则所有人都可以使用你的机器人
77
+ ### 配置文件
99
78
 
100
- ## 📖 常用命令
79
+ 配置优先级:环境变量 > `~/.open-im/config.json` > 默认值。
101
80
 
102
- | 命令 | 说明 |
103
- |------|------|
104
- | `open-im` / `open-im run` | 前台运行(首次使用会引导配置) |
105
- | `open-im init` | 初始化配置(首次使用或重新配置) |
106
- | `open-im start` | 后台启动服务 |
107
- | `open-im stop` | 停止服务 |
108
- | `open-im restart` | 重启服务 |
81
+ 至少需配置 **Telegram** **飞书** 其一:
109
82
 
110
- ### Telegram 机器人命令
83
+ - **Telegram**:`TELEGRAM_BOT_TOKEN` 或 `telegramBotToken`
84
+ - **飞书**:`FEISHU_APP_ID` + `FEISHU_APP_SECRET` 或 `feishuAppId` + `feishuAppSecret`
111
85
 
112
- | 命令 | 功能 |
113
- |------|------|
114
- | `/help` | 查看帮助 |
115
- | `/new` | 开启新会话 |
116
- | `/cd <路径>` | 切换工作目录 |
117
- | `/pwd` | 查看当前目录 |
118
- | `/status` | 查看运行状态 |
86
+ ### 飞书配置说明
119
87
 
120
- ## 💡 使用场景
88
+ 1. [飞书开放平台](https://open.feishu.cn/) 创建企业自建应用
89
+ 2. 开启「机器人」能力
90
+ 3. 配置事件订阅:启用 `im.message.receive_v1`,使用 **长连接** 模式(WebSocket)
91
+ 4. 将机器人添加到目标群聊或发起私聊
121
92
 
122
- - 🚇 **通勤路上** - 用手机处理简单的代码问题
123
- - ☕ **咖啡厅** - 没带电脑也能快速调试
124
- - 🛋️ **沙发模式** - 躺着看 AI 帮你写代码
125
- - 🌙 **紧急修复** - 半夜收到报警,手机直接处理
126
-
127
- ## 📦 安装方式
93
+ ## 开发
128
94
 
129
95
  ```bash
130
- # npx(无需安装)
131
- npx @wu529778790/open-im run
132
-
133
- # npm 全局安装
134
- npm i @wu529778790/open-im -g
96
+ npm run build # 构建
97
+ npm run dev # 直接运行源码(tsx,无需 build)
98
+ npm run foreground # 前台运行已构建版本
99
+ ```
135
100
 
136
- # yarn 全局安装
137
- yarn global add @wu529778790/open-im
101
+ ## IM 内命令
138
102
 
139
- # pnpm 全局安装
140
- pnpm i @wu529778790/open-im -g
141
- ```
103
+ | 命令 | 说明 |
104
+ |------|------|
105
+ | `/help` | 显示帮助 |
106
+ | `/new` | 开始新会话 |
107
+ | `/status` | 显示状态(AI 工具、工作目录、费用等) |
108
+ | `/cd <路径>` | 切换工作目录 |
109
+ | `/pwd` | 显示当前工作目录 |
110
+ | `/allow` `/y` | 允许权限请求 |
111
+ | `/deny` `/n` | 拒绝权限请求 |
142
112
 
143
113
  ## 📝 License
144
114
 
@@ -151,16 +121,19 @@ pnpm i @wu529778790/open-im -g
151
121
  如果配置引导没有出现,尝试以下方法:
152
122
 
153
123
  1. **手动运行配置命令:**
124
+
154
125
  ```bash
155
126
  npx @wu529778790/open-im init
156
127
  ```
157
128
 
158
129
  2. **检查是否已有配置文件:**
130
+
159
131
  ```bash
160
132
  cat ~/.open-im/config.json
161
133
  ```
162
134
 
163
135
  3. **手动创建配置文件:**
136
+
164
137
  ```bash
165
138
  mkdir -p ~/.open-im
166
139
  cat > ~/.open-im/config.json << 'EOF'
@@ -220,6 +193,7 @@ npx @wu529778790/open-im run
220
193
  ```
221
194
 
222
195
  支持的代理格式:
196
+
223
197
  - HTTP:`http://127.0.0.1:7890`
224
198
  - HTTPS:`https://127.0.0.1:7890`
225
199
  - SOCKS5:`socks5://127.0.0.1:1080`
@@ -1,11 +1,18 @@
1
+ import { createLogger } from '../logger.js';
2
+ const log = createLogger('AccessControl');
1
3
  export class AccessControl {
2
4
  allowedUserIds;
3
5
  constructor(allowedUserIds) {
4
6
  this.allowedUserIds = new Set(allowedUserIds);
7
+ log.info(`AccessControl initialized with ${allowedUserIds.length} allowed users:`, allowedUserIds);
5
8
  }
6
9
  isAllowed(userId) {
7
- if (this.allowedUserIds.size === 0)
10
+ if (this.allowedUserIds.size === 0) {
11
+ log.debug(`Allowing user ${userId} (no whitelist configured)`);
8
12
  return true;
9
- return this.allowedUserIds.has(userId);
13
+ }
14
+ const allowed = this.allowedUserIds.has(userId);
15
+ log.info(`Checking user ${userId}: ${allowed ? 'ALLOWED' : 'DENIED'}`);
16
+ return allowed;
10
17
  }
11
18
  }
@@ -1,20 +1,29 @@
1
- import { spawn } from 'node:child_process';
2
- import { createInterface } from 'node:readline';
3
- import { parseStreamLine, extractTextDelta, extractThinkingDelta, extractResult } from './stream-parser.js';
4
- import { isStreamInit, isContentBlockStart, isContentBlockDelta, isContentBlockStop } from './types.js';
5
- import { createLogger } from '../logger.js';
6
- const log = createLogger('CliRunner');
1
+ import { spawn } from "node:child_process";
2
+ import { createInterface } from "node:readline";
3
+ import { parseStreamLine, extractTextDelta, extractThinkingDelta, extractResult, } from "./stream-parser.js";
4
+ import { isStreamInit, isContentBlockStart, isContentBlockDelta, isContentBlockStop, } from "./types.js";
5
+ import { createLogger } from "../logger.js";
6
+ const log = createLogger("CliRunner");
7
7
  export function runClaude(cliPath, prompt, sessionId, workDir, callbacks, options) {
8
- const args = ['-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages'];
8
+ const args = [
9
+ "-p",
10
+ "--output-format",
11
+ "stream-json",
12
+ "--verbose",
13
+ "--include-partial-messages",
14
+ ];
9
15
  if (options?.skipPermissions)
10
- args.push('--dangerously-skip-permissions');
16
+ args.push("--dangerously-skip-permissions");
11
17
  if (options?.model)
12
- args.push('--model', options.model);
18
+ args.push("--model", options.model);
13
19
  if (sessionId)
14
- args.push('--resume', sessionId);
15
- args.push('--', prompt);
20
+ args.push("--resume", sessionId);
21
+ args.push("--", prompt);
16
22
  const env = {};
17
23
  for (const [k, v] of Object.entries(process.env)) {
24
+ // Skip CLAUDECODE to prevent nested session detection
25
+ if (k === "CLAUDECODE")
26
+ continue;
18
27
  if (v !== undefined)
19
28
  env[k] = v;
20
29
  }
@@ -22,29 +31,68 @@ export function runClaude(cliPath, prompt, sessionId, workDir, callbacks, option
22
31
  env.CC_IM_CHAT_ID = options.chatId;
23
32
  if (options?.hookPort)
24
33
  env.CC_IM_HOOK_PORT = String(options.hookPort);
25
- const child = spawn(cliPath, args, { cwd: workDir, stdio: ['ignore', 'pipe', 'pipe'], env, windowsHide: true });
26
- log.debug(`Claude CLI spawned: pid=${child.pid}, cwd=${workDir}, session=${sessionId ?? 'new'}`);
27
- let accumulated = '';
28
- let accumulatedThinking = '';
34
+ // Try different spawn strategies based on platform
35
+ log.info(`Spawning CLI: path=${cliPath}, platform=${process.platform}`);
36
+ let child;
37
+ if (process.platform === "win32") {
38
+ // Check if running in Git Bash (MINGW) or MSYS
39
+ const isGitBash = process.env.MSYSTEM ||
40
+ process.env.MINGW_PREFIX ||
41
+ process.env.SHELL?.includes("bash");
42
+ log.info(`Detected environment: Git Bash=${isGitBash ? "yes" : "no"}`);
43
+ if (isGitBash) {
44
+ // In Git Bash, use shell for proper path resolution
45
+ log.info(`Using shell spawn for Git Bash environment`);
46
+ child = spawn(cliPath, args, {
47
+ cwd: workDir,
48
+ stdio: ["ignore", "pipe", "pipe"],
49
+ env,
50
+ shell: true,
51
+ windowsHide: true,
52
+ });
53
+ }
54
+ else {
55
+ // In pure cmd/PowerShell, direct spawn works best
56
+ log.info(`Using direct spawn for Windows cmd/PowerShell`);
57
+ child = spawn(cliPath, args, {
58
+ cwd: workDir,
59
+ stdio: ["ignore", "pipe", "pipe"],
60
+ env,
61
+ windowsHide: true,
62
+ });
63
+ }
64
+ }
65
+ else {
66
+ child = spawn(cliPath, args, {
67
+ cwd: workDir,
68
+ stdio: ["ignore", "pipe", "pipe"],
69
+ env,
70
+ });
71
+ }
72
+ log.info(`Claude CLI: pid=${child.pid}, cwd=${workDir}, session=${sessionId ?? "new"}`);
73
+ let accumulated = "";
74
+ let accumulatedThinking = "";
29
75
  let completed = false;
30
- let model = '';
76
+ let model = "";
31
77
  const toolStats = {};
32
78
  const pendingToolInputs = new Map();
33
79
  const MAX_TIMEOUT = 2_147_483_647;
34
- const timeoutMs = options?.timeoutMs && options.timeoutMs > 0 ? Math.min(options.timeoutMs, MAX_TIMEOUT) : 0;
80
+ const timeoutMs = options?.timeoutMs && options.timeoutMs > 0
81
+ ? Math.min(options.timeoutMs, MAX_TIMEOUT)
82
+ : 0;
35
83
  let timeoutHandle = null;
36
84
  if (timeoutMs > 0) {
37
85
  timeoutHandle = setTimeout(() => {
38
86
  if (!completed && !child.killed) {
39
87
  completed = true;
40
88
  log.warn(`Claude CLI timeout after ${timeoutMs}ms, killing pid=${child.pid}`);
41
- child.kill('SIGTERM');
89
+ child.kill("SIGTERM");
42
90
  callbacks.onError(`执行超时(${timeoutMs}ms),已终止进程`);
43
91
  }
44
92
  }, timeoutMs);
45
93
  }
46
94
  const rl = createInterface({ input: child.stdout });
47
- rl.on('line', (line) => {
95
+ rl.on("line", (line) => {
48
96
  const event = parseStreamLine(line);
49
97
  if (!event)
50
98
  return;
@@ -64,16 +112,18 @@ export function runClaude(cliPath, prompt, sessionId, workDir, callbacks, option
64
112
  callbacks.onThinking?.(accumulatedThinking);
65
113
  return;
66
114
  }
67
- if (isContentBlockStart(event) && event.event.content_block?.type === 'tool_use') {
115
+ if (isContentBlockStart(event) &&
116
+ event.event.content_block?.type === "tool_use") {
68
117
  const name = event.event.content_block.name;
69
118
  if (name)
70
- pendingToolInputs.set(event.event.index, { name, json: '' });
119
+ pendingToolInputs.set(event.event.index, { name, json: "" });
71
120
  return;
72
121
  }
73
- if (isContentBlockDelta(event) && event.event.delta?.type === 'input_json_delta') {
122
+ if (isContentBlockDelta(event) &&
123
+ event.event.delta?.type === "input_json_delta") {
74
124
  const pending = pendingToolInputs.get(event.event.index);
75
125
  if (pending)
76
- pending.json += event.event.delta.partial_json ?? '';
126
+ pending.json += event.event.delta.partial_json ?? "";
77
127
  return;
78
128
  }
79
129
  if (isContentBlockStop(event)) {
@@ -134,17 +184,19 @@ export function runClaude(cliPath, prompt, sessionId, workDir, callbacks, option
134
184
  }
135
185
  }
136
186
  };
137
- child.on('close', (code) => {
187
+ child.on("close", (code) => {
188
+ log.info(`Claude CLI closed: exitCode=${code}, pid=${child.pid}`);
138
189
  exitCode = code;
139
190
  childClosed = true;
140
191
  finalize();
141
192
  });
142
- rl.on('close', () => {
193
+ rl.on("close", () => {
143
194
  rlClosed = true;
144
195
  finalize();
145
196
  });
146
- child.on('error', (err) => {
147
- log.error(`Claude CLI error: ${err.message}`);
197
+ child.on("error", (err) => {
198
+ const errorCode = err.code;
199
+ log.error(`Claude CLI spawn error: ${err.message}, code=${errorCode}, path=${cliPath}`);
148
200
  if (timeoutHandle)
149
201
  clearTimeout(timeoutHandle);
150
202
  if (!completed) {
@@ -160,7 +212,7 @@ export function runClaude(cliPath, prompt, sessionId, workDir, callbacks, option
160
212
  clearTimeout(timeoutHandle);
161
213
  rl.close();
162
214
  if (!child.killed)
163
- child.kill('SIGTERM');
215
+ child.kill("SIGTERM");
164
216
  },
165
217
  };
166
218
  }