evolclaw 2.1.1 → 2.2.0

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.
Files changed (43) hide show
  1. package/README.md +10 -3
  2. package/data/evolclaw.sample.json +9 -1
  3. package/dist/agents/claude-runner.js +612 -0
  4. package/dist/agents/codex-runner.js +310 -0
  5. package/dist/channels/aun.js +416 -9
  6. package/dist/channels/feishu.js +397 -104
  7. package/dist/channels/wechat.js +84 -2
  8. package/dist/cli.js +427 -126
  9. package/dist/config.js +102 -4
  10. package/dist/core/adapters/claude-session-file-adapter.js +144 -0
  11. package/dist/core/adapters/codex-session-file-adapter.js +196 -0
  12. package/dist/core/agent-loader.js +39 -0
  13. package/dist/core/channel-loader.js +60 -0
  14. package/dist/core/command-handler.js +908 -304
  15. package/dist/core/event-bus.js +32 -0
  16. package/dist/core/ipc-server.js +71 -0
  17. package/dist/core/message-bridge.js +187 -0
  18. package/dist/core/message-processor.js +370 -227
  19. package/dist/core/message-queue.js +153 -29
  20. package/dist/core/permission.js +58 -0
  21. package/dist/core/session-file-adapter.js +7 -0
  22. package/dist/core/session-manager.js +571 -223
  23. package/dist/core/stats-collector.js +86 -0
  24. package/dist/index.js +309 -243
  25. package/dist/paths.js +1 -0
  26. package/dist/utils/error-utils.js +4 -2
  27. package/dist/utils/init-feishu.js +2 -0
  28. package/dist/utils/init-wechat.js +2 -0
  29. package/dist/utils/init.js +285 -53
  30. package/dist/utils/ipc-client.js +36 -0
  31. package/dist/utils/migrate-project.js +122 -0
  32. package/dist/utils/{permission.js → permission-utils.js} +31 -3
  33. package/dist/utils/rich-content-renderer.js +228 -0
  34. package/dist/utils/session-file-health.js +11 -34
  35. package/dist/utils/stream-debouncer.js +122 -0
  36. package/dist/utils/stream-idle-monitor.js +1 -1
  37. package/package.json +3 -1
  38. package/dist/core/agent-runner.js +0 -348
  39. package/dist/core/message-stream.js +0 -59
  40. package/dist/index.js.bak +0 -340
  41. package/dist/utils/markdown-to-feishu.js +0 -94
  42. /package/dist/utils/{platform.js → cross-platform.js} +0 -0
  43. /package/dist/{core → utils}/message-cache.js +0 -0
@@ -4,29 +4,33 @@ import path from 'path';
4
4
  import imageType from 'image-type';
5
5
  import { ensureDir } from '../config.js';
6
6
  import { logger } from '../utils/logger.js';
