@wu529778790/open-im 1.11.4-beta.20 → 1.11.4-beta.4

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
@@ -10,7 +10,6 @@ open-im 把 Claude Code、Codex、CodeBuddy 接入 Telegram、飞书、企业微
10
10
  - **无缝接力** — 和 Claude Code CLI 共享 session,手机聊一半,电脑接着来
11
11
  - **完整能力** — 流式输出、会话管理、模型切换,全靠聊天命令
12
12
  - **一个桥接,多个平台** — 同一个 bot 支持 7 个 IM 平台
13
- - **交互式选择** — AI 问"选 1/2/3"时,IM 显示按钮(Telegram/飞书/钉钉)
14
13
 
15
14
  ## 快速开始
16
15
 
@@ -18,11 +17,19 @@ open-im 把 Claude Code、Codex、CodeBuddy 接入 Telegram、飞书、企业微
18
17
  # 安装
19
18
  npm install -g @wu529778790/open-im
20
19
 
20
+ # 配置(交互式向导)
21
+ open-im init
22
+
21
23
  # 启动
22
24
  open-im start
23
25
  ```
24
26
 
25
- 首次启动会自动打开 Web 控制台,引导你完成配置。
27
+ 或直接用 npx:
28
+
29
+ ```bash
30
+ npx @wu529778790/open-im init
31
+ npx @wu529778790/open-im start
32
+ ```
26
33
 
27
34
  ### 最小配置
28
35
 
@@ -39,22 +46,20 @@ open-im start
39
46
 
40
47
  ## 平台支持
41
48
 
42
- | 平台 | 流式输出 | 图片 | 交互按钮 | 接入指南 |
43
- |------|---------|------|---------|---------|
44
- | Telegram | ✅ | ✅ | ✅ | [Bot 文档](https://core.telegram.org/bots#creating-a-new-bot) |
45
- | 飞书 | ✅ | ✅ | ✅ | [开放平台](https://open.feishu.cn/) |
46
- | QQ 机器人 | ✅ | ✅ | ❌ | [开放平台](https://bot.q.qq.com/) |
47
- | 企业微信 | ✅ | ✅ | ❌ | [管理后台](https://work.weixin.qq.com/) |
48
- | 钉钉机器人 | ⚠️ 部分 | ✅ | ✅ | [开放平台](https://open-dev.dingtalk.com/) |
49
- | 微信助理(WorkBuddy) | ✅ | ❌ | ❌ | [接入指南](https://www.codebuddy.cn/docs/workbuddy/Claw) |
50
- | 微信客服号(ClawBot) | ✅ | ⚠️ 下载中 | ❌ | [接入指南](https://www.codebuddy.cn/docs/workbuddy/Claw) |
49
+ | 平台 | 流式输出 | 接入指南 |
50
+ |------|---------|---------|
51
+ | Telegram | ✅ | [Bot 文档](https://core.telegram.org/bots#creating-a-new-bot) |
52
+ | 飞书 | ✅ | [开放平台](https://open.feishu.cn/) |
53
+ | QQ 机器人 | ✅ | [开放平台](https://bot.q.qq.com/) |
54
+ | 企业微信 | ✅ | [管理后台](https://work.weixin.qq.com/) |
55
+ | 钉钉机器人 | ⚠️ 部分 | [开放平台](https://open-dev.dingtalk.com/) |
56
+ | 微信助理(WorkBuddy) | ✅ | [接入指南](https://www.codebuddy.cn/docs/workbuddy/Claw) |
57
+ | 微信客服号(ClawBot) | ✅ | [接入指南](https://www.codebuddy.cn/docs/workbuddy/Claw) |
51
58
 
52
- 每个平台可单独配置 AI 后端(`claude` / `codex` / `codebuddy` / `opencode`),默认 `claude`。
59
+ 每个平台可单独配置 AI 后端(`claude` / `codex` / `codebuddy`),默认 `claude`。
53
60
 
54
61
  ## 聊天命令
55
62
 
56
- ### 会话管理
57
-
58
63
  | 命令 | 说明 |
59
64
  |------|------|
60
65
  | `/help` | 显示所有命令 |
@@ -65,35 +70,12 @@ open-im start
65
70
  | `/delete <序号>` | 删除会话 |
66
71
  | `/rename <标题>` | 重命名会话 |
67
72
  | `/fork [序号]` | 分支会话 |
68
-
69
- ### 信息查看
70
-
71
- | 命令 | 说明 |
72
- |------|------|
73
73
  | `/models` | 查看可用模型 |
74
74
  | `/context` | 查看上下文用量 |
75
- | `/plugins` | 查看已安装插件 |
76
75
  | `/status` | 显示状态信息 |
77
76
  | `/cd <路径>` / `/pwd` | 切换/查看工作目录 |
78
-
79
- ### 快捷命令
80
-
81
- | 命令 | 说明 |
82
- |------|------|
83
- | `/git commit` | 提交代码 |
84
- | `/git push` | 推送到远程 |
85
- | `/git pull` | 拉取远程更新 |
86
- | `/test` | 运行测试 |
87
- | `/build` | 构建项目 |
88
- | `/review` | 代码审查 |
89
- | `/explain` | 解释项目结构 |
90
-
91
- ### 权限控制
92
-
93
- | 命令 | 说明 |
94
- |------|------|
95
- | `/allow` `/y` | 允许操作 |
96
- | `/deny` `/n` | 拒绝操作 |
77
+ | `/plugins` | 查看已安装插件 |
78
+ | `/allow` `/y` / `/deny` `/n` | 权限确认 |
97
79
 
98
80
  ## 会话接力
99
81
 
@@ -120,8 +102,6 @@ claude -c # 接上手机端的对话
120
102
  - 启动/停止桥接服务
121
103
  - 编辑配置文件
122
104
  - 首次运行自动弹出设置向导
123
- - 平台卡片支持展开/折叠
124
- - 一键保存并启动
125
105
 
126
106
  局域网访问:`export OPEN_IM_WEB_HOST=0.0.0.0`
127
107
 
@@ -134,7 +114,6 @@ claude -c # 接上手机端的对话
134
114
  | `open-im stop` | 停止服务 |
135
115
  | `open-im restart` | 重启 |
136
116
  | `open-im dashboard` | 仅启动 Web 配置服务 |
137
- | `open-im --version` | 查看版本号 |
138
117
 
139
118
  ## 配置
140
119
 
@@ -164,11 +143,10 @@ claude -c # 接上手机端的对话
164
143
  - **`TELEGRAM_BOT_TOKEN`** — Telegram Bot Token
165
144
  - **`OPEN_IM_WEB_PORT`** — Web 控制台端口(默认 39282)
166
145
  - **`OPEN_IM_WEB_HOST`** — Web 控制台监听地址
167
- - **`OPEN_IM_SENTRY_DSN`** — Sentry 错误追踪(可选)
168
146
 
169
- ### 错误追踪
147
+ ### 隐私
170
148
 
171
- 默认启用 Sentry 收集错误日志(不含聊天内容)。关闭:`OPEN_IM_TELEMETRY=false`
149
+ 匿名运行信息用于改进稳定性(不含聊天内容)。关闭:`OPEN_IM_TELEMETRY=false`
172
150
 
173
151
  ## 平台配置详情
174
152
 
@@ -12,8 +12,7 @@ import { createLogger } from '../logger.js';
12
12
  import { jitteredDelay, isFatalReconnectError, SLOW_PROBE_MS } from '../shared/reconnect.js';
13
13
  import { cacheContextToken } from './message-sender.js';
14
14
  import { setClawbotContextToken, clearClawbotContextToken } from '../shared/active-chats.js';
15
- import { createMediaTargetPath } from '../shared/media-storage.js';
16
- import { createDecipheriv } from 'node:crypto';
15
+ import { decryptAes256CbcMedia, saveBufferMedia, createMediaTargetPath } from '../shared/media-storage.js';
17
16
  import { CLAWBOT_POLL_INTERVAL_MS } from '../constants.js';
18
17
  const log = createLogger('ClawBot');
19
18
  const RECONNECT_DELAYS_MS = [3000, 5000, 10000, 20000, 30000];
@@ -133,10 +132,6 @@ function startPolling() {
133
132
  cacheContextToken(chatId, msg.context_token);
134
133
  setClawbotContextToken(msg.context_token);
135
134
  }
136
- // Debug: log raw item_list for image messages
137
- if (extracted === '[图片]') {
138
- log.info(`Image message raw item_list: ${JSON.stringify(msg.item_list).substring(0, 2000)}`);
139
- }
140
135
  // Extract and download images from message
141
136
  const imagePaths = await extractImages(msg);
142
137
  userMessages.push({ chatId, msgId, content: extracted, imagePaths: imagePaths.length > 0 ? imagePaths : undefined });
@@ -254,56 +249,28 @@ async function extractImages(msg) {
254
249
  for (const item of msg.item_list) {
255
250
  if (item.type !== 2 /* MessageItemType.IMAGE */)
256
251
  continue;
257
- const imageItem = item.image_item;
258
- const media = imageItem?.media;
259
- // iLink API 使用 full_url 而非 cdn_url
260
- const imageUrl = media?.full_url || media?.cdn_url;
261
- if (!imageUrl) {
262
- log.warn('Image item missing full_url/cdn_url');
252
+ const media = item.image_item?.media;
253
+ if (!media?.cdn_url)
263
254
  continue;
264
- }
265
255
  try {
266
- // 直接从 full_url 下载图片(CDN 返回的是已解密的图片数据)
267
- const response = await fetch(imageUrl, { signal: AbortSignal.timeout(30_000) });
256
+ // Download from CDN
257
+ const response = await fetch(media.cdn_url, { signal: AbortSignal.timeout(30_000) });
268
258
  if (!response.ok) {
269
259
  log.warn(`Image download failed: HTTP ${response.status}`);
270
260
  continue;
271
261
  }
272
262
  const buffer = Buffer.from(await response.arrayBuffer());
273
- // 检查是否是有效图片
274
- const first4 = buffer.subarray(0, 4).toString('hex');
275
- const isJpeg = first4 === 'ffd8ffe0' || first4 === 'ffd8ffe1';
276
- const isPng = first4 === '89504e47';
277
- let finalBuffer;
278
- if (isJpeg || isPng) {
279
- // CDN 返回的是有效图片,直接使用
280
- finalBuffer = buffer;
263
+ // Decrypt if AES key provided
264
+ let decrypted;
265
+ if (media.aes_key) {
266
+ decrypted = decryptAes256CbcMedia(buffer, media.aes_key);
281
267
  }
282
268
  else {
283
- // CDN 返回的是加密数据,尝试解密
284
- const aesKeyHex = imageItem?.aeskey;
285
- if (aesKeyHex && aesKeyHex.length === 32) {
286
- try {
287
- const keyBuf = Buffer.from(aesKeyHex, 'hex');
288
- const iv = keyBuf.subarray(0, 16);
289
- const decipher = createDecipheriv('aes-128-cbc', keyBuf, iv);
290
- finalBuffer = Buffer.concat([decipher.update(buffer), decipher.final()]);
291
- }
292
- catch {
293
- log.info('AES decryption failed, using raw image data');
294
- finalBuffer = buffer;
295
- }
296
- }
297
- else {
298
- finalBuffer = buffer;
299
- }
269
+ decrypted = buffer;
300
270
  }
301
271
  // Save to disk
302
272
  const targetPath = createMediaTargetPath('.jpg', `clawbot-${Date.now()}`);
303
- const { writeFile } = await import('node:fs/promises');
304
- const { mkdir } = await import('node:fs/promises');
305
- await mkdir('/tmp/t/open-im-images', { recursive: true });
306
- await writeFile(targetPath, finalBuffer);
273
+ await saveBufferMedia(decrypted, targetPath);
307
274
  paths.push(targetPath);
308
275
  log.info(`ClawBot image saved: ${targetPath}`);
309
276
  }
@@ -4,10 +4,12 @@
4
4
  * Uses POST + JSON body + Bearer token auth (iLink protocol).
5
5
  */
6
6
  import { randomBytes } from 'node:crypto';
7
+ import { readFileSync } from 'node:fs';
7
8
  import { createLogger } from '../logger.js';
8
9
  import { toReplyPlainText } from '../shared/utils.js';
9
10
  import { getChannelState } from './client.js';
10
11
  import { getActiveChatId, getClawbotContextToken } from '../shared/active-chats.js';
12
+ import { textToSpeech, getTTSConfig } from '../shared/tts.js';
11
13
  const log = createLogger('ClawBotSender');
12
14
  let apiUrl = 'https://ilinkai.weixin.qq.com';
13
15
  let apiToken = '';
@@ -88,6 +90,59 @@ async function postMessage(chatId, text, contextToken) {
88
90
  return false;
89
91
  }
90
92
  }
93
+ /**
94
+ * 发送语音消息
95
+ */
96
+ async function postVoiceMessage(chatId, audioPath, contextToken) {
97
+ if (getChannelState() !== 'connected') {
98
+ log.warn('ClawBot not connected, cannot send voice message');
99
+ return false;
100
+ }
101
+ const token = contextToken ?? getCachedContextToken(chatId);
102
+ if (!token) {
103
+ log.warn(`ClawBot no context_token for chatId=${chatId}, cannot send voice`);
104
+ return false;
105
+ }
106
+ try {
107
+ // 读取音频文件并转为 base64
108
+ const audioBuffer = readFileSync(audioPath);
109
+ const audioBase64 = audioBuffer.toString('base64');
110
+ const url = `${apiUrl}/ilink/bot/sendmessage`;
111
+ const body = JSON.stringify({
112
+ msg: {
113
+ from_user_id: '',
114
+ to_user_id: chatId,
115
+ client_id: generateClientId(),
116
+ message_type: 2, // BOT
117
+ message_state: 2, // FINISH
118
+ item_list: [{
119
+ type: 3, // VOICE
120
+ voice_item: {
121
+ media: { cdn_url: `data:audio/mp3;base64,${audioBase64}` },
122
+ },
123
+ }],
124
+ context_token: token,
125
+ },
126
+ base_info: { channel_version: '0.1.0' },
127
+ });
128
+ const res = await fetch(url, {
129
+ method: 'POST',
130
+ headers: buildHeaders(),
131
+ body,
132
+ });
133
+ const data = await res.json();
134
+ const ok = data.ret === 0 || data.ret === undefined;
135
+ if (!ok) {
136
+ log.error(`ClawBot voice message failed: ret=${data.ret} errcode=${data.errcode} errmsg=${data.errmsg}`);
137
+ return false;
138
+ }
139
+ return true;
140
+ }
141
+ catch (err) {
142
+ log.error('ClawBot voice message error:', err);
143
+ return false;
144
+ }
145
+ }
91
146
  /**
92
147
  * Send text reply to a ClawBot chat, splitting long messages automatically.
93
148
  */
@@ -96,6 +151,20 @@ export async function sendTextReply(chatId, text, contextToken) {
96
151
  // 发送文字消息
97
152
  log.info(`Sending ClawBot reply to chatId=${chatId}, len=${plainText.length}`);
98
153
  await postMessage(chatId, plainText, contextToken);
154
+ // 如果 TTS 启用,同时发送语音消息
155
+ const ttsConfig = getTTSConfig();
156
+ if (ttsConfig.enabled && plainText.length > 10) {
157
+ try {
158
+ const audioPath = await textToSpeech(plainText);
159
+ if (audioPath) {
160
+ await postVoiceMessage(chatId, audioPath, contextToken);
161
+ log.info(`Voice message sent to chatId=${chatId}`);
162
+ }
163
+ }
164
+ catch (err) {
165
+ log.warn('Failed to send voice message:', err);
166
+ }
167
+ }
99
168
  }
100
169
  /**
101
170
  * Send error reply to a ClawBot chat.
@@ -21,12 +21,9 @@ export interface TextItem {
21
21
  }
22
22
  /** Image content item */
23
23
  export interface ImageItem {
24
- aeskey?: string;
25
24
  media?: {
26
25
  aes_key?: string;
27
26
  cdn_url?: string;
28
- full_url?: string;
29
- encrypt_query_param?: string;
30
27
  };
31
28
  width?: number;
32
29
  height?: number;
@@ -95,6 +95,11 @@ export interface Config {
95
95
  allowedUserIds: string[];
96
96
  apiUrl?: string;
97
97
  apiToken?: string;
98
+ /** TTS 配置 */
99
+ tts?: {
100
+ enabled?: boolean;
101
+ voice?: string;
102
+ };
98
103
  };
99
104
  };
100
105
  }
@@ -162,6 +167,10 @@ export interface FilePlatformClawbot {
162
167
  allowedUserIds?: string[];
163
168
  apiUrl?: string;
164
169
  apiToken?: string;
170
+ tts?: {
171
+ enabled?: boolean;
172
+ voice?: string;
173
+ };
165
174
  }
166
175
  export interface FileToolClaude {
167
176
  cliPath?: string;
@@ -338,6 +338,8 @@ function buildInitialPayload(file) {
338
338
  apiUrl: file.platforms?.clawbot?.apiUrl ?? "http://127.0.0.1:26322",
339
339
  apiToken: maskSecret(file.platforms?.clawbot?.apiToken),
340
340
  allowedUserIds: (file.platforms?.clawbot?.allowedUserIds ?? []).join(", "),
341
+ ttsEnabled: file.platforms?.clawbot?.tts?.enabled ?? false,
342
+ ttsVoice: file.platforms?.clawbot?.tts?.voice ?? "zh-CN-XiaoxiaoNeural",
341
343
  },
342
344
  },
343
345
  ai: {
@@ -771,6 +773,10 @@ function toFileConfig(payload, existing) {
771
773
  apiUrl: clean(payload.platforms.clawbot.apiUrl) ?? "http://127.0.0.1:26322",
772
774
  apiToken: resolveSecret(payload.platforms.clawbot.apiToken, existing.platforms?.clawbot?.apiToken),
773
775
  allowedUserIds: splitCsv(payload.platforms.clawbot.allowedUserIds),
776
+ tts: {
777
+ enabled: payload.platforms.clawbot.ttsEnabled,
778
+ voice: payload.platforms.clawbot.ttsVoice || "zh-CN-XiaoxiaoNeural",
779
+ },
774
780
  },
775
781
  },
776
782
  };
package/dist/index.js CHANGED
@@ -104,6 +104,9 @@ const PLATFORM_MODULES = {
104
104
  const pc = config.platforms.clawbot;
105
105
  if (pc?.apiUrl && pc?.apiToken) {
106
106
  initClawBotSender(pc.apiUrl, pc.apiToken);
107
+ // 初始化 TTS
108
+ const { initTTS } = await import('./shared/tts.js');
109
+ initTTS({ enabled: true, voice: 'zh-CN-XiaoxiaoNeural' });
107
110
  }
108
111
  const handle = setupClawbotHandlers(config, sessionManager);
109
112
  await initClawbot(config, handle.handleEvent);
@@ -0,0 +1,22 @@
1
+ /**
2
+ * TTS (Text-to-Speech) 模块
3
+ * 使用 edge-tts-node 调用微软 Edge TTS 服务
4
+ */
5
+ /** TTS 配置 */
6
+ export interface TTSConfig {
7
+ enabled: boolean;
8
+ voice?: string;
9
+ }
10
+ /**
11
+ * 初始化 TTS
12
+ */
13
+ export declare function initTTS(cfg?: Partial<TTSConfig>): void;
14
+ /**
15
+ * 获取 TTS 配置
16
+ */
17
+ export declare function getTTSConfig(): TTSConfig;
18
+ /**
19
+ * 文字转语音
20
+ * @returns 音频文件路径
21
+ */
22
+ export declare function textToSpeech(text: string): Promise<string | null>;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * TTS (Text-to-Speech) 模块
3
+ * 使用 edge-tts-node 调用微软 Edge TTS 服务
4
+ */
5
+ import { MsEdgeTTS, OUTPUT_FORMAT } from 'edge-tts-node';
6
+ import { createLogger } from '../logger.js';
7
+ import { mkdirSync, existsSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { APP_HOME } from '../constants.js';
10
+ import { randomBytes } from 'node:crypto';
11
+ const log = createLogger('TTS');
12
+ /** 默认配置 */
13
+ const DEFAULT_TTS_CONFIG = {
14
+ enabled: false,
15
+ voice: 'zh-CN-XiaoxiaoNeural',
16
+ };
17
+ let config = DEFAULT_TTS_CONFIG;
18
+ let tts = null;
19
+ /**
20
+ * 初始化 TTS
21
+ */
22
+ export function initTTS(cfg) {
23
+ config = { ...DEFAULT_TTS_CONFIG, ...cfg };
24
+ if (config.enabled) {
25
+ tts = new MsEdgeTTS({ enableLogger: false });
26
+ log.info(`TTS enabled, voice: ${config.voice}`);
27
+ }
28
+ }
29
+ /**
30
+ * 获取 TTS 配置
31
+ */
32
+ export function getTTSConfig() {
33
+ return config;
34
+ }
35
+ /**
36
+ * 文字转语音
37
+ * @returns 音频文件路径
38
+ */
39
+ export async function textToSpeech(text) {
40
+ if (!config.enabled || !tts) {
41
+ return null;
42
+ }
43
+ try {
44
+ // 截断过长的文本(TTS 有长度限制)
45
+ const truncatedText = text.length > 5000 ? text.substring(0, 5000) + '...' : text;
46
+ // 清理 markdown 格式(TTS 不需要)
47
+ const cleanText = truncatedText
48
+ .replace(/```[\s\S]*?```/g, '代码块已省略') // 代码块
49
+ .replace(/`[^`]+`/g, (match) => match.slice(1, -1)) // 行内代码
50
+ .replace(/\*\*[^*]+\*\*/g, (match) => match.slice(2, -2)) // 粗体
51
+ .replace(/\*[^*]+\*/g, (match) => match.slice(1, -1)) // 斜体
52
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // 链接
53
+ .replace(/#{1,6}\s/g, '') // 标题
54
+ .replace(/\n{3,}/g, '\n\n'); // 多余换行
55
+ // 生成音频文件路径
56
+ const audioDir = join(APP_HOME, 'audio');
57
+ if (!existsSync(audioDir)) {
58
+ mkdirSync(audioDir, { recursive: true });
59
+ }
60
+ const audioPath = join(audioDir, `tts-${randomBytes(8).toString('hex')}.mp3`);
61
+ // 设置 TTS 元数据
62
+ await tts.setMetadata(config.voice, OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3);
63
+ // 调用 TTS
64
+ await tts.toFile(audioPath, cleanText);
65
+ log.info(`TTS generated: ${audioPath}`);
66
+ return audioPath;
67
+ }
68
+ catch (err) {
69
+ log.error('TTS failed:', err);
70
+ return null;
71
+ }
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.11.4-beta.20",
3
+ "version": "1.11.4-beta.4",
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",
@@ -58,13 +58,10 @@
58
58
  "@sentry/node": "^10.58.0",
59
59
  "centrifuge": "^5.5.3",
60
60
  "dingtalk-stream": "^2.1.4",
61
- "edge-tts": "^1.0.1",
62
- "https-proxy-agent": "^9.1.0",
63
- "node-edge-tts": "^1.2.10",
61
+ "edge-tts-node": "^1.5.7",
64
62
  "prompts": "^2.4.2",
65
- "say": "^0.16.0",
66
63
  "telegraf": "^4.16.3",
67
- "ws": "^8.21.0"
64
+ "ws": "^8.20.0"
68
65
  },
69
66
  "devDependencies": {
70
67
  "@eslint/js": "^9.15.0",