evolclaw 2.1.2 → 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.
- package/README.md +10 -3
- package/data/evolclaw.sample.json +9 -1
- package/dist/agents/claude-runner.js +612 -0
- package/dist/agents/codex-runner.js +310 -0
- package/dist/channels/aun.js +416 -9
- package/dist/channels/feishu.js +397 -104
- package/dist/channels/wechat.js +84 -2
- package/dist/cli.js +427 -126
- package/dist/config.js +102 -4
- package/dist/core/adapters/claude-session-file-adapter.js +144 -0
- package/dist/core/adapters/codex-session-file-adapter.js +196 -0
- package/dist/core/agent-loader.js +39 -0
- package/dist/core/channel-loader.js +60 -0
- package/dist/core/command-handler.js +908 -304
- package/dist/core/event-bus.js +32 -0
- package/dist/core/ipc-server.js +71 -0
- package/dist/core/message-bridge.js +187 -0
- package/dist/core/message-processor.js +370 -227
- package/dist/core/message-queue.js +153 -29
- package/dist/core/permission.js +58 -0
- package/dist/core/session-file-adapter.js +7 -0
- package/dist/core/session-manager.js +567 -205
- package/dist/core/stats-collector.js +86 -0
- package/dist/index.js +309 -243
- package/dist/paths.js +1 -0
- package/dist/utils/init-feishu.js +2 -0
- package/dist/utils/init-wechat.js +2 -0
- package/dist/utils/init.js +285 -53
- package/dist/utils/ipc-client.js +36 -0
- package/dist/utils/migrate-project.js +122 -0
- package/dist/utils/{permission.js → permission-utils.js} +31 -3
- package/dist/utils/rich-content-renderer.js +228 -0
- package/dist/utils/session-file-health.js +11 -34
- package/dist/utils/stream-debouncer.js +122 -0
- package/dist/utils/stream-idle-monitor.js +1 -1
- package/package.json +3 -1
- package/dist/core/agent-runner.js +0 -348
- package/dist/core/message-stream.js +0 -59
- package/dist/index.js.bak +0 -340
- package/dist/utils/markdown-to-feishu.js +0 -94
- /package/dist/utils/{platform.js → cross-platform.js} +0 -0
- /package/dist/{core → utils}/message-cache.js +0 -0
package/dist/channels/feishu.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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.
|
|
20
|
-
this.initChatTypeTable();
|
|
23
|
+
this.enableRichContent = config.enableRichContent ?? true; // 默认启用
|
|
21
24
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
|
255
|
-
|
|
296
|
+
async getUserName(userId) {
|
|
297
|
+
if (!userId || !this.client)
|
|
298
|
+
return undefined;
|
|
256
299
|
// 检查缓存
|
|
257
|
-
if (this.
|
|
258
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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 (
|
|
282
|
-
logger.
|
|
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
|
-
|
|
302
|
-
|
|
350
|
+
const hasRichImages = richItemsWithKeys.length > 0;
|
|
351
|
+
// 如果有富内容图片、Markdown 或 @,使用 post 格式
|
|
352
|
+
const msgType = (useMarkdown || hasMention || hasRichImages) ? 'post' : 'text';
|
|
303
353
|
let msgContent;
|
|
304
|
-
if (
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
494
|
+
async sendImage(chatId, png, options) {
|
|
495
|
+
if (!this.client)
|
|
496
|
+
return;
|
|
385
497
|
try {
|
|
386
|
-
const
|
|
387
|
-
|
|
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
|
-
|
|
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, '.
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
}
|