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 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(Windows 暂不支持,见 TODO)
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
- - [ ] Windows 系统 CLI 命令支持
229
- - [ ] 微信插件支持图片/文件的收发
228
+ - [x] Windows 系统 CLI 命令支持
229
+ - [x] 微信插件支持图片/文件的收发
230
230
  - [ ] 自动授权可配置(自动放行/自动拒绝)
231
231
  - [ ] 手动授权支持(飞书卡片/文本回复)
232
232
  - [ ] ACP 协议支持(接入 Codex / Gemini CLI)
@@ -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
- quotedText = `> [文件消息]\n\n`;
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
- let content = parsed.text_without_at_bot || parsed.text;
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:resource 权限';
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 msgType = useMarkdown ? 'post' : 'text';
254
- const msgContent = useMarkdown
255
- ? JSON.stringify(markdownToFeishuPost(content, options?.title))
256
- : JSON.stringify({ text: content });
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 },
@@ -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
- logger.info(`[WeChat] Received: from=${fromUserId} text=${text.slice(0, 50)}...`);
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, text, 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}/`;