evolclaw 2.0.4 → 2.0.6
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 +18 -18
- package/dist/channels/feishu.js +68 -10
- package/dist/channels/wechat.js +238 -5
- package/dist/cli.js +107 -82
- package/dist/core/agent-runner.js +3 -2
- package/dist/core/command-handler.js +3 -3
- package/dist/core/message-processor.js +6 -2
- package/dist/core/session-manager.js +4 -3
- package/dist/index.js +21 -6
- package/dist/paths.js +3 -1
- package/dist/utils/init.js +66 -50
- package/dist/utils/markdown-to-feishu.js +58 -2
- package/dist/utils/permission.js +7 -0
- package/dist/utils/platform.js +175 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,18 +6,18 @@ EvolClaw 是一个轻量级 AI Agent 网关,基于 Claude Agent SDK 构建。
|
|
|
6
6
|
|
|
7
7
|
## 核心特性
|
|
8
8
|
|
|
9
|
-
- **多端会话接力**:跨终端共享会话、环境、项目,无缝切换开发体验
|
|
10
|
-
- **配置自动继承**:复用 CLI 环境的 API Key/URL、记忆文件、MCP/Skills 插件,零额外配置
|
|
11
|
-
- **轻量化设计**:进程模式运行,CLI 命令行管理,无端口开放,无容器依赖,无 UI 界面
|
|
12
|
-
- **多项目支持**:每个项目独立会话,支持动态切换
|
|
13
|
-
-
|
|
14
|
-
- **多渠道接入**:Channel Adapter 模式,飞书 + 微信扫码一键接入
|
|
15
|
-
- **分层权限**:用户级/管理员级命令分离,多用户安全隔离
|
|
16
|
-
- **统一消息处理**:消息处理与渠道解耦,新增渠道仅需 ~15 行代码
|
|
17
|
-
- **会话持久化**:会话数据与 CLI 工具共享,不额外存储,服务重启不丢失
|
|
18
|
-
- **执行中插入**:任务执行中可发送新消息,自动中断当前任务并处理新请求
|
|
19
|
-
- **消息智能发送**:前台任务动态聚合批量发送,后台任务静默完成后通知
|
|
20
|
-
- **健壮性保障**:任务超时提醒、会话异常安全模式修复、重启失败自动自愈
|
|
9
|
+
- 🔄 **多端会话接力**:跨终端共享会话、环境、项目,无缝切换开发体验
|
|
10
|
+
- ♻️ **配置自动继承**:复用 CLI 环境的 API Key/URL、记忆文件、MCP/Skills 插件,零额外配置
|
|
11
|
+
- 🚀 **轻量化设计**:进程模式运行,CLI 命令行管理,无端口开放,无容器依赖,无 UI 界面
|
|
12
|
+
- 📁 **多项目支持**:每个项目独立会话,支持动态切换
|
|
13
|
+
- 👥 **双模式会话**:多用户私聊会话隔离,群聊会话共享,满足不同协作场景
|
|
14
|
+
- 🌐 **多渠道接入**:Channel Adapter 模式,飞书 + 微信扫码一键接入
|
|
15
|
+
- 🔐 **分层权限**:用户级/管理员级命令分离,多用户安全隔离
|
|
16
|
+
- 📊 **统一消息处理**:消息处理与渠道解耦,新增渠道仅需 ~15 行代码
|
|
17
|
+
- 💾 **会话持久化**:会话数据与 CLI 工具共享,不额外存储,服务重启不丢失
|
|
18
|
+
- ⚡ **执行中插入**:任务执行中可发送新消息,自动中断当前任务并处理新请求
|
|
19
|
+
- 🔕 **消息智能发送**:前台任务动态聚合批量发送,后台任务静默完成后通知
|
|
20
|
+
- 🤖 **健壮性保障**:任务超时提醒、会话异常安全模式修复、重启失败自动自愈
|
|
21
21
|
|
|
22
22
|
## 适合场景
|
|
23
23
|
|
|
@@ -71,7 +71,7 @@ MessageProcessor.processMessage()
|
|
|
71
71
|
|
|
72
72
|
### 环境要求
|
|
73
73
|
|
|
74
|
-
- **操作系统**:macOS / Linux
|
|
74
|
+
- **操作系统**:macOS / Linux / Windows
|
|
75
75
|
- **Node.js** >= 22(需要 node:sqlite 内置模块支持)
|
|
76
76
|
- **Claude Code** >= 2.1.32(`npm install -g @anthropic-ai/claude-code`)
|
|
77
77
|
|
|
@@ -83,6 +83,8 @@ MessageProcessor.processMessage()
|
|
|
83
83
|
npm install -g evolclaw
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
+
> **Windows 用户**:首次运行前可能需要执行 `Set-ExecutionPolicy RemoteSigned -Scope CurrentUser`
|
|
87
|
+
|
|
86
88
|
**从源码安装**:
|
|
87
89
|
|
|
88
90
|
```bash
|
|
@@ -111,7 +113,7 @@ evolclaw init wechat
|
|
|
111
113
|
- 渠道选择(飞书/微信)并扫码登录
|
|
112
114
|
- 默认项目路径
|
|
113
115
|
- 模型选择(sonnet/opus/haiku)
|
|
114
|
-
- 自动写入 `EVOLCLAW_HOME` 到 shell profile
|
|
116
|
+
- 自动写入 `EVOLCLAW_HOME` 到 shell profile(Unix)或用户环境变量(Windows)
|
|
115
117
|
|
|
116
118
|
配置文件生成在 `{EVOLCLAW_HOME}/data/evolclaw.json`(默认 `~/.evolclaw/data/evolclaw.json`)。
|
|
117
119
|
|
|
@@ -159,8 +161,6 @@ npm test
|
|
|
159
161
|
|
|
160
162
|
```
|
|
161
163
|
evolclaw/
|
|
162
|
-
├── bin/
|
|
163
|
-
│ └── evolclaw # CLI 入口(npm link)
|
|
164
164
|
├── src/
|
|
165
165
|
│ ├── core/
|
|
166
166
|
│ │ ├── command-handler.ts # 斜杠命令处理
|
|
@@ -225,8 +225,8 @@ evolclaw/
|
|
|
225
225
|
|
|
226
226
|
## TODO
|
|
227
227
|
|
|
228
|
-
- [
|
|
229
|
-
- [
|
|
228
|
+
- [x] Windows 系统 CLI 命令支持
|
|
229
|
+
- [x] 微信插件支持图片/文件的收发
|
|
230
230
|
- [ ] 自动授权可配置(自动放行/自动拒绝)
|
|
231
231
|
- [ ] 手动授权支持(飞书卡片/文本回复)
|
|
232
232
|
- [ ] ACP 协议支持(接入 Codex / Gemini CLI)
|
package/dist/channels/feishu.js
CHANGED
|
@@ -46,6 +46,10 @@ export class FeishuChannel {
|
|
|
46
46
|
const msg = data.message;
|
|
47
47
|
logger.debug('[Feishu] Received message, message_id:', msg.message_id, 'type:', msg.message_type);
|
|
48
48
|
logger.debug('[Feishu] Full data object:', JSON.stringify(data, null, 2));
|
|
49
|
+
// 诊断:话题消息检测
|
|
50
|
+
if (msg.thread_id) {
|
|
51
|
+
logger.info('[Feishu] Thread message detected, thread_id:', msg.thread_id, 'parent_id:', msg.parent_id, 'root_id:', msg.root_id);
|
|
52
|
+
}
|
|
49
53
|
if (!msg.message_id || this.isDuplicate(msg.message_id)) {
|
|
50
54
|
logger.debug('[Feishu] Duplicate message ignored:', msg.message_id);
|
|
51
55
|
return;
|
|
@@ -54,6 +58,12 @@ export class FeishuChannel {
|
|
|
54
58
|
this.addAckReaction(msg.message_id);
|
|
55
59
|
if (!this.messageHandler)
|
|
56
60
|
return;
|
|
61
|
+
// 提取 @ 提及列表(排除机器人自身)
|
|
62
|
+
const mentions = (msg.mentions || []).map((m) => ({
|
|
63
|
+
userId: m.id?.open_id || '',
|
|
64
|
+
name: m.name,
|
|
65
|
+
key: m.key
|
|
66
|
+
})).filter((m) => m.userId && m.userId !== this.config.appId);
|
|
57
67
|
// 提取发送者信息
|
|
58
68
|
const userId = data.sender?.sender_id?.open_id;
|
|
59
69
|
let userName;
|
|
@@ -113,7 +123,19 @@ export class FeishuChannel {
|
|
|
113
123
|
}
|
|
114
124
|
}
|
|
115
125
|
else if (quotedMsgType === 'file') {
|
|
116
|
-
|
|
126
|
+
const parsedFile = JSON.parse(quotedContent);
|
|
127
|
+
const quotedFileKey = parsedFile.file_key;
|
|
128
|
+
const quotedFileName = parsedFile.file_name || 'unknown';
|
|
129
|
+
const projectPath = this.projectPathProvider
|
|
130
|
+
? await this.projectPathProvider(msg.chat_id)
|
|
131
|
+
: process.cwd();
|
|
132
|
+
const quotedFilePath = await this.downloadFile(quotedFileKey, quotedFileName, msg.parent_id, projectPath);
|
|
133
|
+
if (quotedFilePath) {
|
|
134
|
+
quotedText = `> [引用的文件:${quotedFileName}]\n> 文件已保存到:${quotedFilePath}\n\n`;
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
quotedText = `> [文件消息]\n\n`;
|
|
138
|
+
}
|
|
117
139
|
}
|
|
118
140
|
else {
|
|
119
141
|
quotedText = `> [${quotedMsgType}消息]\n\n`;
|
|
@@ -127,11 +149,9 @@ export class FeishuChannel {
|
|
|
127
149
|
if (msg.message_type === 'text') {
|
|
128
150
|
const parsed = JSON.parse(msg.content);
|
|
129
151
|
// 优先使用 text_without_at_bot(去除机器人 @),否则使用 text
|
|
130
|
-
|
|
131
|
-
// 去除消息中所有的 @ 提及(支持命令在前或在后)
|
|
132
|
-
content = content.replace(/@[^\s]+\s*/g, '').trim();
|
|
152
|
+
const content = parsed.text_without_at_bot || parsed.text;
|
|
133
153
|
const finalContent = quotedText + content;
|
|
134
|
-
await this.messageHandler(msg.chat_id, finalContent, quotedImages.length > 0 ? quotedImages : undefined, userId, userName, msg.message_id);
|
|
154
|
+
await this.messageHandler(msg.chat_id, finalContent, quotedImages.length > 0 ? quotedImages : undefined, userId, userName, msg.message_id, mentions.length > 0 ? mentions : undefined);
|
|
135
155
|
}
|
|
136
156
|
// 处理图片消息
|
|
137
157
|
else if (msg.message_type === 'image') {
|
|
@@ -148,7 +168,7 @@ export class FeishuChannel {
|
|
|
148
168
|
await this.messageHandler(msg.chat_id, prompt, allImages, userId, userName, msg.message_id);
|
|
149
169
|
}
|
|
150
170
|
else {
|
|
151
|
-
const prompt = quotedText + '[图片下载失败] 应用可能缺少 im:
|
|
171
|
+
const prompt = quotedText + '[图片下载失败] 应用可能缺少 im:message 或 im:message:readonly 权限';
|
|
152
172
|
await this.messageHandler(msg.chat_id, prompt, quotedImages.length > 0 ? quotedImages : undefined, userId, userName, msg.message_id);
|
|
153
173
|
}
|
|
154
174
|
}
|
|
@@ -171,6 +191,27 @@ export class FeishuChannel {
|
|
|
171
191
|
await this.messageHandler(msg.chat_id, prompt, quotedImages.length > 0 ? quotedImages : undefined, userId, userName, msg.message_id);
|
|
172
192
|
}
|
|
173
193
|
}
|
|
194
|
+
// 处理富文本消息
|
|
195
|
+
else if (msg.message_type === 'post') {
|
|
196
|
+
const parsed = JSON.parse(msg.content);
|
|
197
|
+
let text = '';
|
|
198
|
+
const title = parsed.zh_cn?.title || parsed.en_us?.title || parsed.title;
|
|
199
|
+
const content = parsed.zh_cn?.content || parsed.en_us?.content || parsed.content;
|
|
200
|
+
if (content) {
|
|
201
|
+
for (const line of content) {
|
|
202
|
+
for (const elem of line) {
|
|
203
|
+
if (elem.text)
|
|
204
|
+
text += elem.text;
|
|
205
|
+
}
|
|
206
|
+
text += '\n';
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
let finalContent = text.trim();
|
|
210
|
+
if (title)
|
|
211
|
+
finalContent = `${title}\n${finalContent}`;
|
|
212
|
+
finalContent = quotedText + finalContent;
|
|
213
|
+
await this.messageHandler(msg.chat_id, finalContent, quotedImages.length > 0 ? quotedImages : undefined, userId, userName, msg.message_id);
|
|
214
|
+
}
|
|
174
215
|
// 处理其他类型消息
|
|
175
216
|
else {
|
|
176
217
|
logger.debug('[Feishu] Unsupported message type:', msg.message_type);
|
|
@@ -250,10 +291,27 @@ export class FeishuChannel {
|
|
|
250
291
|
logger.debug(`[Feishu] sendMessage called, chatId: ${chatId}, content length: ${content.length}`);
|
|
251
292
|
try {
|
|
252
293
|
const useMarkdown = !options?.forceText && hasMarkdownSyntax(content);
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
294
|
+
const hasMention = !!(options?.mentionUserIds && options.mentionUserIds.length > 0);
|
|
295
|
+
// 如果需要 @,强制使用 post 格式
|
|
296
|
+
const msgType = (useMarkdown || hasMention) ? 'post' : 'text';
|
|
297
|
+
let msgContent;
|
|
298
|
+
if (hasMention) {
|
|
299
|
+
// 构造带 @ 的富文本消息
|
|
300
|
+
const postData = useMarkdown
|
|
301
|
+
? markdownToFeishuPost(content, options?.title)
|
|
302
|
+
: { zh_cn: { title: options?.title || '', content: [[{ tag: 'text', text: content }]] } };
|
|
303
|
+
// 在第一行开头插入所有 @ 标签
|
|
304
|
+
if (postData.zh_cn.content.length > 0) {
|
|
305
|
+
const atTags = options.mentionUserIds.map(uid => ({ tag: 'at', user_id: uid }));
|
|
306
|
+
postData.zh_cn.content[0].unshift(...atTags);
|
|
307
|
+
}
|
|
308
|
+
msgContent = JSON.stringify(postData);
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
msgContent = useMarkdown
|
|
312
|
+
? JSON.stringify(markdownToFeishuPost(content, options?.title))
|
|
313
|
+
: JSON.stringify({ text: content });
|
|
314
|
+
}
|
|
257
315
|
if (options?.replyToMessageId) {
|
|
258
316
|
await this.client.im.message.reply({
|
|
259
317
|
path: { message_id: options.replyToMessageId },
|
package/dist/channels/wechat.js
CHANGED
|
@@ -17,8 +17,58 @@ const SESSION_PAUSE_DURATION_MS = 10 * 60 * 1000; // 长暂停:10 分钟
|
|
|
17
17
|
const MSG_TYPE_USER = 1;
|
|
18
18
|
const MSG_TYPE_BOT = 2;
|
|
19
19
|
const MSG_ITEM_TEXT = 1;
|
|
20
|
+
const MSG_ITEM_IMAGE = 2;
|
|
20
21
|
const MSG_ITEM_VOICE = 3;
|
|
22
|
+
const MSG_ITEM_FILE = 4;
|
|
23
|
+
const MSG_ITEM_VIDEO = 5;
|
|
21
24
|
const MSG_STATE_FINISH = 2;
|
|
25
|
+
// ── CDN + AES ───────────────────────────────────────────────────────────────
|
|
26
|
+
const CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c';
|
|
27
|
+
const MIME_MAP = {
|
|
28
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
|
29
|
+
'.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp',
|
|
30
|
+
'.mp4': 'video/mp4', '.mov': 'video/quicktime', '.avi': 'video/x-msvideo',
|
|
31
|
+
'.pdf': 'application/pdf', '.doc': 'application/msword',
|
|
32
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
33
|
+
'.xls': 'application/vnd.ms-excel',
|
|
34
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
35
|
+
'.zip': 'application/zip', '.rar': 'application/x-rar-compressed',
|
|
36
|
+
'.txt': 'text/plain', '.csv': 'text/csv', '.md': 'text/markdown',
|
|
37
|
+
};
|
|
38
|
+
// Exported for unit testing
|
|
39
|
+
export function parseAesKey(aesKeyBase64) {
|
|
40
|
+
const decoded = Buffer.from(aesKeyBase64, 'base64');
|
|
41
|
+
if (decoded.length === 16)
|
|
42
|
+
return decoded;
|
|
43
|
+
if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString('ascii')))
|
|
44
|
+
return Buffer.from(decoded.toString('ascii'), 'hex');
|
|
45
|
+
throw new Error(`Invalid aes_key length: ${decoded.length}`);
|
|
46
|
+
}
|
|
47
|
+
// Exported for unit testing
|
|
48
|
+
export function decryptAesEcb(ciphertext, key) {
|
|
49
|
+
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null);
|
|
50
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
51
|
+
}
|
|
52
|
+
// Exported for unit testing
|
|
53
|
+
export function encryptAesEcb(plaintext, key) {
|
|
54
|
+
const cipher = crypto.createCipheriv('aes-128-ecb', key, null);
|
|
55
|
+
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
56
|
+
}
|
|
57
|
+
async function downloadMedia(cdnMedia, hexKey) {
|
|
58
|
+
const aesKeyBase64 = hexKey
|
|
59
|
+
? Buffer.from(hexKey, 'hex').toString('base64')
|
|
60
|
+
: cdnMedia.aes_key;
|
|
61
|
+
if (!cdnMedia.encrypt_query_param)
|
|
62
|
+
throw new Error('No encrypt_query_param');
|
|
63
|
+
const url = `${CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(cdnMedia.encrypt_query_param)}`;
|
|
64
|
+
const res = await fetch(url);
|
|
65
|
+
if (!res.ok)
|
|
66
|
+
throw new Error(`CDN download failed: ${res.status}`);
|
|
67
|
+
const encrypted = Buffer.from(await res.arrayBuffer());
|
|
68
|
+
if (!aesKeyBase64)
|
|
69
|
+
return encrypted; // 无 key = 明文
|
|
70
|
+
return decryptAesEcb(encrypted, parseAesKey(aesKeyBase64));
|
|
71
|
+
}
|
|
22
72
|
// ── Markdown → Plain Text ───────────────────────────────────────────────────
|
|
23
73
|
function markdownToPlainText(text) {
|
|
24
74
|
let result = text;
|
|
@@ -91,6 +141,8 @@ export class WechatChannel {
|
|
|
91
141
|
// Session expired 状态
|
|
92
142
|
sessionPausedUntil = 0;
|
|
93
143
|
onSessionExpired;
|
|
144
|
+
// Project path resolver(用于保存文件到 uploads 目录)
|
|
145
|
+
projectPathResolver;
|
|
94
146
|
constructor(config) {
|
|
95
147
|
this.config = config;
|
|
96
148
|
const dataDir = resolvePaths().dataDir;
|
|
@@ -179,6 +231,64 @@ export class WechatChannel {
|
|
|
179
231
|
throw err;
|
|
180
232
|
}
|
|
181
233
|
}
|
|
234
|
+
/** 注册 projectPath 解析器,用于保存接收的文件 */
|
|
235
|
+
onProjectPathRequest(resolver) {
|
|
236
|
+
this.projectPathResolver = resolver;
|
|
237
|
+
}
|
|
238
|
+
/** 发送文件(图片/视频/文件)给用户,通过 CDN 上传 */
|
|
239
|
+
async sendFile(to, filePath) {
|
|
240
|
+
// Session 暂停期间拒绝发送
|
|
241
|
+
if (this.isSessionPaused()) {
|
|
242
|
+
logger.warn(`[WeChat] Session paused, dropping file send to ${to}`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const contextToken = this.contextTokenCache.get(to);
|
|
246
|
+
if (!contextToken) {
|
|
247
|
+
logger.error(`[WeChat] No context_token for ${to}, cannot send file`);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (!fs.existsSync(filePath)) {
|
|
251
|
+
logger.error(`[WeChat] File not found: ${filePath}`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
const plaintext = Buffer.from(fs.readFileSync(filePath));
|
|
256
|
+
const rawsize = plaintext.length;
|
|
257
|
+
const rawfilemd5 = crypto.createHash('md5').update(plaintext).digest('hex');
|
|
258
|
+
const aeskey = crypto.randomBytes(16);
|
|
259
|
+
const filekey = crypto.randomBytes(16).toString('hex');
|
|
260
|
+
const filesize = Math.ceil((rawsize + 1) / 16) * 16;
|
|
261
|
+
// MIME → UploadMediaType
|
|
262
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
263
|
+
const mime = MIME_MAP[ext] || 'application/octet-stream';
|
|
264
|
+
const uploadMediaType = mime.startsWith('image/') ? 1
|
|
265
|
+
: mime.startsWith('video/') ? 2 : 3;
|
|
266
|
+
// Step 1: getuploadurl
|
|
267
|
+
const uploadResp = await this.getUploadUrl({
|
|
268
|
+
filekey, media_type: uploadMediaType, to_user_id: to,
|
|
269
|
+
rawsize, rawfilemd5, filesize,
|
|
270
|
+
aeskey: aeskey.toString('hex'),
|
|
271
|
+
no_need_thumb: true,
|
|
272
|
+
});
|
|
273
|
+
// Step 2: encrypt + upload to CDN
|
|
274
|
+
const ciphertext = encryptAesEcb(plaintext, aeskey);
|
|
275
|
+
const downloadParam = await this.cdnUpload(uploadResp.upload_param, filekey, ciphertext);
|
|
276
|
+
// Step 3: sendmessage with CDN reference
|
|
277
|
+
const cdnMedia = {
|
|
278
|
+
encrypt_query_param: downloadParam,
|
|
279
|
+
aes_key: Buffer.from(aeskey.toString('hex')).toString('base64'),
|
|
280
|
+
encrypt_type: 1,
|
|
281
|
+
};
|
|
282
|
+
const itemType = mime.startsWith('image/') ? MSG_ITEM_IMAGE
|
|
283
|
+
: mime.startsWith('video/') ? MSG_ITEM_VIDEO : MSG_ITEM_FILE;
|
|
284
|
+
const item = this.buildMediaItem(itemType, cdnMedia, filePath, filesize, rawsize);
|
|
285
|
+
await this.sendMediaMessage(to, item, contextToken);
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
logger.error(`[WeChat] Failed to send file ${filePath} to ${to}:`, err);
|
|
289
|
+
throw err;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
182
292
|
// ── Long-Poll Loop ────────────────────────────────────────────────────
|
|
183
293
|
async pollLoop(signal) {
|
|
184
294
|
let consecutiveFailures = 0;
|
|
@@ -315,22 +425,29 @@ export class WechatChannel {
|
|
|
315
425
|
async handleInboundMessage(msg) {
|
|
316
426
|
if (msg.message_type !== MSG_TYPE_USER)
|
|
317
427
|
return;
|
|
318
|
-
const text = extractTextFromMessage(msg);
|
|
319
|
-
if (!text)
|
|
320
|
-
return;
|
|
321
428
|
const fromUserId = msg.from_user_id ?? '';
|
|
322
429
|
// 缓存 context_token
|
|
323
430
|
if (msg.context_token) {
|
|
324
431
|
this.contextTokenCache.set(fromUserId, msg.context_token);
|
|
325
432
|
this.persistContextTokens();
|
|
326
433
|
}
|
|
327
|
-
|
|
434
|
+
// 提取文本(原有逻辑)
|
|
435
|
+
const text = extractTextFromMessage(msg);
|
|
436
|
+
// 提取媒体 → 下载
|
|
437
|
+
const media = await this.extractMedia(msg, fromUserId);
|
|
438
|
+
// 合成最终内容
|
|
439
|
+
const finalContent = media.prompt
|
|
440
|
+
? (text ? `${text}\n\n${media.prompt}` : media.prompt)
|
|
441
|
+
: text;
|
|
442
|
+
if (!finalContent && !media.images.length)
|
|
443
|
+
return;
|
|
444
|
+
logger.info(`[WeChat] Received: from=${fromUserId} text=${(finalContent || '').slice(0, 50)} images=${media.images.length}...`);
|
|
328
445
|
// 发送 typing 指示器(异步,不阻塞)
|
|
329
446
|
this.acknowledgeMessage(fromUserId, msg.context_token).catch(() => { });
|
|
330
447
|
// 回调主流程
|
|
331
448
|
if (this.messageHandler) {
|
|
332
449
|
try {
|
|
333
|
-
await this.messageHandler(fromUserId,
|
|
450
|
+
await this.messageHandler(fromUserId, finalContent || '', fromUserId, media.images.length ? media.images : undefined);
|
|
334
451
|
}
|
|
335
452
|
catch (err) {
|
|
336
453
|
logger.error('[WeChat] Message handler error:', err);
|
|
@@ -379,6 +496,122 @@ export class WechatChannel {
|
|
|
379
496
|
}
|
|
380
497
|
return undefined;
|
|
381
498
|
}
|
|
499
|
+
// ── Media Extraction (Inbound) ────────────────────────────────────────
|
|
500
|
+
async extractMedia(msg, channelId) {
|
|
501
|
+
const images = [];
|
|
502
|
+
const prompts = [];
|
|
503
|
+
for (const item of msg.item_list ?? []) {
|
|
504
|
+
try {
|
|
505
|
+
if (item.type === MSG_ITEM_IMAGE && item.image_item?.media) {
|
|
506
|
+
const buf = await downloadMedia(item.image_item.media, item.image_item.aeskey);
|
|
507
|
+
images.push({ data: buf.toString('base64'), mimeType: 'image/jpeg' });
|
|
508
|
+
}
|
|
509
|
+
else if (item.type === MSG_ITEM_FILE && item.file_item?.media) {
|
|
510
|
+
const buf = await downloadMedia(item.file_item.media);
|
|
511
|
+
const fileName = this.sanitizeFileName(item.file_item.file_name || `file_${Date.now()}`);
|
|
512
|
+
const savePath = await this.saveToUploads(buf, fileName, channelId);
|
|
513
|
+
prompts.push(`用户发送了文件:${fileName}\n文件已保存到:${savePath}\n请使用 Read 工具读取并分析文件内容。`);
|
|
514
|
+
}
|
|
515
|
+
else if (item.type === MSG_ITEM_VIDEO && item.video_item?.media) {
|
|
516
|
+
const buf = await downloadMedia(item.video_item.media);
|
|
517
|
+
const fileName = `video_${Date.now()}.mp4`;
|
|
518
|
+
const savePath = await this.saveToUploads(buf, fileName, channelId);
|
|
519
|
+
prompts.push(`用户发送了视频:${fileName}\n文件已保存到:${savePath}`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
catch (err) {
|
|
523
|
+
logger.error(`[WeChat] Failed to download media type=${item.type}:`, err);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return { prompt: prompts.join('\n\n'), images };
|
|
527
|
+
}
|
|
528
|
+
async saveToUploads(buf, fileName, channelId) {
|
|
529
|
+
const projectPath = this.projectPathResolver
|
|
530
|
+
? await this.projectPathResolver(channelId)
|
|
531
|
+
: process.cwd();
|
|
532
|
+
const uploadsDir = path.join(projectPath, '.claude', 'uploads');
|
|
533
|
+
fs.mkdirSync(uploadsDir, { recursive: true });
|
|
534
|
+
const savePath = path.join(uploadsDir, fileName);
|
|
535
|
+
fs.writeFileSync(savePath, buf);
|
|
536
|
+
return savePath;
|
|
537
|
+
}
|
|
538
|
+
/** 清理文件名:移除路径穿越字符,只保留 basename */
|
|
539
|
+
sanitizeFileName(name) {
|
|
540
|
+
return path.basename(name).replace(/[<>:"|?*\x00-\x1f]/g, '_') || `file_${Date.now()}`;
|
|
541
|
+
}
|
|
542
|
+
// ── Media Upload (Outbound) ──────────────────────────────────────────
|
|
543
|
+
async getUploadUrl(params) {
|
|
544
|
+
const body = JSON.stringify({
|
|
545
|
+
...params,
|
|
546
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
547
|
+
});
|
|
548
|
+
const raw = await this.apiFetch('ilink/bot/getuploadurl', body, DEFAULT_API_TIMEOUT_MS);
|
|
549
|
+
const resp = JSON.parse(raw);
|
|
550
|
+
if (!resp.upload_param)
|
|
551
|
+
throw new Error('getuploadurl: no upload_param');
|
|
552
|
+
return resp;
|
|
553
|
+
}
|
|
554
|
+
async cdnUpload(uploadParam, filekey, ciphertext) {
|
|
555
|
+
const url = `${CDN_BASE_URL}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${filekey}`;
|
|
556
|
+
let lastError;
|
|
557
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
558
|
+
try {
|
|
559
|
+
const res = await fetch(url, {
|
|
560
|
+
method: 'POST',
|
|
561
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
562
|
+
body: new Uint8Array(ciphertext),
|
|
563
|
+
});
|
|
564
|
+
if (res.status >= 400 && res.status < 500) {
|
|
565
|
+
throw new Error(`CDN upload client error: ${res.status}`);
|
|
566
|
+
}
|
|
567
|
+
if (!res.ok)
|
|
568
|
+
throw new Error(`CDN upload failed: ${res.status}`);
|
|
569
|
+
const downloadParam = res.headers.get('x-encrypted-param');
|
|
570
|
+
if (!downloadParam)
|
|
571
|
+
throw new Error('Missing x-encrypted-param header');
|
|
572
|
+
return downloadParam;
|
|
573
|
+
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
lastError = err;
|
|
576
|
+
if (err.message?.includes('client error'))
|
|
577
|
+
throw err; // 4xx 不重试
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
throw lastError;
|
|
581
|
+
}
|
|
582
|
+
buildMediaItem(itemType, cdnMedia, filePath, ciphertextSize, plaintextSize) {
|
|
583
|
+
if (itemType === MSG_ITEM_IMAGE) {
|
|
584
|
+
return { type: MSG_ITEM_IMAGE, image_item: { media: cdnMedia, mid_size: ciphertextSize } };
|
|
585
|
+
}
|
|
586
|
+
if (itemType === MSG_ITEM_VIDEO) {
|
|
587
|
+
return { type: MSG_ITEM_VIDEO, video_item: { media: cdnMedia, video_size: ciphertextSize } };
|
|
588
|
+
}
|
|
589
|
+
return {
|
|
590
|
+
type: MSG_ITEM_FILE,
|
|
591
|
+
file_item: {
|
|
592
|
+
media: cdnMedia,
|
|
593
|
+
file_name: path.basename(filePath),
|
|
594
|
+
len: String(plaintextSize),
|
|
595
|
+
},
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
async sendMediaMessage(to, item, contextToken) {
|
|
599
|
+
const clientId = `evolclaw-wechat:${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
|
600
|
+
const body = {
|
|
601
|
+
msg: {
|
|
602
|
+
from_user_id: '',
|
|
603
|
+
to_user_id: to,
|
|
604
|
+
client_id: clientId,
|
|
605
|
+
message_type: MSG_TYPE_BOT,
|
|
606
|
+
message_state: MSG_STATE_FINISH,
|
|
607
|
+
item_list: [item],
|
|
608
|
+
context_token: contextToken,
|
|
609
|
+
},
|
|
610
|
+
base_info: { channel_version: CHANNEL_VERSION },
|
|
611
|
+
};
|
|
612
|
+
await this.apiFetch('ilink/bot/sendmessage', JSON.stringify(body), DEFAULT_API_TIMEOUT_MS);
|
|
613
|
+
logger.info(`[WeChat] Sent media to ${to}, type=${item.type}`);
|
|
614
|
+
}
|
|
382
615
|
// ── ilink API Helpers ─────────────────────────────────────────────────
|
|
383
616
|
async apiFetch(endpoint, body, timeoutMs, externalSignal) {
|
|
384
617
|
const base = this.config.baseUrl.endsWith('/') ? this.config.baseUrl : `${this.config.baseUrl}/`;
|