evolclaw 2.1.2 → 2.3.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 (54) hide show
  1. package/README.md +59 -30
  2. package/data/evolclaw.sample.json +15 -4
  3. package/dist/agents/claude-runner.js +685 -0
  4. package/dist/agents/codex-runner.js +315 -0
  5. package/dist/agents/gemini-runner.js +425 -0
  6. package/dist/channels/aun.js +580 -10
  7. package/dist/channels/feishu.js +888 -135
  8. package/dist/channels/wechat.js +127 -21
  9. package/dist/cli.js +519 -136
  10. package/dist/config.js +277 -25
  11. package/dist/core/agent-loader.js +39 -0
  12. package/dist/core/channel-loader.js +67 -0
  13. package/dist/core/command-handler.js +1537 -392
  14. package/dist/core/event-bus.js +32 -0
  15. package/dist/core/interaction-router.js +68 -0
  16. package/dist/core/message/message-bridge.js +216 -0
  17. package/dist/core/message/message-processor.js +1028 -0
  18. package/dist/core/message/message-queue.js +240 -0
  19. package/dist/core/message/stream-debouncer.js +122 -0
  20. package/dist/{utils → core/message}/stream-flusher.js +73 -13
  21. package/dist/{utils → core/message}/stream-idle-monitor.js +1 -1
  22. package/dist/core/permission.js +259 -0
  23. package/dist/core/session/adapters/claude-session-file-adapter.js +144 -0
  24. package/dist/core/session/adapters/codex-session-file-adapter.js +261 -0
  25. package/dist/core/session/adapters/gemini-session-file-adapter.js +177 -0
  26. package/dist/core/session/session-file-adapter.js +7 -0
  27. package/dist/core/session/session-file-health.js +45 -0
  28. package/dist/core/session/session-manager.js +1072 -0
  29. package/dist/index.js +402 -252
  30. package/dist/ipc.js +106 -0
  31. package/dist/paths.js +1 -0
  32. package/dist/types.js +3 -0
  33. package/dist/utils/{platform.js → cross-platform.js} +38 -1
  34. package/dist/utils/error-utils.js +130 -5
  35. package/dist/utils/init-channel.js +649 -0
  36. package/dist/utils/init.js +190 -53
  37. package/dist/utils/logger.js +8 -3
  38. package/dist/utils/media-cache.js +207 -0
  39. package/dist/utils/migrate-project.js +122 -0
  40. package/dist/utils/rich-content-renderer.js +228 -0
  41. package/dist/utils/stats-collector.js +102 -0
  42. package/package.json +4 -2
  43. package/dist/core/agent-runner.js +0 -348
  44. package/dist/core/message-processor.js +0 -604
  45. package/dist/core/message-queue.js +0 -116
  46. package/dist/core/message-stream.js +0 -59
  47. package/dist/core/session-manager.js +0 -664
  48. package/dist/index.js.bak +0 -340
  49. package/dist/utils/init-feishu.js +0 -261
  50. package/dist/utils/init-wechat.js +0 -170
  51. package/dist/utils/markdown-to-feishu.js +0 -94
  52. package/dist/utils/permission.js +0 -43
  53. package/dist/utils/session-file-health.js +0 -68
  54. /package/dist/core/{message-cache.js → message/message-cache.js} +0 -0
@@ -2,31 +2,36 @@ import * as lark from '@larksuiteoapi/node-sdk';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
4
  import imageType from 'image-type';