7
- import { markdownToFeishuPost, hasMarkdownSyntax } from '../utils/markdown-to-feishu.js';
7
+ import { hasRichContent, renderAllRichContent, checkDependencies } from '../utils/rich-content-renderer.js';
8
8
  export class FeishuChannel {
9
9
  config;
10
10
  client = null;
11
11
  wsClient = null;
12
12
  messageHandler;
13
13
  projectPathProvider;
14
- db;
15
14
  cleanupInterval;
16
- chatTypeCache = new Map();
15
+ seenMessages = new Map(); // messageId -> timestamp
16
+ seenThreads = new Set(); // 已见的 thread_id,用于判断话题创建消息
17
+ userNameCache = new Map(); // userId -> userName
18
+ recallHandler;
19
+ connected = false;
20
+ enableRichContent;
17
21
  constructor(config) {
18
22
  this.config = config;
19
- this.db = config.db;
20
- this.initChatTypeTable();
23
+ this.enableRichContent = config.enableRichContent ?? true; // 默认启用
21
24
  }
22
- initChatTypeTable() {
23
- this.db.exec(`
24
- CREATE TABLE IF NOT EXISTS chat_types (
25
- chat_id TEXT PRIMARY KEY,
26
- chat_mode TEXT NOT NULL,
27
- updated_at INTEGER NOT NULL
28
- )
29
- `);
25
+ /**
26
+ * 预填充已知的 thread_id(重启后从数据库恢复,避免误判话题创建)
27
+ */
28
+ preloadThreads(threadIds) {
29
+ for (const id of threadIds)
30
+ this.seenThreads.add(id);
31
+ if (threadIds.length > 0) {
32
+ logger.info(`[Feishu] Preloaded ${threadIds.length} known thread(s)`);
33
+ }
30
34
  }
31
35
  async connect() {
32
36
  // 检查配置有效性
@@ -51,10 +55,11 @@ export class FeishuChannel {
51
55
  return;
52
56
  }
53
57
  this.markSeen(msg.message_id);
54
- this.addAckReaction(msg.message_id);
55
58
  if (!this.messageHandler)
56
59
  return;
57
- // 话题消息检测日志(去重后)
60
+ // 提取 chatType(从 SDK 事件直接获取)
61
+ const chatType = msg.chat_type === 'group' ? 'group' : 'private';
62
+ // 话题消息检测日志
58
63
  if (msg.thread_id) {
59
64
  logger.info('[Feishu] Thread message, thread_id:', msg.thread_id, 'root_id:', msg.root_id);
60
65
  }
@@ -65,24 +70,37 @@ export class FeishuChannel {
65
70
  key: m.key
66
71
  })).filter((m) => m.userId && m.userId !== this.config.appId);
67
72
  // 提取发送者信息
68
- const userId = data.sender?.sender_id?.open_id;
69
- let userName;
70
- try {
71
- userName = await this.getUserName(userId);
73
+ const peerId = data.sender?.sender_id?.open_id;
74
+ // 尝试从 mentions 中查找发送者名字(群聊中可能包含)
75
+ let peerName;
76
+ if (mentions.length > 0) {
77
+ const senderMention = mentions.find((m) => m.userId === peerId);
78
+ peerName = senderMention?.name;
72
79
  }
73
- catch {
74
- userName = undefined;
80
+ // 如果 mentions 中没有,尝试调用 API 获取
81
+ if (!peerName && peerId) {
82
+ try {
83
+ peerName = await this.getUserName(peerId);
84
+ }
85
+ catch (err) {
86
+ logger.debug('[Feishu] getUserName error:', err);
87
+ }
75
88
  }
76
89
  try {
77
90
  // 提取话题信息
78
91
  const threadId = msg.thread_id || undefined;
79
92
  const rootId = msg.root_id || undefined;
80
- // 处理引用消息(话题内消息跳过,避免每条都拼接引用前缀)
93
+ // 处理引用消息(话题内后续消息跳过,避免每条都拼接引用前缀)
81
94
  let quotedText = '';
82
95
  let quotedImages = [];
83
- // 话题创建消息检测:DB 中无对应 thread session 时为首条消息
84
- const isThreadCreating = threadId && !this.hasThreadSession(threadId);
85
- if (msg.parent_id && (!msg.thread_id || isThreadCreating) && this.client) {
96
+ // 引用消息处理:
97
+ // - 非话题:直接回复某条消息时,拉取被引用的消息内容
98
+ // - 话题首条:创建话题时,拉取根消息内容作为上下文
99
+ // - 话题后续:不拉取(上下文由 session 维护)
100
+ const isThreadCreation = !!(msg.thread_id && msg.parent_id && !this.seenThreads.has(msg.thread_id));
101
+ if (msg.thread_id)
102
+ this.seenThreads.add(msg.thread_id);
103
+ if (msg.parent_id && (!msg.thread_id || isThreadCreation) && this.client) {
86
104
  try {
87
105
  const res = await this.client.im.message.get({
88
106
  path: { message_id: msg.parent_id }
@@ -154,9 +172,11 @@ export class FeishuChannel {
154
172
  if (msg.message_type === 'text') {
155
173
  const parsed = JSON.parse(msg.content);
156
174
  // 优先使用 text_without_at_bot(去除机器人 @),否则使用 text
157
- const content = parsed.text_without_at_bot || parsed.text;
175
+ let content = (parsed.text_without_at_bot || parsed.text || '').trim();
176
+ // 清理残留的 mention 占位符(@_user_N 代表机器人)
177
+ content = content.replace(/@_user_\d+/g, '').trim();
158
178
  const finalContent = quotedText + content;
159
- await this.messageHandler({ channelId: msg.chat_id, content: finalContent, images: quotedImages.length > 0 ? quotedImages : undefined, userId, userName, messageId: msg.message_id, mentions: mentions.length > 0 ? mentions : undefined, threadId, rootId });
179
+ await this.messageHandler({ channelId: msg.chat_id, content: finalContent, images: quotedImages.length > 0 ? quotedImages : undefined, peerId, peerName, messageId: msg.message_id, mentions: mentions.length > 0 ? mentions : undefined, threadId, rootId, chatType });
160
180
  }
161
181
  // 处理图片消息
162
182
  else if (msg.message_type === 'image') {
@@ -170,11 +190,11 @@ export class FeishuChannel {
170
190
  if (imageData) {
171
191
  const allImages = [...quotedImages, imageData];
172
192
  const prompt = quotedText + '用户发送了一张图片,请分析这张图片的内容。';
173
- await this.messageHandler({ channelId: msg.chat_id, content: prompt, images: allImages, userId, userName, messageId: msg.message_id, threadId, rootId });
193
+ await this.messageHandler({ channelId: msg.chat_id, content: prompt, images: allImages, peerId, peerName, messageId: msg.message_id, threadId, rootId, chatType });
174
194
  }
175
195
  else {
176
196
  const prompt = quotedText + '[图片下载失败] 应用可能缺少 im:message 或 im:message:readonly 权限';
177
- await this.messageHandler({ channelId: msg.chat_id, content: prompt, images: quotedImages.length > 0 ? quotedImages : undefined, userId, userName, messageId: msg.message_id, threadId, rootId });
197
+ await this.messageHandler({ channelId: msg.chat_id, content: prompt, images: quotedImages.length > 0 ? quotedImages : undefined, peerId, peerName, messageId: msg.message_id, threadId, rootId, chatType });
178
198
  }
179
199
  }
180
200
  // 处理文件消息
@@ -189,24 +209,34 @@ export class FeishuChannel {
189
209
  const filePath = await this.downloadFile(fileKey, fileName, msg.message_id, projectPath);
190
210
  if (filePath) {
191
211
  const prompt = quotedText + `用户发送了文件:${fileName}\n文件已保存到:${filePath}\n请使用 Read 工具读取并分析文件内容。`;
192
- await this.messageHandler({ channelId: msg.chat_id, content: prompt, images: quotedImages.length > 0 ? quotedImages : undefined, userId, userName, messageId: msg.message_id, threadId, rootId });
212
+ await this.messageHandler({ channelId: msg.chat_id, content: prompt, images: quotedImages.length > 0 ? quotedImages : undefined, peerId, peerName, messageId: msg.message_id, threadId, rootId, chatType });
193
213
  }
194
214
  else {
195
215
  const prompt = quotedText + '[文件下载失败] 应用可能缺少 im:resource 权限';
196
- await this.messageHandler({ channelId: msg.chat_id, content: prompt, images: quotedImages.length > 0 ? quotedImages : undefined, userId, userName, messageId: msg.message_id, threadId, rootId });
216
+ await this.messageHandler({ channelId: msg.chat_id, content: prompt, images: quotedImages.length > 0 ? quotedImages : undefined, peerId, peerName, messageId: msg.message_id, threadId, rootId, chatType });
197
217
  }
198
218
  }
199
219
  // 处理富文本消息
200
220
  else if (msg.message_type === 'post') {
201
221
  const parsed = JSON.parse(msg.content);
202
222
  let text = '';
223
+ const postImages = [];
203
224
  const title = parsed.zh_cn?.title || parsed.en_us?.title || parsed.title;
204
225
  const content = parsed.zh_cn?.content || parsed.en_us?.content || parsed.content;
205
226
  if (content) {
227
+ const projectPath = this.projectPathProvider
228
+ ? await this.projectPathProvider(msg.chat_id)
229
+ : process.cwd();
206
230
  for (const line of content) {
207
231
  for (const elem of line) {
208
- if (elem.text)
232
+ if (elem.tag === 'img' && elem.image_key) {
233
+ const imageData = await this.downloadAndSaveImage(elem.image_key, msg.chat_id, msg.message_id, projectPath);
234
+ if (imageData)
235
+ postImages.push(imageData);
236
+ }
237
+ else if (elem.text) {
209
238
  text += elem.text;
239
+ }
210
240
  }
211
241
  text += '\n';
212
242
  }
@@ -215,19 +245,27 @@ export class FeishuChannel {
215
245
  if (title)
216
246
  finalContent = `${title}\n${finalContent}`;
217
247
  finalContent = quotedText + finalContent;
218
- await this.messageHandler({ channelId: msg.chat_id, content: finalContent, images: quotedImages.length > 0 ? quotedImages : undefined, userId, userName, messageId: msg.message_id, threadId, rootId });
248
+ const allImages = [...quotedImages, ...postImages];
249
+ await this.messageHandler({ channelId: msg.chat_id, content: finalContent, images: allImages.length > 0 ? allImages : undefined, peerId, peerName, messageId: msg.message_id, threadId, rootId, chatType });
219
250
  }
220
251
  // 处理其他类型消息
221
252
  else {
222
253
  logger.debug('[Feishu] Unsupported message type:', msg.message_type);
223
254
  const prompt = quotedText + `[不支持的消息类型: ${msg.message_type}]`;
224
- await this.messageHandler({ channelId: msg.chat_id, content: prompt, images: quotedImages.length > 0 ? quotedImages : undefined, userId, userName, messageId: msg.message_id, threadId, rootId });
255
+ await this.messageHandler({ channelId: msg.chat_id, content: prompt, images: quotedImages.length > 0 ? quotedImages : undefined, peerId, peerName, messageId: msg.message_id, threadId, rootId, chatType });
225
256
  }
226
257
  }
227
258
  catch (error) {
228
259
  logger.error('[Feishu] Failed to process message:', error);
229
260
  }
230
261
  },
262
+ 'im.message.recalled_v1': async (data) => {
263
+ const messageId = data?.message_id;
264
+ if (messageId) {
265
+ logger.info('[Feishu] Message recalled:', messageId);
266
+ this.recallHandler?.(messageId);
267
+ }
268
+ },
231
269
  'im.message.message_read_v1': async () => { },
232
270
  'im.message.reaction.created_v1': async () => { }
233
271
  });
@@ -236,6 +274,7 @@ export class FeishuChannel {
236
274
  appSecret: this.config.appSecret,
237
275
  });
238
276
  await this.wsClient.start({ eventDispatcher });
277
+ this.connected = true;
239
278
  this.startCleanupTask();
240
279
  }
241
280
  catch (error) {
@@ -248,43 +287,33 @@ export class FeishuChannel {
248
287
  onMessage(handler) {
249
288
  this.messageHandler = handler;
250
289
  }
290
+ onRecall(handler) {
291
+ this.recallHandler = handler;
292
+ }
251
293
  onProjectPathRequest(provider) {
252
294
  this.projectPathProvider = provider;
253
295
  }
254
- async getChatMode(chatId) {
255
- logger.info(`[Feishu] getChatMode called for chatId: ${chatId}`);
296
+ async getUserName(userId) {
297
+ if (!userId || !this.client)
298
+ return undefined;
256
299
  // 检查缓存
257
- if (this.chatTypeCache.has(chatId)) {
258
- logger.info(`[Feishu] getChatMode from cache: ${this.chatTypeCache.get(chatId)}`);
259
- return this.chatTypeCache.get(chatId);
300
+ if (this.userNameCache.has(userId)) {
301
+ return this.userNameCache.get(userId);
260
302
  }
261
- // 检查数据库
262
- const row = this.db.prepare('SELECT chat_mode FROM chat_types WHERE chat_id = ?').get(chatId);
263
- if (row) {
264
- logger.info(`[Feishu] getChatMode from db: ${row.chat_mode}`);
265
- this.chatTypeCache.set(chatId, row.chat_mode);
266
- return row.chat_mode;
267
- }
268
- // 调用 API 获取
269
- if (!this.client)
270
- return 'p2p';
271
303
  try {
272
- logger.info(`[Feishu] Calling API to get chat mode for ${chatId}`);
273
- const res = await this.client.im.chat.get({ path: { chat_id: chatId } });
274
- const chatMode = res.data?.chat_mode || 'p2p';
275
- logger.info(`[Feishu] API returned chat_mode: ${chatMode}`);
276
- // 保存到数据库和缓存
277
- this.db.prepare('INSERT OR REPLACE INTO chat_types (chat_id, chat_mode, updated_at) VALUES (?, ?, ?)').run(chatId, chatMode, Date.now());
278
- this.chatTypeCache.set(chatId, chatMode);
279
- return chatMode;
304
+ const res = await this.client.contact.user.get({
305
+ path: { user_id: userId },
306
+ params: { user_id_type: 'open_id' }
307
+ });
308
+ const userName = res.data?.user?.name;
309
+ if (userName) {
310
+ this.userNameCache.set(userId, userName);
311
+ return userName;
312
+ }
280
313
  }
281
- catch (error) {
282
- logger.warn('[Feishu] Failed to get chat mode, defaulting to p2p:', error);
283
- return 'p2p';
314
+ catch (err) {
315
+ logger.debug('[Feishu] Failed to get user name, code:', err?.code, 'msg:', err?.message);
284
316
  }
285
- }
286
- async getUserName(_userId) {
287
- // TODO: 需要开通 contact:contact.base:readonly 权限后启用
288
317
  return undefined;
289
318
  }
290
319
  async sendMessage(chatId, content, options) {
@@ -296,27 +325,74 @@ export class FeishuChannel {
296
325
  }
297
326
  logger.debug(`[Feishu] sendMessage called, chatId: ${chatId}, content length: ${content.length}`);
298
327
  try {
328
+ // 检测富内容并渲染(受 enableRichContent 开关控制,且依赖必须可用)
329
+ const richItems = (this.enableRichContent && checkDependencies() && hasRichContent(content))
330
+ ? await renderAllRichContent(content)
331
+ : [];
332
+ // 上传所有图片获取 image_key,建立位置映射
333
+ const richItemsWithKeys = [];
334
+ for (const item of richItems) {
335
+ try {
336
+ const uploadResponse = await this.client.im.image.create({
337
+ data: { image_type: 'message', image: Buffer.from(item.png) }
338
+ });
339
+ if (uploadResponse?.image_key) {
340
+ richItemsWithKeys.push({ start: item.start, end: item.end, imageKey: uploadResponse.image_key });
341
+ logger.debug(`[Feishu] Uploaded ${item.type} image, image_key:`, uploadResponse.image_key);
342
+ }
343
+ }
344
+ catch (err) {
345
+ logger.warn(`[Feishu] Failed to upload ${item.type} image:`, err);
346
+ }
347
+ }
299
348
  const useMarkdown = !options?.forceText && hasMarkdownSyntax(content);
300
349
  const hasMention = !!(options?.mentionUserIds && options.mentionUserIds.length > 0);
301
- // 如果需要 @,强制使用 post 格式
302
- const msgType = (useMarkdown || hasMention) ? 'post' : 'text';
350
+ const hasRichImages = richItemsWithKeys.length > 0;
351
+ // 如果有富内容图片、Markdown @,使用 post 格式
352
+ const msgType = (useMarkdown || hasMention || hasRichImages) ? 'post' : 'text';
303
353
  let msgContent;
304
- if (hasMention) {
305
- // 构造带 @ 的富文本消息
306
- const postData = useMarkdown
307
- ? markdownToFeishuPost(content, options?.title)
308
- : { zh_cn: { title: options?.title || '', content: [[{ tag: 'text', text: content }]] } };
354
+ if (msgType === 'post') {
355
+ let postData;
356
+ if (hasRichImages) {
357
+ // 有富内容图片:按位置分段文本并插入图片
358
+ postData = { zh_cn: { title: options?.title || '', content: [] } };
359
+ const sorted = [...richItemsWithKeys].sort((a, b) => a.start - b.start);
360
+ let lastEnd = 0;
361
+ for (const item of sorted) {
362
+ // 插入图片前的文本段
363
+ if (item.start > lastEnd) {
364
+ const textSegment = content.slice(lastEnd, item.start).trim();
365
+ if (textSegment) {
366
+ postData.zh_cn.content.push([{ tag: 'text', text: textSegment }]);
367
+ }
368
+ }
369
+ // 插入图片
370
+ postData.zh_cn.content.push([{ tag: 'img', image_key: item.imageKey }]);
371
+ lastEnd = item.end;
372
+ }
373
+ // 插入最后一段文本
374
+ if (lastEnd < content.length) {
375
+ const textSegment = content.slice(lastEnd).trim();
376
+ if (textSegment) {
377
+ postData.zh_cn.content.push([{ tag: 'text', text: textSegment }]);
378
+ }
379
+ }
380
+ }
381
+ else {
382
+ // 无富内容图片:使用原有逻辑
383
+ postData = useMarkdown
384
+ ? markdownToFeishuPost(content, options?.title)
385
+ : { zh_cn: { title: options?.title || '', content: [[{ tag: 'text', text: content }]] } };
386
+ }
309
387
  // 在第一行开头插入所有 @ 标签
310
- if (postData.zh_cn.content.length > 0) {
388
+ if (hasMention && postData.zh_cn.content.length > 0) {
311
389
  const atTags = options.mentionUserIds.map(uid => ({ tag: 'at', user_id: uid }));
312
390
  postData.zh_cn.content[0].unshift(...atTags);
313
391
  }
314
392
  msgContent = JSON.stringify(postData);
315
393
  }
316
394
  else {
317
- msgContent = useMarkdown
318
- ? JSON.stringify(markdownToFeishuPost(content, options?.title))
319
- : JSON.stringify({ text: content });
395
+ msgContent = JSON.stringify({ text: content });
320
396
  }
321
397
  if (options?.replyToMessageId) {
322
398
  const replyData = { msg_type: msgType, content: msgContent };
@@ -334,7 +410,12 @@ export class FeishuChannel {
334
410
  data: { receive_id: chatId, msg_type: msgType, content: msgContent }
335
411
  });
336
412
  }
337
- logger.debug(`[Feishu] Sent message as ${useMarkdown ? 'post (Markdown)' : 'text'}`);
413
+ if (hasRichImages) {
414
+ logger.info(`[Feishu] Sent message with ${richItemsWithKeys.length} embedded images`);
415
+ }
416
+ else {
417
+ logger.debug(`[Feishu] Sent message as ${useMarkdown ? 'post (Markdown)' : 'text'}`);
418
+ }
338
419
  }
339
420
  catch (error) {
340
421
  // 230011: 消息已被撤回,降级为普通消息重试
@@ -346,10 +427,21 @@ export class FeishuChannel {
346
427
  throw error;
347
428
  }
348
429
  }
349
- async sendFile(chatId, filePath) {
430
+ async sendFile(chatId, filePath, options) {
350
431
  if (!this.client)
351
432
  return;
352
433
  try {
434
+ // 检测是否为图片,是则走 sendImage(内联预览)而非文件卡片
435
+ const header = Buffer.alloc(12);
436
+ const fd = fs.openSync(filePath, 'r');
437
+ fs.readSync(fd, header, 0, 12, 0);
438
+ fs.closeSync(fd);
439
+ const imgType = await imageType(header);
440
+ if (imgType) {
441
+ logger.info(`[Feishu] Detected image (${imgType.mime}), sending as inline image:`, filePath);
442
+ const buf = fs.readFileSync(filePath);
443
+ return this.sendImage(chatId, buf, options);
444
+ }
353
445
  logger.info('[Feishu] Uploading file:', filePath);
354
446
  const fileStream = fs.createReadStream(filePath);
355
447
  const fileName = path.basename(filePath);
@@ -365,32 +457,104 @@ export class FeishuChannel {
365
457
  return;
366
458
  }
367
459
  const fileKey = uploadResponse.file_key;
460
+ const msgContent = JSON.stringify({ file_key: fileKey });
368
461
  logger.info('[Feishu] File uploaded, file_key:', fileKey);
369
- await this.client.im.message.create({
370
- params: { receive_id_type: 'chat_id' },
371
- data: {
372
- receive_id: chatId,
373
- msg_type: 'file',
374
- content: JSON.stringify({ file_key: fileKey })
462
+ if (options?.replyToMessageId) {
463
+ const replyData = { msg_type: 'file', content: msgContent };
464
+ if (options.replyInThread) {
465
+ replyData.reply_in_thread = true;
375
466
  }
376
- });
467
+ await this.client.im.message.reply({
468
+ path: { message_id: options.replyToMessageId },
469
+ data: replyData
470
+ });
471
+ }
472
+ else {
473
+ await this.client.im.message.create({
474
+ params: { receive_id_type: 'chat_id' },
475
+ data: {
476
+ receive_id: chatId,
477
+ msg_type: 'file',
478
+ content: msgContent
479
+ }
480
+ });
481
+ }
377
482
  logger.info('[Feishu] File message sent successfully');
378
483
  }
379
484
  catch (error) {
485
+ // 230011: 消息已被撤回,降级为普通消息重试
486
+ if (error.response?.data?.code === 230011 && options?.replyToMessageId) {
487
+ logger.warn('[Feishu] Message withdrawn (230011), retrying file send without reply');
488
+ return this.sendFile(chatId, filePath);
489
+ }
380
490
  logger.error('[Feishu] Failed to send file:', error);
381
491
  throw error;
382
492
  }
383
493
  }
384
- hasThreadSession(threadId) {
494
+ async sendImage(chatId, png, options) {
495
+ if (!this.client)
496
+ return;
385
497
  try {
386
- const row = this.db.prepare('SELECT 1 FROM sessions WHERE thread_id = ? LIMIT 1').get(threadId);
387
- return !!row;
498
+ const uploadResponse = await this.client.im.image.create({
499
+ data: {
500
+ image_type: 'message',
501
+ image: Buffer.from(png),
502
+ }
503
+ });
504
+ const imageKey = uploadResponse?.image_key;
505
+ if (!imageKey) {
506
+ logger.error('[Feishu] Image upload failed: no image_key returned');
507
+ return;
508
+ }
509
+ logger.debug('[Feishu] Image uploaded, image_key:', imageKey);
510
+ const msgContent = JSON.stringify({ image_key: imageKey });
511
+ if (options?.replyToMessageId) {
512
+ const replyData = { msg_type: 'image', content: msgContent };
513
+ if (options.replyInThread)
514
+ replyData.reply_in_thread = true;
515
+ await this.client.im.message.reply({
516
+ path: { message_id: options.replyToMessageId },
517
+ data: replyData
518
+ });
519
+ }
520
+ else {
521
+ await this.client.im.message.create({
522
+ params: { receive_id_type: 'chat_id' },
523
+ data: { receive_id: chatId, msg_type: 'image', content: msgContent }
524
+ });
525
+ }
526
+ logger.debug('[Feishu] Image message sent successfully');
388
527
  }
389
- catch {
390
- return false;
528
+ catch (error) {
529
+ logger.error('[Feishu] Failed to send image:', error);
530
+ throw error;
391
531
  }
392
532
  }
533
+ isDuplicate(msgId) {
534
+ return this.seenMessages.has(msgId);
535
+ }
536
+ markSeen(msgId) {
537
+ this.seenMessages.set(msgId, Date.now());
538
+ }
539
+ startCleanupTask() {
540
+ this.cleanupInterval = setInterval(() => {
541
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
542
+ let cleaned = 0;
543
+ for (const [id, ts] of this.seenMessages) {
544
+ if (ts < cutoff) {
545
+ this.seenMessages.delete(id);
546
+ cleaned++;
547
+ }
548
+ }
549
+ if (cleaned > 0)
550
+ logger.info(`[Feishu] Cleaned ${cleaned} old message IDs`);
551
+ // seenThreads 无时间戳,仅限容量(话题持久存在,不按时间清理)
552
+ if (this.seenThreads.size > 1000)
553
+ this.seenThreads.clear();
554
+ }, 60 * 60 * 1000);
555
+ }
393
556
  async disconnect() {
557
+ this.connected = false;
394
558
  if (this.cleanupInterval) {
395
559
  clearInterval(this.cleanupInterval);
396
560
  this.cleanupInterval = undefined;
@@ -401,6 +565,23 @@ export class FeishuChannel {
401
565
  }
402
566
  this.client = null;
403
567
  }
568
+ /** Get current connection status */
569
+ getStatus() {
570
+ return { connected: this.connected };
571
+ }
572
+ /** Reconnect: disconnect then connect again */
573
+ async reconnect() {
574
+ if (this.connected) {
575
+ await this.disconnect();
576
+ }
577
+ try {
578
+ await this.connect();
579
+ return '重连成功';
580
+ }
581
+ catch (err) {
582
+ return `重连失败: ${err instanceof Error ? err.message : String(err)}`;
583
+ }
584
+ }
404
585
  async downloadAndSaveImage(imageKey, chatId, messageId, projectPath) {
405
586
  if (!this.client)
406
587
  return null;
@@ -490,7 +671,7 @@ export class FeishuChannel {
490
671
  logger.warn('[Feishu] Empty response from file download');
491
672
  return null;
492
673
  }
493
- const uploadsDir = path.join(projectPath, '.claude', 'uploads');
674
+ const uploadsDir = path.join(projectPath, '.evolclaw', 'uploads');
494
675
  ensureDir(uploadsDir);
495
676
  const filePath = path.join(uploadsDir, fileName);
496
677
  fs.writeFileSync(filePath, buffer);
@@ -505,13 +686,6 @@ export class FeishuChannel {
505
686
  return null;
506
687
  }
507
688
  }
508
- isDuplicate(msgId) {
509
- const result = this.db.prepare('SELECT 1 FROM processed_messages WHERE message_id = ? LIMIT 1').get(msgId);
510
- return !!result;
511
- }
512
- markSeen(msgId) {
513
- this.db.prepare('INSERT OR IGNORE INTO processed_messages (message_id, channel, channel_id, processed_at) VALUES (?, ?, ?, ?)').run(msgId, 'feishu', '', Date.now());
514
- }
515
689
  addAckReaction(messageId) {
516
690
  if (!this.client)
517
691
  return;
@@ -522,13 +696,132 @@ export class FeishuChannel {
522
696
  }
523
697
  }).catch(() => { });
524
698
  }
525
- startCleanupTask() {
526
- this.cleanupInterval = setInterval(() => {
527
- const cutoff = Date.now() - 24 * 60 * 60 * 1000;
528
- const result = this.db.prepare('DELETE FROM processed_messages WHERE processed_at < ?').run(cutoff);
529
- if (result.changes > 0) {
530
- logger.info(`[Feishu] Cleaned ${result.changes} old processed messages`);
531
- }
532
- }, 60 * 60 * 1000);
699
+ }
700
+ function displayWidth(str) {
701
+ let width = 0;
702
+ for (const ch of str) {
703
+ const code = ch.codePointAt(0);
704
+ if ((code >= 0x4E00 && code <= 0x9FFF) ||
705
+ (code >= 0x3400 && code <= 0x4DBF) ||
706
+ (code >= 0xF900 && code <= 0xFAFF) ||
707
+ (code >= 0xFF01 && code <= 0xFF60) ||
708
+ (code >= 0x3000 && code <= 0x303F)) {
709
+ width += 2;
710
+ }
711
+ else {
712
+ width += 1;
713
+ }
714
+ }
715
+ return width;
716
+ }
717
+ function padToWidth(str, targetWidth) {
718
+ const current = displayWidth(str);
719
+ const padding = Math.max(0, targetWidth - current);
720
+ return str + ' '.repeat(padding);
721
+ }
722
+ function convertTablesToText(text) {
723
+ const tableRegex = /^(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)+)/gm;
724
+ return text.replace(tableRegex, (_match, headerLine, _sep, bodyBlock) => {
725
+ const parseRow = (line) => line.split('|').slice(1, -1).map((c) => c.trim());
726
+ const headers = parseRow(headerLine);
727
+ const rows = bodyBlock.trim().split('\n').map(parseRow);
728
+ const colWidths = headers.map((h, i) => {
729
+ const cellWidths = rows.map(r => displayWidth(r[i] || ''));
730
+ return Math.max(displayWidth(h), ...cellWidths);
731
+ });
732
+ const headerStr = headers.map((h, i) => padToWidth(h, colWidths[i])).join(' ');
733
+ const sepStr = colWidths.map(w => '-'.repeat(w)).join(' ');
734
+ const rowStrs = rows.map(r => headers.map((_, i) => padToWidth(r[i] || '', colWidths[i])).join(' '));
735
+ return '```\n' + [headerStr, sepStr, ...rowStrs].join('\n') + '\n```';
736
+ });
737
+ }
738
+ export function markdownToFeishuPost(markdown, defaultTitle) {
739
+ const match = markdown.match(/^# (.+)$/m);
740
+ const title = match?.[1] ?? defaultTitle ?? '';
741
+ let body = match ? markdown.replace(/^# .+\n?/, '') : markdown;
742
+ body = convertTablesToText(body);
743
+ return {
744
+ zh_cn: {
745
+ title,
746
+ content: [[{ tag: 'md', text: body.trim() }]]
747
+ }
748
+ };
749
+ }
750
+ export function hasMarkdownSyntax(text) {
751
+ const markdownPatterns = [
752
+ /^#{1,6}\s/m, /\*\*.*?\*\*/, /\*.*?\*/, /__.*?__/, /_.*?_/, /~~.*?~~/,
753
+ /`.*?`/, /```[\s\S]*?```/, /\[.*?\]\(.*?\)/, /^[\s]*[-*+]\s/m,
754
+ /^[\s]*\d+\.\s/m, /^\|.+\|$/m
755
+ ];
756
+ return markdownPatterns.some(pattern => pattern.test(text));
757
+ }
758
+ export class FeishuChannelPlugin {
759
+ name = 'feishu';
760
+ isEnabled(config) {
761
+ const feishuConfig = config.channels?.feishu;
762
+ if (feishuConfig?.enabled === false)
763
+ return false;
764
+ return !!(feishuConfig?.appId && feishuConfig?.appSecret);
765
+ }
766
+ async createChannel(config) {
767
+ const feishuConfig = config.channels?.feishu;
768
+ if (!feishuConfig?.appId || !feishuConfig?.appSecret) {
769
+ throw new Error('Feishu config missing');
770
+ }
771
+ const channel = new FeishuChannel({
772
+ appId: feishuConfig.appId,
773
+ appSecret: feishuConfig.appSecret,
774
+ enableRichContent: feishuConfig.enableRichContent,
775
+ });
776
+ const adapter = {
777
+ name: 'feishu',
778
+ sendText: (id, text, context) => channel.sendMessage(id, text, context),
779
+ sendFile: (id, filePath, context) => channel.sendFile(id, filePath, context),
780
+ sendImage: (id, png, context) => channel.sendImage(id, png, context),
781
+ acknowledge: (messageId) => { channel.addAckReaction(messageId); return Promise.resolve(); },
782
+ };
783
+ const policy = {
784
+ canSwitchProject: (chatType, identity) => identity === 'owner',
785
+ canListProjects: (chatType, identity) => identity === 'owner',
786
+ canCreateSession: (chatType, identity) => true,
787
+ canDeleteSession: (chatType, identity) => true,
788
+ canImportCliSession: (chatType, identity) => identity === 'owner',
789
+ messagePrefix: (chatType, peerName) => (chatType === 'group' && peerName) ? `[${peerName}] ` : '',
790
+ showMiddleResult: (chatType, identity) => {
791
+ const mode = feishuConfig.showActivities ?? config.showActivities ?? 'all';
792
+ if (mode === 'none')
793
+ return false;
794
+ if (mode === 'dm-only')
795
+ return chatType === 'private';
796
+ if (mode === 'owner-dm-only')
797
+ return chatType === 'private' && identity === 'owner';
798
+ return true;
799
+ },
800
+ showIdleMonitor: (chatType, identity) => {
801
+ const mode = feishuConfig.showActivities ?? config.showActivities ?? 'all';
802
+ if (mode === 'none')
803
+ return false;
804
+ if (mode === 'dm-only')
805
+ return chatType === 'private';
806
+ if (mode === 'owner-dm-only')
807
+ return chatType === 'private' && identity === 'owner';
808
+ return true;
809
+ },
810
+ accumulateErrors: (chatType, identity) => true,
811
+ };
812
+ const options = {
813
+ fileMarkerPattern: /\[SEND_FILE:(?:(\w+):)?([^\]]+)\]/g,
814
+ supportsImages: true,
815
+ flushDelay: feishuConfig.flushDelay,
816
+ };
817
+ return {
818
+ adapter,
819
+ channel,
820
+ policy,
821
+ options,
822
+ connect: () => channel.connect(),
823
+ disconnect: () => channel.disconnect(),
824
+ onProjectPathRequest: (channelId) => Promise.resolve(config.projects?.defaultPath || process.cwd()),
825
+ };
533
826
  }
534
827
  }