5
- import { ensureDir } from '../config.js';
5
+ import { sanitizeFileName, saveToUploads, validateImage } from '../utils/media-cache.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
+ interactionCallback;
20
+ connected = false;
21
+ enableRichContent;
17
22
  constructor(config) {
18
23
  this.config = config;
19
- this.db = config.db;
20
- this.initChatTypeTable();
21
- }
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
- `);
24
+ this.enableRichContent = config.enableRichContent ?? false; // 默认关闭
25
+ }
26
+ /**
27
+ * 预填充已知的 thread_id(重启后从数据库恢复,避免误判话题创建)
28
+ */
29
+ preloadThreads(threadIds) {
30
+ for (const id of threadIds)
31
+ this.seenThreads.add(id);
32
+ if (threadIds.length > 0) {
33
+ logger.info(`[Feishu] Preloaded ${threadIds.length} known thread(s)`);
34
+ }
30
35
  }
31
36
  async connect() {
32
37
  // 检查配置有效性
@@ -51,10 +56,11 @@ export class FeishuChannel {
51
56
  return;
52
57
  }
53
58
  this.markSeen(msg.message_id);
54
- this.addAckReaction(msg.message_id);
55
59
  if (!this.messageHandler)
56
60
  return;
57
- // 话题消息检测日志(去重后)
61
+ // 提取 chatType(从 SDK 事件直接获取)
62
+ const chatType = msg.chat_type === 'group' ? 'group' : 'private';
63
+ // 话题消息检测日志
58
64
  if (msg.thread_id) {
59
65
  logger.info('[Feishu] Thread message, thread_id:', msg.thread_id, 'root_id:', msg.root_id);
60
66
  }
@@ -65,24 +71,37 @@ export class FeishuChannel {
65
71
  key: m.key
66
72
  })).filter((m) => m.userId && m.userId !== this.config.appId);
67
73
  // 提取发送者信息
68
- const userId = data.sender?.sender_id?.open_id;
69
- let userName;
70
- try {
71
- userName = await this.getUserName(userId);
74
+ const peerId = data.sender?.sender_id?.open_id;
75
+ // 尝试从 mentions 中查找发送者名字(群聊中可能包含)
76
+ let peerName;
77
+ if (mentions.length > 0) {
78
+ const senderMention = mentions.find((m) => m.userId === peerId);
79
+ peerName = senderMention?.name;
72
80
  }
73
- catch {
74
- userName = undefined;
81
+ // 如果 mentions 中没有,尝试调用 API 获取
82
+ if (!peerName && peerId) {
83
+ try {
84
+ peerName = await this.getUserName(peerId);
85
+ }
86
+ catch (err) {
87
+ logger.debug('[Feishu] getUserName error:', err);
88
+ }
75
89
  }
76
90
  try {
77
91
  // 提取话题信息
78
92
  const threadId = msg.thread_id || undefined;
79
93
  const rootId = msg.root_id || undefined;
80
- // 处理引用消息(话题内消息跳过,避免每条都拼接引用前缀)
94
+ // 处理引用消息(话题内后续消息跳过,避免每条都拼接引用前缀)
81
95
  let quotedText = '';
82
96
  let quotedImages = [];
83
- // 话题创建消息检测:DB 中无对应 thread session 时为首条消息
84
- const isThreadCreating = threadId && !this.hasThreadSession(threadId);
85
- if (msg.parent_id && (!msg.thread_id || isThreadCreating) && this.client) {
97
+ // 引用消息处理:
98
+ // - 非话题:直接回复某条消息时,拉取被引用的消息内容
99
+ // - 话题首条:创建话题时,拉取根消息内容作为上下文
100
+ // - 话题后续:不拉取(上下文由 session 维护)
101
+ const isThreadCreation = !!(msg.thread_id && msg.parent_id && !this.seenThreads.has(msg.thread_id));
102
+ if (msg.thread_id)
103
+ this.seenThreads.add(msg.thread_id);
104
+ if (msg.parent_id && (!msg.thread_id || isThreadCreation) && this.client) {
86
105
  try {
87
106
  const res = await this.client.im.message.get({
88
107
  path: { message_id: msg.parent_id }
@@ -94,7 +113,7 @@ export class FeishuChannel {
94
113
  const quotedContent = res.data.items[0].body.content;
95
114
  if (quotedMsgType === 'text') {
96
115
  const parsed = JSON.parse(quotedContent);
97
- quotedText = `> ${parsed.text}\n\n`;
116
+ quotedText = `> 以下是引用的原消息\n> ================\n> ${parsed.text}\n> ================\n\n`;
98
117
  }
99
118
  else if (quotedMsgType === 'post') {
100
119
  const parsed = JSON.parse(quotedContent);
@@ -110,7 +129,7 @@ export class FeishuChannel {
110
129
  text += '\n';
111
130
  }
112
131
  }
113
- quotedText = `> ${text.trim()}\n\n`;
132
+ quotedText = `> 以下是引用的原消息\n> ================\n> ${text.trim()}\n> ================\n\n`;
114
133
  }
115
134
  else if (quotedMsgType === 'image') {
116
135
  const parsed = JSON.parse(quotedContent);
@@ -121,10 +140,10 @@ export class FeishuChannel {
121
140
  const imageData = await this.downloadAndSaveImage(imageKey, msg.chat_id, msg.parent_id, projectPath);
122
141
  if (imageData) {
123
142
  quotedImages.push(imageData);
124
- quotedText = `> [引用的图片]\n\n`;
143
+ quotedText = `> 以下是引用的原消息\n> ================\n> [引用的图片]\n> ================\n\n`;
125
144
  }
126
145
  else {
127
- quotedText = `> [图片消息]\n\n`;
146
+ quotedText = `> 以下是引用的原消息\n> ================\n> [图片消息]\n> ================\n\n`;
128
147
  }
129
148
  }
130
149
  else if (quotedMsgType === 'file') {
@@ -136,14 +155,14 @@ export class FeishuChannel {
136
155
  : process.cwd();
137
156
  const quotedFilePath = await this.downloadFile(quotedFileKey, quotedFileName, msg.parent_id, projectPath);
138
157
  if (quotedFilePath) {
139
- quotedText = `> [引用的文件:${quotedFileName}]\n> 文件已保存到:${quotedFilePath}\n\n`;
158
+ quotedText = `> 以下是引用的原消息\n> ================\n> [引用的文件:${quotedFileName}]\n> 文件已保存到:${quotedFilePath}\n> ================\n\n`;
140
159
  }
141
160
  else {
142
- quotedText = `> [文件消息]\n\n`;
161
+ quotedText = `> 以下是引用的原消息\n> ================\n> [文件消息]\n> ================\n\n`;
143
162
  }
144
163
  }
145
164
  else {
146
- quotedText = `> [${quotedMsgType}消息]\n\n`;
165
+ quotedText = `> 以下是引用的原消息\n> ================\n> [${quotedMsgType}消息]\n> ================\n\n`;
147
166
  }
148
167
  }
149
168
  catch (err) {
@@ -154,9 +173,11 @@ export class FeishuChannel {
154
173
  if (msg.message_type === 'text') {
155
174
  const parsed = JSON.parse(msg.content);
156
175
  // 优先使用 text_without_at_bot(去除机器人 @),否则使用 text
157
- const content = parsed.text_without_at_bot || parsed.text;
176
+ let content = (parsed.text_without_at_bot || parsed.text || '').trim();
177
+ // 清理残留的 mention 占位符(@_user_N 代表机器人)
178
+ content = content.replace(/@_user_\d+/g, '').trim();
158
179
  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 });
180
+ 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
181
  }
161
182
  // 处理图片消息
162
183
  else if (msg.message_type === 'image') {
@@ -170,11 +191,11 @@ export class FeishuChannel {
170
191
  if (imageData) {
171
192
  const allImages = [...quotedImages, imageData];
172
193
  const prompt = quotedText + '用户发送了一张图片,请分析这张图片的内容。';
173
- await this.messageHandler({ channelId: msg.chat_id, content: prompt, images: allImages, userId, userName, messageId: msg.message_id, threadId, rootId });
194
+ await this.messageHandler({ channelId: msg.chat_id, content: prompt, images: allImages, peerId, peerName, messageId: msg.message_id, threadId, rootId, chatType });
174
195
  }
175
196
  else {
176
197
  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 });
198
+ 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
199
  }
179
200
  }
180
201
  // 处理文件消息
@@ -189,24 +210,34 @@ export class FeishuChannel {
189
210
  const filePath = await this.downloadFile(fileKey, fileName, msg.message_id, projectPath);
190
211
  if (filePath) {
191
212
  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 });
213
+ 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
214
  }
194
215
  else {
195
216
  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 });
217
+ 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
218
  }
198
219
  }
199
220
  // 处理富文本消息
200
221
  else if (msg.message_type === 'post') {
201
222
  const parsed = JSON.parse(msg.content);
202
223
  let text = '';
224
+ const postImages = [];
203
225
  const title = parsed.zh_cn?.title || parsed.en_us?.title || parsed.title;
204
226
  const content = parsed.zh_cn?.content || parsed.en_us?.content || parsed.content;
205
227
  if (content) {
228
+ const projectPath = this.projectPathProvider
229
+ ? await this.projectPathProvider(msg.chat_id)
230
+ : process.cwd();
206
231
  for (const line of content) {
207
232
  for (const elem of line) {
208
- if (elem.text)
233
+ if (elem.tag === 'img' && elem.image_key) {
234
+ const imageData = await this.downloadAndSaveImage(elem.image_key, msg.chat_id, msg.message_id, projectPath);
235
+ if (imageData)
236
+ postImages.push(imageData);
237
+ }
238
+ else if (elem.text) {
209
239
  text += elem.text;
240
+ }
210
241
  }
211
242
  text += '\n';
212
243
  }
@@ -215,27 +246,75 @@ export class FeishuChannel {
215
246
  if (title)
216
247
  finalContent = `${title}\n${finalContent}`;
217
248
  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 });
249
+ const allImages = [...quotedImages, ...postImages];
250
+ 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
251
  }
220
252
  // 处理其他类型消息
221
253
  else {
222
254
  logger.debug('[Feishu] Unsupported message type:', msg.message_type);
223
255
  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 });
256
+ 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
257
  }
226
258
  }
227
259
  catch (error) {
228
260
  logger.error('[Feishu] Failed to process message:', error);
229
261
  }
230
262
  },
263
+ 'im.message.recalled_v1': async (data) => {
264
+ const messageId = data?.message_id;
265
+ if (messageId) {
266
+ logger.info('[Feishu] Message recalled:', messageId);
267
+ this.recallHandler?.(messageId);
268
+ }
269
+ },
231
270
  'im.message.message_read_v1': async () => { },
232
- 'im.message.reaction.created_v1': async () => { }
271
+ 'im.message.reaction.created_v1': async () => { },
272
+ 'card.action.trigger': async (data) => {
273
+ try {
274
+ const action = data?.action;
275
+ if (!action?.value)
276
+ return;
277
+ const value = action.value;
278
+ const requestId = value._request_id;
279
+ if (!requestId) {
280
+ logger.debug('[Feishu] Card action without _request_id, ignoring');
281
+ return;
282
+ }
283
+ // Legacy field change (non-form select_static with _field_key): ignore silently
284
+ if (value._field_key) {
285
+ logger.debug(`[Feishu] Legacy field change: requestId=${requestId}, field=${value._field_key}`);
286
+ return;
287
+ }
288
+ // Form submit: `action.form_value` contains all field values from form container
289
+ const formValues = action.form_value || {};
290
+ const response = {
291
+ type: 'interaction.response',
292
+ id: requestId,
293
+ action: value._action || 'submit',
294
+ values: { ...formValues, ...value },
295
+ operatorId: data.operator?.open_id,
296
+ };
297
+ // Remove internal fields from values
298
+ delete response.values._request_id;
299
+ delete response.values._action;
300
+ delete response.values._card_title;
301
+ logger.info(`[Feishu] Card action: requestId=${requestId}, action=${response.action}, values=${JSON.stringify(response.values)}`);
302
+ this.interactionCallback?.(response);
303
+ // Return updated card (buttons disabled + result shown)
304
+ const cardTitle = value._card_title || '操作';
305
+ return this.buildResolvedCard(cardTitle, response);
306
+ }
307
+ catch (err) {
308
+ logger.error('[Feishu] Failed to handle card action:', err);
309
+ }
310
+ },
233
311
  });
234
312
  this.wsClient = new lark.WSClient({
235
313
  appId: this.config.appId,
236
314
  appSecret: this.config.appSecret,
237
315
  });
238
316
  await this.wsClient.start({ eventDispatcher });
317
+ this.connected = true;
239
318
  this.startCleanupTask();
240
319
  }
241
320
  catch (error) {
@@ -248,43 +327,36 @@ export class FeishuChannel {
248
327
  onMessage(handler) {
249
328
  this.messageHandler = handler;
250
329
  }
330
+ onRecall(handler) {
331
+ this.recallHandler = handler;
332
+ }
333
+ onInteraction(callback) {
334
+ this.interactionCallback = callback;
335
+ }
251
336
  onProjectPathRequest(provider) {
252
337
  this.projectPathProvider = provider;
253
338
  }
254
- async getChatMode(chatId) {
255
- logger.info(`[Feishu] getChatMode called for chatId: ${chatId}`);
339
+ async getUserName(userId) {
340
+ if (!userId || !this.client)
341
+ return undefined;
256
342
  // 检查缓存
257
- if (this.chatTypeCache.has(chatId)) {
258
- logger.info(`[Feishu] getChatMode from cache: ${this.chatTypeCache.get(chatId)}`);
259
- return this.chatTypeCache.get(chatId);
260
- }
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';
343
+ if (this.userNameCache.has(userId)) {
344
+ return this.userNameCache.get(userId);
345
+ }
271
346
  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;
347
+ const res = await this.client.contact.user.get({
348
+ path: { user_id: userId },
349
+ params: { user_id_type: 'open_id' }
350
+ });
351
+ const userName = res.data?.user?.name;
352
+ if (userName) {
353
+ this.userNameCache.set(userId, userName);
354
+ return userName;
355
+ }
280
356
  }
281
- catch (error) {
282
- logger.warn('[Feishu] Failed to get chat mode, defaulting to p2p:', error);
283
- return 'p2p';
357
+ catch (err) {
358
+ logger.debug('[Feishu] Failed to get user name, code:', err?.code, 'msg:', err?.message);
284
359
  }
285
- }
286
- async getUserName(_userId) {
287
- // TODO: 需要开通 contact:contact.base:readonly 权限后启用
288
360
  return undefined;
289
361
  }
290
362
  async sendMessage(chatId, content, options) {
@@ -294,29 +366,89 @@ export class FeishuChannel {
294
366
  logger.warn('[Feishu] Attempted to send empty message, skipping');
295
367
  return;
296
368
  }
369
+ // 飞书消息内容限制约 30KB(text)/ 150KB(post),安全阈值 28000 字符
370
+ // 超长消息自动拆分,按段落边界分割
371
+ const MAX_CONTENT_LENGTH = 28000;
372
+ if (content.length > MAX_CONTENT_LENGTH) {
373
+ logger.info(`[Feishu] Message too long (${content.length} chars), splitting into parts`);
374
+ const parts = splitLongMessage(content, MAX_CONTENT_LENGTH);
375
+ for (let i = 0; i < parts.length; i++) {
376
+ // 首条消息保留 reply 选项,后续消息不再 reply
377
+ const partOptions = i === 0 ? options : { ...options, replyToMessageId: undefined };
378
+ await this.sendMessage(chatId, parts[i], partOptions);
379
+ }
380
+ return;
381
+ }
297
382
  logger.debug(`[Feishu] sendMessage called, chatId: ${chatId}, content length: ${content.length}`);
298
383
  try {
384
+ // 检测富内容并渲染(受 enableRichContent 开关控制,且依赖必须可用)
385
+ const richItems = (this.enableRichContent && checkDependencies() && hasRichContent(content))
386
+ ? await renderAllRichContent(content)
387
+ : [];
388
+ // 上传所有图片获取 image_key,建立位置映射
389
+ const richItemsWithKeys = [];
390
+ for (const item of richItems) {
391
+ try {
392
+ const uploadResponse = await this.client.im.image.create({
393
+ data: { image_type: 'message', image: Buffer.from(item.png) }
394
+ });
395
+ if (uploadResponse?.image_key) {
396
+ richItemsWithKeys.push({ start: item.start, end: item.end, imageKey: uploadResponse.image_key });
397
+ logger.debug(`[Feishu] Uploaded ${item.type} image, image_key:`, uploadResponse.image_key);
398
+ }
399
+ }
400
+ catch (err) {
401
+ logger.warn(`[Feishu] Failed to upload ${item.type} image:`, err);
402
+ }
403
+ }
299
404
  const useMarkdown = !options?.forceText && hasMarkdownSyntax(content);
300
405
  const hasMention = !!(options?.mentionUserIds && options.mentionUserIds.length > 0);
301
- // 如果需要 @,强制使用 post 格式
302
- const msgType = (useMarkdown || hasMention) ? 'post' : 'text';
406
+ const hasRichImages = richItemsWithKeys.length > 0;
407
+ // 消息类型决策:有 Markdown / @ / 富内容图片 post,否则 text
408
+ const msgType = (useMarkdown || hasMention || hasRichImages) ? 'post' : 'text';
303
409
  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 }]] } };
410
+ if (msgType === 'post') {
411
+ let postData;
412
+ if (hasRichImages) {
413
+ // 有富内容图片:按位置分段文本并插入图片
414
+ postData = { zh_cn: { title: options?.title || '', content: [] } };
415
+ const sorted = [...richItemsWithKeys].sort((a, b) => a.start - b.start);
416
+ let lastEnd = 0;
417
+ for (const item of sorted) {
418
+ // 插入图片前的文本段
419
+ if (item.start > lastEnd) {
420
+ const textSegment = content.slice(lastEnd, item.start).trim();
421
+ if (textSegment) {
422
+ postData.zh_cn.content.push([{ tag: 'text', text: textSegment }]);
423
+ }
424
+ }
425
+ // 插入图片
426
+ postData.zh_cn.content.push([{ tag: 'img', image_key: item.imageKey }]);
427
+ lastEnd = item.end;
428
+ }
429
+ // 插入最后一段文本
430
+ if (lastEnd < content.length) {
431
+ const textSegment = content.slice(lastEnd).trim();
432
+ if (textSegment) {
433
+ postData.zh_cn.content.push([{ tag: 'text', text: textSegment }]);
434
+ }
435
+ }
436
+ }
437
+ else {
438
+ // 无富内容图片:使用原有逻辑
439
+ postData = useMarkdown
440
+ ? markdownToFeishuPost(content, options?.title)
441
+ : { zh_cn: { title: options?.title || '', content: [[{ tag: 'text', text: content }]] } };
442
+ }
309
443
  // 在第一行开头插入所有 @ 标签
310
- if (postData.zh_cn.content.length > 0) {
444
+ if (hasMention && postData.zh_cn.content.length > 0) {
311
445
  const atTags = options.mentionUserIds.map(uid => ({ tag: 'at', user_id: uid }));
312
446
  postData.zh_cn.content[0].unshift(...atTags);
313
447
  }
314
448
  msgContent = JSON.stringify(postData);
315
449
  }
316
450
  else {
317
- msgContent = useMarkdown
318
- ? JSON.stringify(markdownToFeishuPost(content, options?.title))
319
- : JSON.stringify({ text: content });
451
+ msgContent = JSON.stringify({ text: content });
320
452
  }
321
453
  if (options?.replyToMessageId) {
322
454
  const replyData = { msg_type: msgType, content: msgContent };
@@ -330,11 +462,16 @@ export class FeishuChannel {
330
462
  }
331
463
  else {
332
464
  await this.client.im.message.create({
333
- params: { receive_id_type: 'chat_id' },
465
+ params: { receive_id_type: chatId.startsWith('ou_') ? 'open_id' : chatId.startsWith('on_') ? 'union_id' : 'chat_id' },
334
466
  data: { receive_id: chatId, msg_type: msgType, content: msgContent }
335
467
  });
336
468
  }
337
- logger.debug(`[Feishu] Sent message as ${useMarkdown ? 'post (Markdown)' : 'text'}`);
469
+ if (hasRichImages) {
470
+ logger.info(`[Feishu] Sent message with ${richItemsWithKeys.length} embedded images`);
471
+ }
472
+ else {
473
+ logger.debug(`[Feishu] Sent message as ${useMarkdown ? 'post (Markdown)' : 'text'}`);
474
+ }
338
475
  }
339
476
  catch (error) {
340
477
  // 230011: 消息已被撤回,降级为普通消息重试
@@ -342,14 +479,31 @@ export class FeishuChannel {
342
479
  logger.warn('[Feishu] Message withdrawn (230011), retrying without reply');
343
480
  return this.sendMessage(chatId, content, { ...options, replyToMessageId: undefined });
344
481
  }
482
+ // 230025: 消息内容超长,截断后重试
483
+ if (error.response?.data?.code === 230025) {
484
+ logger.warn(`[Feishu] Message too long (230025, ${content.length} chars), truncating`);
485
+ const truncated = content.slice(0, 28000) + '\n\n⚠️ 消息过长,已截断';
486
+ return this.sendMessage(chatId, truncated, options);
487
+ }
345
488
  logger.error('[Feishu] Failed to send message:', error);
346
489
  throw error;
347
490
  }
348
491
  }
349
- async sendFile(chatId, filePath) {
492
+ async sendFile(chatId, filePath, options) {
350
493
  if (!this.client)
351
494
  return;
352
495
  try {
496
+ // 检测是否为图片,是则走 sendImage(内联预览)而非文件卡片
497
+ const header = Buffer.alloc(12);
498
+ const fd = fs.openSync(filePath, 'r');
499
+ fs.readSync(fd, header, 0, 12, 0);
500
+ fs.closeSync(fd);
501
+ const imgType = await imageType(header);
502
+ if (imgType) {
503
+ logger.info(`[Feishu] Detected image (${imgType.mime}), sending as inline image:`, filePath);
504
+ const buf = fs.readFileSync(filePath);
505
+ return this.sendImage(chatId, buf, options);
506
+ }
353
507
  logger.info('[Feishu] Uploading file:', filePath);
354
508
  const fileStream = fs.createReadStream(filePath);
355
509
  const fileName = path.basename(filePath);
@@ -365,32 +519,104 @@ export class FeishuChannel {
365
519
  return;
366
520
  }
367
521
  const fileKey = uploadResponse.file_key;
522
+ const msgContent = JSON.stringify({ file_key: fileKey });
368
523
  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 })
524
+ if (options?.replyToMessageId) {
525
+ const replyData = { msg_type: 'file', content: msgContent };
526
+ if (options.replyInThread) {
527
+ replyData.reply_in_thread = true;
375
528
  }
376
- });
529
+ await this.client.im.message.reply({
530
+ path: { message_id: options.replyToMessageId },
531
+ data: replyData
532
+ });
533
+ }
534
+ else {
535
+ await this.client.im.message.create({
536
+ params: { receive_id_type: chatId.startsWith('ou_') ? 'open_id' : chatId.startsWith('on_') ? 'union_id' : 'chat_id' },
537
+ data: {
538
+ receive_id: chatId,
539
+ msg_type: 'file',
540
+ content: msgContent
541
+ }
542
+ });
543
+ }
377
544
  logger.info('[Feishu] File message sent successfully');
378
545
  }
379
546
  catch (error) {
547
+ // 230011: 消息已被撤回,降级为普通消息重试
548
+ if (error.response?.data?.code === 230011 && options?.replyToMessageId) {
549
+ logger.warn('[Feishu] Message withdrawn (230011), retrying file send without reply');
550
+ return this.sendFile(chatId, filePath);
551
+ }
380
552
  logger.error('[Feishu] Failed to send file:', error);
381
553
  throw error;
382
554
  }
383
555
  }
384
- hasThreadSession(threadId) {
556
+ async sendImage(chatId, png, options) {
557
+ if (!this.client)
558
+ return;
385
559
  try {
386
- const row = this.db.prepare('SELECT 1 FROM sessions WHERE thread_id = ? LIMIT 1').get(threadId);
387
- return !!row;
560
+ const uploadResponse = await this.client.im.image.create({
561
+ data: {
562
+ image_type: 'message',
563
+ image: Buffer.from(png),
564
+ }
565
+ });
566
+ const imageKey = uploadResponse?.image_key;
567
+ if (!imageKey) {
568
+ logger.error('[Feishu] Image upload failed: no image_key returned');
569
+ return;
570
+ }
571
+ logger.debug('[Feishu] Image uploaded, image_key:', imageKey);
572
+ const msgContent = JSON.stringify({ image_key: imageKey });
573
+ if (options?.replyToMessageId) {
574
+ const replyData = { msg_type: 'image', content: msgContent };
575
+ if (options.replyInThread)
576
+ replyData.reply_in_thread = true;
577
+ await this.client.im.message.reply({
578
+ path: { message_id: options.replyToMessageId },
579
+ data: replyData
580
+ });
581
+ }
582
+ else {
583
+ await this.client.im.message.create({
584
+ params: { receive_id_type: chatId.startsWith('ou_') ? 'open_id' : chatId.startsWith('on_') ? 'union_id' : 'chat_id' },
585
+ data: { receive_id: chatId, msg_type: 'image', content: msgContent }
586
+ });
587
+ }
588
+ logger.debug('[Feishu] Image message sent successfully');
388
589
  }
389
- catch {
390
- return false;
590
+ catch (error) {
591
+ logger.error('[Feishu] Failed to send image:', error);
592
+ throw error;
391
593
  }
392
594
  }
595
+ isDuplicate(msgId) {
596
+ return this.seenMessages.has(msgId);
597
+ }
598
+ markSeen(msgId) {
599
+ this.seenMessages.set(msgId, Date.now());
600
+ }
601
+ startCleanupTask() {
602
+ this.cleanupInterval = setInterval(() => {
603
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
604
+ let cleaned = 0;
605
+ for (const [id, ts] of this.seenMessages) {
606
+ if (ts < cutoff) {
607
+ this.seenMessages.delete(id);
608
+ cleaned++;
609
+ }
610
+ }
611
+ if (cleaned > 0)
612
+ logger.info(`[Feishu] Cleaned ${cleaned} old message IDs`);
613
+ // seenThreads 无时间戳,仅限容量(话题持久存在,不按时间清理)
614
+ if (this.seenThreads.size > 1000)
615
+ this.seenThreads.clear();
616
+ }, 60 * 60 * 1000);
617
+ }
393
618
  async disconnect() {
619
+ this.connected = false;
394
620
  if (this.cleanupInterval) {
395
621
  clearInterval(this.cleanupInterval);
396
622
  this.cleanupInterval = undefined;
@@ -401,6 +627,23 @@ export class FeishuChannel {
401
627
  }
402
628
  this.client = null;
403
629
  }
630
+ /** Get current connection status */
631
+ getStatus() {
632
+ return { connected: this.connected };
633
+ }
634
+ /** Reconnect: disconnect then connect again */
635
+ async reconnect() {
636
+ if (this.connected) {
637
+ await this.disconnect();
638
+ }
639
+ try {
640
+ await this.connect();
641
+ return '重连成功';
642
+ }
643
+ catch (err) {
644
+ return `重连失败: ${err instanceof Error ? err.message : String(err)}`;
645
+ }
646
+ }
404
647
  async downloadAndSaveImage(imageKey, chatId, messageId, projectPath) {
405
648
  if (!this.client)
406
649
  return null;
@@ -428,28 +671,17 @@ export class FeishuChannel {
428
671
  logger.warn('[Feishu] Empty response from image download');
429
672
  return null;
430
673
  }
431
- // 使用 image-type 检测真实的图片格式
432
- const type = await imageType(buffer);
433
- if (!type) {
434
- logger.warn('[Feishu] Unable to detect image type');
435
- return null;
436
- }
437
- // 白名单验证:只允许常见的图片格式
438
- const allowedMimes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
439
- if (!allowedMimes.includes(type.mime)) {
440
- logger.warn('[Feishu] Unsupported image type:', type.mime);
441
- return null;
442
- }
443
- // 大小限制:10MB
444
- if (buffer.length > 10 * 1024 * 1024) {
445
- logger.warn('[Feishu] Image too large:', buffer.length, 'bytes');
674
+ // 统一图片验证(类型白名单 + 大小限制)
675
+ const result = await validateImage(buffer);
676
+ if (result.mime === null) {
677
+ logger.warn(`[Feishu] Image validation failed: ${result.reason}`);
446
678
  return null;
447
679
  }
448
680
  const base64Data = buffer.toString('base64');
449
- logger.debug('[Feishu] Image downloaded successfully, type:', type.mime, 'size:', base64Data.length);
681
+ logger.debug('[Feishu] Image downloaded successfully, type:', result.mime, 'size:', base64Data.length);
450
682
  return {
451
683
  data: base64Data,
452
- mimeType: type.mime // 使用真实检测的 MIME 类型
684
+ mimeType: result.mime
453
685
  };
454
686
  }
455
687
  logger.error('[Feishu] Image download failed: no valid method');
@@ -490,11 +722,7 @@ export class FeishuChannel {
490
722
  logger.warn('[Feishu] Empty response from file download');
491
723
  return null;
492
724
  }
493
- const uploadsDir = path.join(projectPath, '.claude', 'uploads');
494
- ensureDir(uploadsDir);
495
- const filePath = path.join(uploadsDir, fileName);
496
- fs.writeFileSync(filePath, buffer);
497
- logger.info('[Feishu] File downloaded successfully:', filePath, 'size:', buffer.length);
725
+ const { filePath } = saveToUploads(buffer, sanitizeFileName(fileName), projectPath);
498
726
  return filePath;
499
727
  }
500
728
  logger.error('[Feishu] File download failed: no valid method');
@@ -505,12 +733,101 @@ export class FeishuChannel {
505
733
  return null;
506
734
  }
507
735
  }
508
- isDuplicate(msgId) {
509
- const result = this.db.prepare('SELECT 1 FROM processed_messages WHERE message_id = ? LIMIT 1').get(msgId);
510
- return !!result;
736
+ async sendInteraction(chatId, interaction, options) {
737
+ if (!this.client)
738
+ return false;
739
+ const card = buildInteractionCard(interaction);
740
+ if (!card)
741
+ return false;
742
+ try {
743
+ let messageId;
744
+ if (options?.replyToMessageId) {
745
+ const replyData = {
746
+ msg_type: 'interactive',
747
+ content: JSON.stringify(card),
748
+ };
749
+ if (options.replyInThread)
750
+ replyData.reply_in_thread = true;
751
+ const res = await this.client.im.message.reply({
752
+ path: { message_id: options.replyToMessageId },
753
+ data: replyData,
754
+ });
755
+ messageId = res?.data?.message_id;
756
+ }
757
+ else {
758
+ const res = await this.client.im.message.create({
759
+ params: { receive_id_type: chatId.startsWith('ou_') ? 'open_id' : chatId.startsWith('on_') ? 'union_id' : 'chat_id' },
760
+ data: {
761
+ receive_id: chatId,
762
+ msg_type: 'interactive',
763
+ content: JSON.stringify(card),
764
+ },
765
+ });
766
+ messageId = res?.data?.message_id;
767
+ }
768
+ logger.info(`[Feishu] Sent interaction card: ${interaction.id}, messageId=${messageId}`);
769
+ return messageId || false;
770
+ }
771
+ catch (error) {
772
+ const detail = error?.response?.data || error?.message || error;
773
+ logger.error(`[Feishu] Failed to send interaction card (id=${interaction.id}, replyTo=${options?.replyToMessageId || 'none'}):`, detail);
774
+ return false;
775
+ }
511
776
  }
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());
777
+ async patchInteractionCard(messageId, card) {
778
+ if (!this.client)
779
+ return;
780
+ try {
781
+ await this.client.im.message.patch({
782
+ path: { message_id: messageId },
783
+ data: { content: JSON.stringify(card) },
784
+ });
785
+ }
786
+ catch (error) {
787
+ logger.warn(`[Feishu] Failed to patch card ${messageId}:`, error?.response?.data || error?.message);
788
+ }
789
+ }
790
+ buildResolvedCard(cardTitle, response) {
791
+ const action = response.action;
792
+ const labelMap = {
793
+ 'allow': '✅ 已允许',
794
+ 'always': '🔓 已设为始终允许',
795
+ 'deny': '❌ 已拒绝',
796
+ 'cancel': '取消',
797
+ 'submit': '✅ 已提交',
798
+ };
799
+ const statusText = labelMap[action] || `✅ ${action}`;
800
+ const now = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
801
+ // Build summary of selected values
802
+ const elements = [];
803
+ if (response.values && action === 'submit') {
804
+ const entries = Object.entries(response.values).filter(([k]) => !k.startsWith('_'));
805
+ if (entries.length > 0) {
806
+ const lines = entries.map(([k, v]) => {
807
+ const display = Array.isArray(v) ? v.join(', ') : String(v);
808
+ return `**${k}**: ${display}`;
809
+ });
810
+ elements.push({ tag: 'markdown', content: lines.join('\n') });
811
+ }
812
+ }
813
+ elements.push({ tag: 'markdown', content: `操作时间:${now}` });
814
+ return {
815
+ toast: {
816
+ type: 'success',
817
+ content: statusText,
818
+ },
819
+ card: {
820
+ type: 'raw',
821
+ data: {
822
+ config: { wide_screen_mode: true },
823
+ header: {
824
+ template: action === 'deny' ? 'red' : 'green',
825
+ title: { tag: 'plain_text', content: `${cardTitle} — ${statusText}` },
826
+ },
827
+ elements,
828
+ },
829
+ },
830
+ };
514
831
  }
515
832
  addAckReaction(messageId) {
516
833
  if (!this.client)
@@ -522,13 +839,449 @@ export class FeishuChannel {
522
839
  }
523
840
  }).catch(() => { });
524
841
  }
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);
842
+ }
843
+ // ── 交互卡片构建工具 ──
844
+ export function buildInteractionCard(interaction) {
845
+ const { kind } = interaction;
846
+ if (kind.kind === 'action') {
847
+ return buildActionCard(interaction.id, kind);
848
+ }
849
+ if (kind.kind === 'form') {
850
+ return buildFormCard(interaction.id, kind);
851
+ }
852
+ // menu kind: not rendered as card (handled via menu.response JSON)
853
+ return null;
854
+ }
855
+ export function buildActionCard(requestId, action) {
856
+ const elements = [];
857
+ // Body text
858
+ if (action.body) {
859
+ elements.push({ tag: 'markdown', content: action.body });
860
+ }
861
+ // Buttons row
862
+ const buttons = action.buttons.map(btn => {
863
+ const buttonEl = {
864
+ tag: 'button',
865
+ text: { tag: 'plain_text', content: btn.label },
866
+ type: btn.style === 'danger' ? 'danger' : btn.style === 'primary' ? 'primary' : 'default',
867
+ value: {
868
+ _request_id: requestId,
869
+ _action: btn.key,
870
+ _card_title: action.title,
871
+ },
872
+ };
873
+ if (btn.confirm) {
874
+ buttonEl.confirm = {
875
+ title: { tag: 'plain_text', content: btn.confirm.title },
876
+ text: { tag: 'plain_text', content: btn.confirm.body },
877
+ };
878
+ }
879
+ return buttonEl;
880
+ });
881
+ elements.push({
882
+ tag: 'action',
883
+ actions: buttons,
884
+ });
885
+ return {
886
+ config: { wide_screen_mode: true },
887
+ header: {
888
+ template: 'blue',
889
+ title: { tag: 'plain_text', content: action.title },
890
+ },
891
+ elements,
892
+ };
893
+ }
894
+ export function buildFormCard(requestId, form) {
895
+ // Use Feishu card v2 form container: all fields wrapped in a `form` tag.
896
+ // On submit, callback receives `action.form_value` with all field values keyed by `name`.
897
+ // This eliminates per-field callbacks when selecting dropdown options.
898
+ const formElements = [];
899
+ // Body text
900
+ if (form.body) {
901
+ formElements.push({ tag: 'markdown', content: form.body });
902
+ }
903
+ // Fields — inside form, components use `name` (not `value`) for identification
904
+ for (const field of form.fields) {
905
+ formElements.push(buildFormFieldElement(field));
906
+ if (field.hint) {
907
+ formElements.push({
908
+ tag: 'note',
909
+ elements: [{ tag: 'plain_text', content: field.hint }],
910
+ });
911
+ }
912
+ }
913
+ // Submit button (inside form, uses form_action_type)
914
+ const submitBtn = {
915
+ tag: 'button',
916
+ text: { tag: 'plain_text', content: form.submitLabel || '确认' },
917
+ type: form.submitStyle === 'danger' ? 'danger' : 'primary',
918
+ form_action_type: 'submit',
919
+ name: 'submit',
920
+ value: {
921
+ _request_id: requestId,
922
+ _action: 'submit',
923
+ _card_title: form.title,
924
+ },
925
+ };
926
+ if (form.submitConfirm) {
927
+ submitBtn.confirm = {
928
+ title: { tag: 'plain_text', content: form.submitConfirm.title },
929
+ text: { tag: 'plain_text', content: form.submitConfirm.body },
930
+ };
931
+ }
932
+ const actions = [submitBtn];
933
+ if (form.cancelable !== false) {
934
+ actions.push({
935
+ tag: 'button',
936
+ text: { tag: 'plain_text', content: '取消' },
937
+ type: 'default',
938
+ // Cancel is NOT form_action_type — it's a regular button that triggers callback directly
939
+ value: {
940
+ _request_id: requestId,
941
+ _action: 'cancel',
942
+ _card_title: form.title,
943
+ },
944
+ });
945
+ }
946
+ formElements.push({ tag: 'action', actions });
947
+ return {
948
+ schema: '2.0',
949
+ config: { update_multi: true },
950
+ header: {
951
+ template: 'blue',
952
+ title: { tag: 'plain_text', content: form.title },
953
+ },
954
+ body: {
955
+ elements: [
956
+ {
957
+ tag: 'form',
958
+ name: requestId,
959
+ elements: formElements,
960
+ },
961
+ ],
962
+ },
963
+ };
964
+ }
965
+ /** Build a field element for use inside a form container (uses `name` for identification) */
966
+ export function buildFormFieldElement(field) {
967
+ switch (field.type) {
968
+ case 'select': {
969
+ const options = field.options.map(opt => ({
970
+ text: { tag: 'plain_text', content: opt.label },
971
+ value: opt.value,
972
+ }));
973
+ const selectedOpt = field.options.find(opt => opt.selected);
974
+ return {
975
+ tag: 'select_static',
976
+ name: field.key,
977
+ placeholder: { tag: 'plain_text', content: field.placeholder || `选择${field.label}` },
978
+ options,
979
+ ...(selectedOpt ? { initial_option: selectedOpt.value } : {}),
980
+ };
981
+ }
982
+ case 'text': {
983
+ return {
984
+ tag: 'input',
985
+ name: field.key,
986
+ placeholder: { tag: 'plain_text', content: field.placeholder || `输入${field.label}` },
987
+ ...(field.defaultValue != null ? { default_value: String(field.defaultValue) } : {}),
988
+ };
989
+ }
990
+ case 'toggle': {
991
+ const checked = field.defaultValue ?? false;
992
+ return {
993
+ tag: 'select_static',
994
+ name: field.key,
995
+ placeholder: { tag: 'plain_text', content: field.label },
996
+ options: [
997
+ { text: { tag: 'plain_text', content: '开启' }, value: 'true' },
998
+ { text: { tag: 'plain_text', content: '关闭' }, value: 'false' },
999
+ ],
1000
+ initial_option: checked ? 'true' : 'false',
1001
+ };
1002
+ }
1003
+ case 'multi-select': {
1004
+ const options = field.options.map(opt => ({
1005
+ text: { tag: 'plain_text', content: opt.label },
1006
+ value: opt.value,
1007
+ }));
1008
+ const selectedValues = field.options.filter(opt => opt.selected).map(opt => opt.value);
1009
+ return {
1010
+ tag: 'multi_select_static',
1011
+ name: field.key,
1012
+ placeholder: { tag: 'plain_text', content: `选择${field.label}` },
1013
+ options,
1014
+ ...(selectedValues.length > 0 ? { initial_options: selectedValues } : {}),
1015
+ };
1016
+ }
1017
+ default:
1018
+ return { tag: 'markdown', content: `[不支持的字段类型: ${field.type}]` };
1019
+ }
1020
+ }
1021
+ export function buildFieldElement(requestId, field) {
1022
+ switch (field.type) {
1023
+ case 'select': {
1024
+ const options = field.options.map(opt => ({
1025
+ text: { tag: 'plain_text', content: opt.label },
1026
+ value: opt.value,
1027
+ }));
1028
+ const selectedOpt = field.options.find(opt => opt.selected);
1029
+ return {
1030
+ tag: 'action',
1031
+ actions: [{
1032
+ tag: 'select_static',
1033
+ placeholder: { tag: 'plain_text', content: field.placeholder || `选择${field.label}` },
1034
+ options,
1035
+ ...(selectedOpt ? { initial_option: selectedOpt.value } : {}),
1036
+ value: {
1037
+ _request_id: requestId,
1038
+ _field_key: field.key,
1039
+ },
1040
+ }],
1041
+ };
1042
+ }
1043
+ case 'text': {
1044
+ // Feishu cards don't have a native text input component.
1045
+ // Use a note element as placeholder label; actual input via form submit.
1046
+ return {
1047
+ tag: 'note',
1048
+ elements: [
1049
+ { tag: 'plain_text', content: `${field.label}: ${field.placeholder || '(请在提交时输入)'}` },
1050
+ ],
1051
+ };
1052
+ }
1053
+ case 'toggle': {
1054
+ const checked = field.defaultValue ?? false;
1055
+ return {
1056
+ tag: 'action',
1057
+ actions: [{
1058
+ tag: 'select_static',
1059
+ placeholder: { tag: 'plain_text', content: field.label },
1060
+ options: [
1061
+ { text: { tag: 'plain_text', content: '开启' }, value: 'true' },
1062
+ { text: { tag: 'plain_text', content: '关闭' }, value: 'false' },
1063
+ ],
1064
+ initial_option: checked ? 'true' : 'false',
1065
+ value: {
1066
+ _request_id: requestId,
1067
+ _field_key: field.key,
1068
+ },
1069
+ }],
1070
+ };
1071
+ }
1072
+ case 'multi-select': {
1073
+ // Feishu cards: use multi_select_static (checker)
1074
+ const options = field.options.map(opt => ({
1075
+ text: { tag: 'plain_text', content: opt.label },
1076
+ value: opt.value,
1077
+ }));
1078
+ const selectedValues = field.options.filter(opt => opt.selected).map(opt => opt.value);
1079
+ return {
1080
+ tag: 'action',
1081
+ actions: [{
1082
+ tag: 'multi_select_static',
1083
+ placeholder: { tag: 'plain_text', content: `选择${field.label}` },
1084
+ options,
1085
+ ...(selectedValues.length > 0 ? { initial_options: selectedValues } : {}),
1086
+ value: {
1087
+ _request_id: requestId,
1088
+ _field_key: field.key,
1089
+ },
1090
+ }],
1091
+ };
1092
+ }
1093
+ default:
1094
+ return { tag: 'markdown', content: `[不支持的字段类型: ${field.type}]` };
1095
+ }
1096
+ }
1097
+ function displayWidth(str) {
1098
+ let width = 0;
1099
+ for (const ch of str) {
1100
+ const code = ch.codePointAt(0);
1101
+ if ((code >= 0x4E00 && code <= 0x9FFF) ||
1102
+ (code >= 0x3400 && code <= 0x4DBF) ||
1103
+ (code >= 0xF900 && code <= 0xFAFF) ||
1104
+ (code >= 0xFF01 && code <= 0xFF60) ||
1105
+ (code >= 0x3000 && code <= 0x303F)) {
1106
+ width += 2;
1107
+ }
1108
+ else {
1109
+ width += 1;
1110
+ }
1111
+ }
1112
+ return width;
1113
+ }
1114
+ function padToWidth(str, targetWidth) {
1115
+ const current = displayWidth(str);
1116
+ const padding = Math.max(0, targetWidth - current);
1117
+ return str + ' '.repeat(padding);
1118
+ }
1119
+ function convertTablesToText(text) {
1120
+ const tableRegex = /^(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)+)/gm;
1121
+ return text.replace(tableRegex, (_match, headerLine, _sep, bodyBlock) => {
1122
+ const parseRow = (line) => line.split('|').slice(1, -1).map((c) => c.trim());
1123
+ const headers = parseRow(headerLine);
1124
+ const rows = bodyBlock.trim().split('\n').map(parseRow);
1125
+ const colWidths = headers.map((h, i) => {
1126
+ const cellWidths = rows.map(r => displayWidth(r[i] || ''));
1127
+ return Math.max(displayWidth(h), ...cellWidths);
1128
+ });
1129
+ const headerStr = headers.map((h, i) => padToWidth(h, colWidths[i])).join(' ');
1130
+ const sepStr = colWidths.map(w => '-'.repeat(w)).join(' ');
1131
+ const rowStrs = rows.map(r => headers.map((_, i) => padToWidth(r[i] || '', colWidths[i])).join(' '));
1132
+ return '```\n' + [headerStr, sepStr, ...rowStrs].join('\n') + '\n```';
1133
+ });
1134
+ }
1135
+ /**
1136
+ * 按段落边界拆分超长消息
1137
+ * 优先在 \n\n 处分割,其次 \n,最后强制截断
1138
+ */
1139
+ function splitLongMessage(content, maxLength) {
1140
+ const parts = [];
1141
+ let remaining = content;
1142
+ while (remaining.length > maxLength) {
1143
+ let splitAt = remaining.lastIndexOf('\n\n', maxLength);
1144
+ if (splitAt <= 0)
1145
+ splitAt = remaining.lastIndexOf('\n', maxLength);
1146
+ if (splitAt <= 0)
1147
+ splitAt = maxLength;
1148
+ parts.push(remaining.slice(0, splitAt).trimEnd());
1149
+ remaining = remaining.slice(splitAt).trimStart();
1150
+ }
1151
+ if (remaining)
1152
+ parts.push(remaining);
1153
+ return parts;
1154
+ }
1155
+ export function markdownToFeishuPost(markdown, defaultTitle) {
1156
+ const match = markdown.match(/^# (.+)$/m);
1157
+ const title = match?.[1] ?? defaultTitle ?? '';
1158
+ let body = match ? markdown.replace(/^# .+\n?/, '') : markdown;
1159
+ body = convertTablesToText(body);
1160
+ return {
1161
+ zh_cn: {
1162
+ title,
1163
+ content: [[{ tag: 'md', text: body.trim() }]]
1164
+ }
1165
+ };
1166
+ }
1167
+ /**
1168
+ * 将 Markdown 内容转为飞书消息卡片格式(interactive msg_type)
1169
+ * 飞书卡片的 markdown 组件支持完整 Markdown 渲染(代码块、表格、列表等)
1170
+ * 当前消息类型决策统一走 post + md tag,此函数为 interactive 卡片场景预留。
1171
+ */
1172
+ export function markdownToFeishuCard(markdown, defaultTitle) {
1173
+ const match = markdown.match(/^# (.+)$/m);
1174
+ const title = match?.[1] ?? defaultTitle;
1175
+ let body = match ? markdown.replace(/^# .+\n?/, '') : markdown;
1176
+ body = convertTablesToText(body).trim();
1177
+ const card = {
1178
+ config: { wide_screen_mode: true },
1179
+ elements: [
1180
+ { tag: 'markdown', content: body }
1181
+ ]
1182
+ };
1183
+ if (title) {
1184
+ card.header = {
1185
+ title: { tag: 'plain_text', content: title }
1186
+ };
1187
+ }
1188
+ return card;
1189
+ }
1190
+ export function hasMarkdownSyntax(text) {
1191
+ const markdownPatterns = [
1192
+ /^#{1,6}\s/m, /\*\*.*?\*\*/, /\*.*?\*/, /__.*?__/, /_.*?_/, /~~.*?~~/,
1193
+ /`.*?`/, /```[\s\S]*?```/, /\[.*?\]\(.*?\)/, /^[\s]*[-*+]\s/m,
1194
+ /^[\s]*\d+\.\s/m, /^\|.+\|$/m
1195
+ ];
1196
+ return markdownPatterns.some(pattern => pattern.test(text));
1197
+ }
1198
+ import { normalizeChannelInstances } from '../config.js';
1199
+ export class FeishuChannelPlugin {
1200
+ name = 'feishu';
1201
+ isEnabled(config) {
1202
+ const raw = config.channels?.feishu;
1203
+ if (!raw)
1204
+ return false;
1205
+ if (Array.isArray(raw)) {
1206
+ return raw.some(inst => inst.enabled !== false && inst.appId && inst.appSecret);
1207
+ }
1208
+ if (raw.enabled === false)
1209
+ return false;
1210
+ return !!(raw.appId && raw.appSecret);
1211
+ }
1212
+ async createChannels(config) {
1213
+ const instances = normalizeChannelInstances(config.channels?.feishu, 'feishu');
1214
+ const result = [];
1215
+ for (const inst of instances) {
1216
+ if (inst.enabled === false || !inst.appId || !inst.appSecret)
1217
+ continue;
1218
+ const channel = new FeishuChannel({
1219
+ appId: inst.appId,
1220
+ appSecret: inst.appSecret,
1221
+ enableRichContent: config.enableRichContent,
1222
+ });
1223
+ const adapter = {
1224
+ channelName: inst.name,
1225
+ sendText: (id, text, context) => channel.sendMessage(id, text, context),
1226
+ sendFile: (id, filePath, context) => channel.sendFile(id, filePath, context),
1227
+ sendImage: (id, png, context) => channel.sendImage(id, png, context),
1228
+ acknowledge: (messageId) => { channel.addAckReaction(messageId); return Promise.resolve(); },
1229
+ sendInteraction: (id, interaction, context) => channel.sendInteraction(id, interaction, context),
1230
+ patchInteractionCard: (messageId, card) => channel.patchInteractionCard(messageId, card),
1231
+ onInteraction: (callback) => channel.onInteraction(callback),
1232
+ };
1233
+ const policy = {
1234
+ canSwitchProject: (chatType, identity) => identity === 'owner',
1235
+ canListProjects: (chatType, identity) => identity === 'owner',
1236
+ canCreateSession: (chatType, identity) => true,
1237
+ canDeleteSession: (chatType, identity) => true,
1238
+ canImportCliSession: (chatType, identity) => identity === 'owner',
1239
+ messagePrefix: (chatType, peerName) => (chatType === 'group' && peerName) ? `[${peerName}] ` : '',
1240
+ showMiddleResult: (chatType, identity) => {
1241
+ const mode = inst.showActivities ?? config.showActivities ?? 'all';
1242
+ if (mode === 'none')
1243
+ return false;
1244
+ if (mode === 'dm-only')
1245
+ return chatType === 'private';
1246
+ if (mode === 'owner-dm-only')
1247
+ return chatType === 'private' && identity === 'owner';
1248
+ return true;
1249
+ },
1250
+ showIdleMonitor: (chatType, identity) => {
1251
+ const mode = inst.showActivities ?? config.showActivities ?? 'all';
1252
+ if (mode === 'none')
1253
+ return false;
1254
+ if (mode === 'dm-only')
1255
+ return chatType === 'private';
1256
+ if (mode === 'owner-dm-only')
1257
+ return chatType === 'private' && identity === 'owner';
1258
+ return true;
1259
+ },
1260
+ accumulateErrors: (chatType, identity) => true,
1261
+ };
1262
+ const options = {
1263
+ fileMarkerPattern: /\[SEND_FILE:(?:(\w+):)?([^\]]+)\]/g,
1264
+ supportsImages: true,
1265
+ flushDelay: inst.flushDelay,
1266
+ };
1267
+ result.push({
1268
+ channelType: 'feishu',
1269
+ adapter,
1270
+ channel,
1271
+ policy,
1272
+ options,
1273
+ connect: () => channel.connect(),
1274
+ disconnect: () => channel.disconnect(),
1275
+ onProjectPathRequest: (channelId) => Promise.resolve(config.projects?.defaultPath || process.cwd()),
1276
+ });
1277
+ }
1278
+ return result;
1279
+ }
1280
+ async createChannel(config) {
1281
+ const instances = await this.createChannels(config);
1282
+ if (instances.length === 0) {
1283
+ throw new Error('Feishu config missing');
1284
+ }
1285
+ return instances[0];
533
1286
  }
534
1287
  }