@neomei/opencode-feishu 0.2.6 → 0.2.8

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 (49) hide show
  1. package/dist/core/config.d.ts +3 -3
  2. package/dist/core/config.js +1 -1
  3. package/dist/core/config.js.map +1 -1
  4. package/dist/core/message-handler.d.ts +25 -11
  5. package/dist/core/message-handler.d.ts.map +1 -1
  6. package/dist/core/message-handler.js +589 -102
  7. package/dist/core/message-handler.js.map +1 -1
  8. package/dist/core/session-manager.d.ts +1 -1
  9. package/dist/core/session-manager.d.ts.map +1 -1
  10. package/dist/core/session-manager.js +18 -2
  11. package/dist/core/session-manager.js.map +1 -1
  12. package/dist/core/types.d.ts +35 -2
  13. package/dist/core/types.d.ts.map +1 -1
  14. package/dist/feishu/api.d.ts +6 -0
  15. package/dist/feishu/api.d.ts.map +1 -1
  16. package/dist/feishu/api.js +71 -6
  17. package/dist/feishu/api.js.map +1 -1
  18. package/dist/feishu/card.d.ts +6 -5
  19. package/dist/feishu/card.d.ts.map +1 -1
  20. package/dist/feishu/card.js +185 -120
  21. package/dist/feishu/card.js.map +1 -1
  22. package/dist/feishu/event-source.d.ts +6 -0
  23. package/dist/feishu/event-source.d.ts.map +1 -1
  24. package/dist/feishu/event-source.js +54 -0
  25. package/dist/feishu/event-source.js.map +1 -1
  26. package/dist/opencode/client.d.ts +11 -0
  27. package/dist/opencode/client.d.ts.map +1 -1
  28. package/dist/opencode/client.js +120 -9
  29. package/dist/opencode/client.js.map +1 -1
  30. package/dist/opencode/event-handler.d.ts +14 -1
  31. package/dist/opencode/event-handler.d.ts.map +1 -1
  32. package/dist/opencode/event-handler.js +171 -25
  33. package/dist/opencode/event-handler.js.map +1 -1
  34. package/dist/plugin.d.ts.map +1 -1
  35. package/dist/plugin.js +10 -3
  36. package/dist/plugin.js.map +1 -1
  37. package/dist/services/doc-service.d.ts +11 -45
  38. package/dist/services/doc-service.d.ts.map +1 -1
  39. package/dist/services/doc-service.js +72 -174
  40. package/dist/services/doc-service.js.map +1 -1
  41. package/dist/services/im-service.d.ts.map +1 -1
  42. package/dist/services/im-service.js +10 -6
  43. package/dist/services/im-service.js.map +1 -1
  44. package/dist/standalone.d.ts.map +1 -1
  45. package/dist/standalone.js +10 -3
  46. package/dist/standalone.js.map +1 -1
  47. package/dist/types/extended.d.ts +2 -1
  48. package/dist/types/extended.d.ts.map +1 -1
  49. package/package.json +5 -5
@@ -10,18 +10,54 @@ export class MessageHandler {
10
10
  opencode;
11
11
  dedup;
12
12
  fileDownloader;
13
- docService;
14
13
  botName;
15
- thinkingTimers = new Map();
16
- constructor(config, sessionManager, feishuApi, opencode, docService, botName = '点点') {
14
+ availableCommands = new Map();
15
+ constructor(config, sessionManager, feishuApi, opencode, botName = '点点') {
17
16
  this.config = config;
18
17
  this.sessionManager = sessionManager;
19
18
  this.feishuApi = feishuApi;
20
19
  this.opencode = opencode;
21
- this.docService = docService;
22
20
  this.botName = botName;
23
21
  this.dedup = new MessageDeduplicator(config.dedupTtl || 600_000);
24
22
  this.fileDownloader = new FileDownloader();
23
+ // Load available commands on startup
24
+ this.loadAvailableCommands();
25
+ }
26
+ async loadAvailableCommands() {
27
+ try {
28
+ log.info('Loading available commands from OpenCode');
29
+ const commands = await this.opencode.getCommands();
30
+ if (commands && Array.isArray(commands)) {
31
+ for (const cmd of commands) {
32
+ if (cmd.name) {
33
+ this.availableCommands.set(cmd.name, {
34
+ type: cmd.source === 'mcp' ? 'custom' : 'session',
35
+ description: cmd.description
36
+ });
37
+ log.info({ name: cmd.name, source: cmd.source, description: cmd.description }, 'Loaded command');
38
+ }
39
+ }
40
+ log.info({ count: this.availableCommands.size }, 'Loaded available commands');
41
+ }
42
+ else {
43
+ log.warn({ commands }, 'No commands loaded from OpenCode');
44
+ }
45
+ // Also add known TUI commands
46
+ const tuiCommands = [
47
+ 'session.list', 'session.new', 'session.share', 'session.interrupt', 'session.compact',
48
+ 'session.page.up', 'session.page.down', 'session.line.up', 'session.line.down',
49
+ 'session.half.page.up', 'session.half.page.down', 'session.first', 'session.last',
50
+ 'prompt.clear', 'prompt.submit', 'agent.cycle'
51
+ ];
52
+ for (const cmd of tuiCommands) {
53
+ if (!this.availableCommands.has(cmd)) {
54
+ this.availableCommands.set(cmd, { type: 'tui' });
55
+ }
56
+ }
57
+ }
58
+ catch (err) {
59
+ log.error({ err }, 'Failed to load available commands');
60
+ }
25
61
  }
26
62
  async handleMessage(message) {
27
63
  try {
@@ -85,6 +121,9 @@ export class MessageHandler {
85
121
  await this.feishuApi.sendText(chatId, '⏳ 正在处理上一条消息,请稍候...');
86
122
  return;
87
123
  }
124
+ // Clear previous turn's card reference so a new card is created for this turn.
125
+ // (EventHandler no longer clears this on session.idle to avoid race conditions.)
126
+ this.sessionManager.clearCurrentMessage(chatId);
88
127
  // Resolve sender name (with cache)
89
128
  const senderUnionId = message.sender.sender_id?.union_id || 'unknown';
90
129
  const senderName = await this.feishuApi.getUserName(senderUnionId);
@@ -98,18 +137,34 @@ export class MessageHandler {
98
137
  case 'text':
99
138
  text = content.text || '';
100
139
  break;
101
- case 'image':
102
- text = await this.downloadMedia(message.message_id, content.image_key, 'image', 'image.jpg', 'image/jpeg', '图片');
140
+ case 'image': {
141
+ const result = await this.downloadMedia(message.message_id, content.image_key, 'image', 'image.jpg', 'image/jpeg', '图片');
142
+ text = result.text;
143
+ if (result.file)
144
+ files.push(result.file);
103
145
  break;
104
- case 'file':
105
- text = await this.downloadMedia(message.message_id, content.file_key, 'file', content.file_name || 'unknown', 'application/octet-stream', '文件');
146
+ }
147
+ case 'file': {
148
+ const result = await this.downloadMedia(message.message_id, content.file_key, 'file', content.file_name || 'unknown', 'application/octet-stream', '文件');
149
+ text = result.text;
150
+ if (result.file)
151
+ files.push(result.file);
106
152
  break;
107
- case 'audio':
108
- text = await this.downloadMedia(message.message_id, content.file_key, 'file', 'audio.opus', 'audio/opus', '语音');
153
+ }
154
+ case 'audio': {
155
+ const result = await this.downloadMedia(message.message_id, content.file_key, 'file', 'audio.opus', 'audio/opus', '语音');
156
+ text = result.text;
157
+ if (result.file)
158
+ files.push(result.file);
109
159
  break;
110
- case 'media':
111
- text = await this.downloadMedia(message.message_id, content.file_key, 'file', content.file_name || 'video.mp4', 'video/mp4', '视频');
160
+ }
161
+ case 'media': {
162
+ const result = await this.downloadMedia(message.message_id, content.file_key, 'file', content.file_name || 'video.mp4', 'video/mp4', '视频');
163
+ text = result.text;
164
+ if (result.file)
165
+ files.push(result.file);
112
166
  break;
167
+ }
113
168
  case 'sticker':
114
169
  text = `[表情消息]`;
115
170
  break;
@@ -138,30 +193,26 @@ export class MessageHandler {
138
193
  return;
139
194
  }
140
195
  log.info({ chatType, chatId, text: text.substring(0, 100) }, 'Message content');
141
- // Check for document creation intent
142
- if (this.docService) {
143
- const docIntent = this.detectDocCreationIntent(text);
144
- if (docIntent.intent) {
145
- log.info({ title: docIntent.title }, 'Document creation intent detected');
146
- try {
147
- const doc = await this.docService.createDocumentFromMarkdown(docIntent.title, `# ${docIntent.title}\n\n> 由 OpenCode AI 助手创建\n\n请在此处编辑文档内容...\n`);
148
- await this.feishuApi.sendText(chatId, `✅ 已为您创建文档「${doc.title}」\n🔗 ${doc.url}\n\n您可以点击链接查看和编辑文档。`);
149
- // Continue to OpenCode to let AI generate a friendly response
150
- text = `${text}\n\n[系统提示:已为用户创建飞书文档「${doc.title}」,链接:${doc.url}。请告诉用户文档已创建,并简要说明可以如何使用。]`;
151
- }
152
- catch (err) {
153
- log.error({ err, title: docIntent.title }, 'Failed to create document');
154
- await this.feishuApi.sendText(chatId, '❌ 创建文档失败,请确认已开通文档相关权限(docx:document、docx:document:readonly)');
155
- }
156
- }
157
- }
158
196
  // Check for pending interaction reply before proceeding with normal message flow
159
197
  const pendingInteraction = this.sessionManager.getPendingInteraction?.(chatId);
160
198
  if (pendingInteraction) {
161
- const handled = await this.handleInteractionReply(chatId, text.trim(), pendingInteraction);
162
- if (handled)
163
- return;
164
- // If not handled as interaction reply, fall through to normal processing
199
+ try {
200
+ const handled = await this.handleInteractionReply(chatId, text.trim(), pendingInteraction);
201
+ if (handled)
202
+ return;
203
+ // The user's message does not match an interaction reply pattern.
204
+ // Do NOT clear the pending interaction — the user may still click the
205
+ // card buttons. Instead, tell them to finish the interaction first.
206
+ }
207
+ catch {
208
+ // Error handling interaction reply: clear to prevent the user from
209
+ // getting permanently stuck.
210
+ this.sessionManager.clearPendingInteraction(chatId);
211
+ }
212
+ await this.feishuApi.sendText(chatId, pendingInteraction.kind === 'permission'
213
+ ? '⏳ 请先处理上方的权限请求(点击卡片按钮),或等待当前任务完成。'
214
+ : '⏳ 请先处理上方的选择(点击卡片按钮或回复选项),或等待当前任务完成。');
215
+ return;
165
216
  }
166
217
  // Atomically check and set busy status to prevent race conditions
167
218
  const currentSession = this.sessionManager.getSession(chatId);
@@ -171,53 +222,126 @@ export class MessageHandler {
171
222
  }
172
223
  this.sessionManager.updateStatus(chatId, 'busy');
173
224
  // Show thinking card immediately before sending prompt
174
- if (!this.config.showProcess) {
175
- const dots = ['.', '..', '...', '....'];
176
- let frame = 0;
177
- const thinkingCard = FeishuCard.createThinkingCard(this.botName, dots[frame]);
225
+ if (this.config.showProcess !== 'none') {
226
+ const thinkingCard = FeishuCard.createThinkingCard(this.botName);
227
+ log.info({ chatId }, 'Sending thinking card');
178
228
  const msg = await this.feishuApi.sendCard(chatId, thinkingCard);
229
+ log.info({ chatId, messageId: msg?.message_id }, 'Thinking card sent result');
179
230
  if (msg?.message_id) {
180
- this.sessionManager.setCurrentMessage?.(chatId, msg.message_id);
181
- // Animate the ellipsis until content arrives
182
- const timer = setInterval(async () => {
183
- const session = this.sessionManager.getSession(chatId);
184
- if (!session?.currentMessageId || !this.thinkingTimers.has(chatId)) {
185
- clearInterval(timer);
186
- return;
187
- }
188
- if (session.currentContent) {
189
- this.stopThinkingAnimation(chatId);
190
- return;
191
- }
192
- frame = (frame + 1) % dots.length;
193
- try {
194
- await this.feishuApi.updateCard(session.currentMessageId, FeishuCard.createThinkingCard(this.botName, dots[frame]));
195
- }
196
- catch { /* best-effort */ }
197
- }, 500);
198
- this.thinkingTimers.set(chatId, timer);
231
+ this.sessionManager.setCurrentMessage(chatId, msg.message_id);
199
232
  }
200
233
  }
201
234
  // Send message to OpenCode
202
235
  try {
203
236
  const slashCommand = this.parseSlashCommand(text);
204
237
  if (slashCommand) {
205
- log.info({ sessionId: session.id, command: slashCommand.command, args: slashCommand.args }, 'Sending command to OpenCode');
206
- await this.opencode.sendCommand(session.id, slashCommand.command, slashCommand.args);
207
- log.info('Command sent successfully');
238
+ // Map common shortcuts to TUI commands
239
+ const commandMappings = {
240
+ 'new': 'session.new',
241
+ 'share': 'session.share',
242
+ 'interrupt': 'session.interrupt',
243
+ 'compact': 'session.compact',
244
+ 'pageup': 'session.page.up',
245
+ 'pagedown': 'session.page.down',
246
+ 'lineup': 'session.line.up',
247
+ 'linedown': 'session.line.down',
248
+ 'halfpageup': 'session.half.page.up',
249
+ 'halfpagedown': 'session.half.page.down',
250
+ 'first': 'session.first',
251
+ 'last': 'session.last',
252
+ 'clear': 'prompt.clear',
253
+ 'submit': 'prompt.submit',
254
+ 'nextagent': 'agent.cycle',
255
+ };
256
+ // Apply mapping if exists
257
+ const mappedCommand = commandMappings[slashCommand.command] || slashCommand.command;
258
+ if (mappedCommand !== slashCommand.command) {
259
+ log.info({ original: slashCommand.command, mapped: mappedCommand }, 'Mapped command');
260
+ }
261
+ // Check if command needs special handling (list queries)
262
+ const listCommands = ['models', 'agents', 'commands', 'sessions', 'tools', 'worktrees', 'files', 'status', 'config'];
263
+ if (listCommands.includes(slashCommand.command)) {
264
+ // Handle list commands by fetching data from OpenCode
265
+ await this.handleListCommand(chatId, session.id, slashCommand.command, slashCommand.args);
266
+ }
267
+ else {
268
+ // Check command type from cached commands
269
+ const commandInfo = this.availableCommands.get(mappedCommand);
270
+ if (commandInfo?.type === 'tui') {
271
+ // TUI commands are executed via tui.executeCommand
272
+ log.info({ sessionId: session.id, command: mappedCommand }, 'Executing TUI command');
273
+ await this.opencode.executeTuiCommand(mappedCommand);
274
+ log.info('TUI command executed successfully');
275
+ // Send confirmation and set session to idle
276
+ await this.feishuApi.sendText(chatId, `✅ 已执行命令: /${slashCommand.command}`);
277
+ this.sessionManager.updateStatus(chatId, 'idle');
278
+ this.sessionManager.clearCurrentMessage(chatId);
279
+ }
280
+ else {
281
+ // All other commands (session commands and custom commands)
282
+ // are sent as session commands
283
+ log.info({ sessionId: session.id, command: slashCommand.command, args: slashCommand.args, type: commandInfo?.type || 'unknown' }, 'Sending command to OpenCode');
284
+ await this.opencode.sendCommand(session.id, slashCommand.command, slashCommand.args);
285
+ log.info('Command sent successfully');
286
+ }
287
+ }
208
288
  }
209
289
  else {
210
290
  log.info({ sessionId: session.id, files: files.length }, 'Sending prompt to OpenCode');
211
- await this.opencode.sendPrompt(session.id, text, files.length > 0 ? files : undefined);
291
+ // Inject chat context so the AI knows the current chat_id for Feishu operations
292
+ const contextPrefix = `[系统上下文] 当前飞书对话ID: ${chatId}\n\n` +
293
+ `你配置了飞书 MCP 工具,可以使用以下工具来操作飞书文档、日历等:\n` +
294
+ `- docx.v1.document.create — 创建飞书文档\n` +
295
+ `- docx.v1.documentBlockChildren.create — 在文档中插入内容\n` +
296
+ `- docx.v1.documentBlock.patch — 更新文档块\n` +
297
+ `- drive.v1.file.createFolder — 创建文件夹\n` +
298
+ `- drive.v1.media.uploadPrepare/uploadFinish — 上传文件\n` +
299
+ `当用户请求创建飞书文档时,请直接调用 MCP 工具创建,不要在回复中询问。\n` +
300
+ `重要:飞书文档的访问链接必须使用 https://www.feishu.cn/docx/ 域名,不要使用 https://open.feishu.cn/docx/ 域名。\n\n`;
301
+ await this.opencode.sendPrompt(session.id, contextPrefix + text, files.length > 0 ? files : undefined);
212
302
  log.info('Prompt sent successfully');
213
303
  }
214
304
  }
215
305
  catch (err) {
216
306
  log.error({ err }, 'Failed to send prompt');
217
- await this.feishuApi.sendCard(chatId, FeishuCard.createErrorCard(`发送消息失败: ${err instanceof Error ? err.message : String(err)}`));
307
+ // Extract meaningful error message from various error formats
308
+ let errorMessage;
309
+ if (err instanceof Error) {
310
+ errorMessage = err.message;
311
+ }
312
+ else if (typeof err === 'object' && err !== null) {
313
+ const errObj = err;
314
+ if (errObj.data?.message) {
315
+ errorMessage = errObj.data.message;
316
+ }
317
+ else if (errObj.message) {
318
+ errorMessage = errObj.message;
319
+ }
320
+ else {
321
+ try {
322
+ errorMessage = JSON.stringify(err);
323
+ }
324
+ catch {
325
+ errorMessage = String(err);
326
+ }
327
+ }
328
+ }
329
+ else {
330
+ errorMessage = String(err);
331
+ }
332
+ log.info({ errorMessage, errType: typeof err }, 'Creating error card with message');
333
+ const errorCard = FeishuCard.createErrorCard(errorMessage);
334
+ log.info({ cardContent: errorCard.elements[0]?.text?.content }, 'Error card content');
335
+ // Set status to idle BEFORE sending the error card to prevent
336
+ // EventHandler from sending a duplicate error card via session.error event.
218
337
  this.sessionManager.updateStatus(chatId, 'idle');
219
338
  this.sessionManager.clearCurrentMessage(chatId);
220
- this.stopThinkingAnimation(chatId);
339
+ // Mark error as handled to prevent duplicate error cards
340
+ const session = this.sessionManager.getSession(chatId);
341
+ if (session) {
342
+ session.errorHandled = true;
343
+ }
344
+ await this.feishuApi.sendCard(chatId, errorCard);
221
345
  }
222
346
  }
223
347
  catch (err) {
@@ -230,13 +354,6 @@ export class MessageHandler {
230
354
  }
231
355
  }
232
356
  }
233
- stopThinkingAnimation(chatId) {
234
- const timer = this.thinkingTimers.get(chatId);
235
- if (timer) {
236
- clearInterval(timer);
237
- this.thinkingTimers.delete(chatId);
238
- }
239
- }
240
357
  /**
241
358
  * Try to parse the user message as a reply to a pending interaction.
242
359
  * Returns true if handled.
@@ -260,10 +377,7 @@ export class MessageHandler {
260
377
  log.info({ chatId, permissionId: perm.id, reply }, 'Replying to permission');
261
378
  await this.opencode.replyPermission(perm.id, reply);
262
379
  this.sessionManager.clearPendingInteraction(chatId);
263
- // Show confirmation in a new card (or update existing if we track the id)
264
- const confirmText = reply === 'reject' ? '已拒绝该权限请求。' :
265
- reply === 'always' ? '已永久授权该权限。' : '已授权一次该权限。';
266
- await this.feishuApi.sendCard(chatId, FeishuCard.createErrorCard(confirmText));
380
+ await this.feishuApi.sendCard(chatId, FeishuCard.createInteractionRepliedCard('permission', reply));
267
381
  return true;
268
382
  }
269
383
  if (interaction.kind === 'question') {
@@ -317,6 +431,396 @@ export class MessageHandler {
317
431
  return false;
318
432
  }
319
433
  }
434
+ /**
435
+ * Handle a card button click (card.action.trigger event).
436
+ * Parses the button value and routes to the appropriate OpenCode API.
437
+ * Returns a card callback response for Feishu (toast / updated card).
438
+ */
439
+ async handleCardAction(action) {
440
+ const { chatId, messageId } = action;
441
+ const value = action.action.value;
442
+ if (!value || typeof value !== 'object') {
443
+ log.warn({ chatId, messageId, value }, 'Card action missing value');
444
+ return { toast: { type: 'error', content: '无效操作' } };
445
+ }
446
+ const actionType = value.action || value._oc;
447
+ log.info({ chatId, messageId, actionType, value }, 'Card action received');
448
+ // Handle navigation commands (TUI operations)
449
+ if (actionType === 'nav') {
450
+ return this.handleNavigationCardAction(chatId, messageId, value);
451
+ }
452
+ if (actionType !== 'perm' && actionType !== 'q') {
453
+ log.warn({ chatId, actionType, value }, 'Unknown card action type');
454
+ return { toast: { type: 'error', content: '不支持的操作' } };
455
+ }
456
+ // Verify there is a pending interaction for this chat
457
+ const pending = this.sessionManager.getPendingInteraction(chatId);
458
+ const session = this.sessionManager.getSession(chatId);
459
+ log.info({ chatId, messageId, hasPending: !!pending, pendingKind: pending?.kind, currentMessageId: session?.currentMessageId }, 'Checking pending interaction');
460
+ if (!pending) {
461
+ log.info({ chatId, messageId, currentMessageId: session?.currentMessageId }, 'No pending interaction for this chat, ignoring card action');
462
+ const valueId = (value.id || '').toString();
463
+ // If the button looks like an AI-generated permission card (not from OpenCode's permission.asked event),
464
+ // simulate a text reply so the AI can continue processing.
465
+ const isAiGeneratedPerm = actionType === 'perm' && valueId.startsWith('perm-');
466
+ if (isAiGeneratedPerm) {
467
+ const replyMap = {
468
+ once: '确认',
469
+ always: '始终允许',
470
+ reject: '拒绝',
471
+ };
472
+ const replyText = replyMap[value.reply] || '确认';
473
+ const confirmMap = {
474
+ once: '已授权一次',
475
+ always: '已永久授权',
476
+ reject: '已拒绝',
477
+ };
478
+ const confirmText = confirmMap[value.reply] || '已授权';
479
+ try {
480
+ // Wait for card update to complete before sending prompt,
481
+ // so the user sees the confirmation state immediately.
482
+ await this.feishuApi.updateCard(messageId, FeishuCard.createInteractionRepliedCard('permission', value.reply));
483
+ log.info({ chatId, messageId }, 'Updated AI-generated perm card to confirmed state');
484
+ }
485
+ catch (err) {
486
+ log.warn({ err, chatId, messageId }, 'Failed to update AI-generated perm card');
487
+ }
488
+ // Re-bind currentMessageId to the clicked card so subsequent
489
+ // flushCard calls update the same card instead of creating a new one.
490
+ if (session) {
491
+ this.sessionManager.setCurrentMessage(chatId, messageId);
492
+ log.info({ chatId, messageId }, 'Re-bound currentMessageId to clicked card');
493
+ }
494
+ // Simulate user text reply to OpenCode so the AI continues
495
+ if (session) {
496
+ try {
497
+ await this.opencode.sendPrompt(session.id, replyText);
498
+ log.info({ chatId, replyText }, 'Simulated permission reply sent to OpenCode');
499
+ }
500
+ catch (err) {
501
+ log.error({ err, chatId, replyText }, 'Failed to send simulated permission reply');
502
+ }
503
+ }
504
+ return { toast: { type: 'success', content: confirmText } };
505
+ }
506
+ // Real OpenCode permission IDs are `per_*`. If we see one with no pending,
507
+ // it's almost always a duplicate event (Feishu re-delivery or quick re-click)
508
+ // for a request we already handled. The card is already in confirmation state
509
+ // from the first click — return success silently rather than misleading
510
+ // "已过期" warning.
511
+ const isRealOpencodePerm = actionType === 'perm' && valueId.startsWith('per_');
512
+ if (isRealOpencodePerm) {
513
+ log.info({ chatId, messageId, valueId }, 'Permission already processed (likely duplicate event), returning success');
514
+ return { toast: { type: 'success', content: '已处理' } };
515
+ }
516
+ // Update the card to remove stale buttons so the user doesn't keep clicking
517
+ this.feishuApi.updateCard(messageId, FeishuCard.createExpiredCard())
518
+ .then(() => log.info({ chatId, messageId }, 'Updated stale card to expired state'))
519
+ .catch((err) => log.warn({ err, chatId, messageId }, 'Failed to update stale card to expired'));
520
+ return { toast: { type: 'warning', content: '该操作已过期' } };
521
+ }
522
+ // Route to the appropriate handler
523
+ if (actionType === 'perm') {
524
+ return this.handlePermissionCardAction(chatId, messageId, value, pending);
525
+ }
526
+ return this.handleQuestionCardAction(chatId, messageId, value, pending);
527
+ }
528
+ async handlePermissionCardAction(chatId, messageId, value, pending) {
529
+ if (pending.kind !== 'permission') {
530
+ return { toast: { type: 'error', content: '当前不是权限请求' } };
531
+ }
532
+ const reply = value.reply;
533
+ if (!reply || !['once', 'always', 'reject'].includes(reply)) {
534
+ return { toast: { type: 'error', content: '无效的权限响应' } };
535
+ }
536
+ const perm = pending.data;
537
+ log.info({ chatId, permissionId: perm.id, reply }, 'Card action: replying to permission');
538
+ const confirmText = reply === 'reject'
539
+ ? '已拒绝该权限请求。'
540
+ : reply === 'always'
541
+ ? '已永久授权该权限。'
542
+ : '已授权一次该权限。';
543
+ const confirmCard = FeishuCard.createInteractionRepliedCard('permission', reply);
544
+ // Update in-memory state synchronously so concurrent flushCard / re-clicks
545
+ // see the new state immediately. This must happen BEFORE we return so the
546
+ // caller doesn't process duplicate events.
547
+ this.sessionManager.clearPendingInteraction(chatId);
548
+ const session = this.sessionManager.getSession(chatId);
549
+ if (session) {
550
+ // Re-bind currentMessageId to the clicked card so subsequent flushCard
551
+ // calls update the same card the user sees (AI may have sent cards via
552
+ // MCP, causing currentMessageId to diverge from the clicked messageId).
553
+ if (messageId !== session.currentMessageId) {
554
+ this.sessionManager.setCurrentMessage(chatId, messageId);
555
+ }
556
+ // Mark that the interaction was handled via card click so flushCard
557
+ // won't overwrite the confirmation state with AI streaming output.
558
+ session.interactionReplied = true;
559
+ }
560
+ this.sessionManager.updateStatus(chatId, 'idle');
561
+ // Fire the network calls in the background and return the toast immediately.
562
+ // Feishu's UI has a strict callback timeout — awaiting both replyPermission
563
+ // and updateCard (~500ms total) was overrunning it and causing the client
564
+ // to display its own error popup despite our handler succeeding.
565
+ void (async () => {
566
+ try {
567
+ await this.opencode.replyPermission(perm.id, reply);
568
+ log.info({ chatId, permissionId: perm.id }, 'replyPermission relayed to OpenCode');
569
+ }
570
+ catch (err) {
571
+ log.error({ err, chatId, permissionId: perm.id }, 'replyPermission failed (background)');
572
+ }
573
+ try {
574
+ await this.feishuApi.updateCard(messageId, confirmCard);
575
+ log.info({ chatId, messageId, permissionId: perm.id }, 'Confirmation card updated');
576
+ }
577
+ catch (err) {
578
+ log.error({ err, chatId, messageId }, 'updateCard for confirmation failed (background)');
579
+ }
580
+ })();
581
+ // NOTE: do NOT include the `card` field in the response. Feishu requires
582
+ // it wrapped as `{ type: 'raw'|'template', data: {...} }`; passing the raw
583
+ // CardContent directly is treated as a malformed response and the client
584
+ // shows its own error popup. We update the card via API in the background
585
+ // call above, so the response only needs a toast.
586
+ return { toast: { type: 'success', content: confirmText } };
587
+ }
588
+ async handleQuestionCardAction(chatId, messageId, value, pending) {
589
+ if (pending.kind !== 'question') {
590
+ return { toast: { type: 'error', content: '当前不是问题选择' } };
591
+ }
592
+ const answers = value.ans;
593
+ if (!answers || !Array.isArray(answers)) {
594
+ return { toast: { type: 'error', content: '无效的选择' } };
595
+ }
596
+ const q = pending.data;
597
+ log.info({ chatId, requestId: q.id, answers }, 'Card action: replying to question');
598
+ const label = answers.map(a => a.join(', ')).join('; ');
599
+ const confirmCard = FeishuCard.createInteractionRepliedCard('question', label);
600
+ // Update in-memory state synchronously and return immediately, doing the
601
+ // network calls in the background — see handlePermissionCardAction for the
602
+ // rationale on returning fast.
603
+ this.sessionManager.clearPendingInteraction(chatId);
604
+ const session = this.sessionManager.getSession(chatId);
605
+ if (session) {
606
+ if (messageId !== session.currentMessageId) {
607
+ this.sessionManager.setCurrentMessage(chatId, messageId);
608
+ }
609
+ session.interactionReplied = true;
610
+ }
611
+ this.sessionManager.updateStatus(chatId, 'idle');
612
+ void (async () => {
613
+ try {
614
+ await this.opencode.replyQuestion(q.id, answers);
615
+ log.info({ chatId, requestId: q.id }, 'replyQuestion relayed to OpenCode');
616
+ }
617
+ catch (err) {
618
+ log.error({ err, chatId, requestId: q.id }, 'replyQuestion failed (background)');
619
+ }
620
+ try {
621
+ await this.feishuApi.updateCard(messageId, confirmCard);
622
+ log.info({ chatId, messageId, requestId: q.id }, 'Confirmation card updated');
623
+ }
624
+ catch (err) {
625
+ log.error({ err, chatId, messageId }, 'updateCard for confirmation failed (background)');
626
+ }
627
+ })();
628
+ // See note in handlePermissionCardAction about omitting the `card` field.
629
+ return { toast: { type: 'success', content: `已提交选择:${label}` } };
630
+ }
631
+ async handleNavigationCardAction(chatId, messageId, value) {
632
+ const cmd = value.cmd;
633
+ const sessionId = value.sessionId;
634
+ if (!cmd) {
635
+ log.warn({ chatId, messageId, value }, 'Navigation action missing cmd');
636
+ return { toast: { type: 'error', content: '无效的导航操作' } };
637
+ }
638
+ log.info({ chatId, messageId, cmd, sessionId }, 'Card action: navigation command');
639
+ try {
640
+ await this.opencode.sendCommand(sessionId || chatId, cmd);
641
+ log.info({ chatId, messageId, cmd }, 'Navigation command sent to OpenCode');
642
+ return { toast: { type: 'success', content: '已执行' } };
643
+ }
644
+ catch (err) {
645
+ log.error({ err, chatId, messageId, cmd }, 'Failed to send navigation command');
646
+ return { toast: { type: 'error', content: '执行失败' } };
647
+ }
648
+ }
649
+ /**
650
+ * Handle list commands (models, agents, commands, sessions) by fetching data from OpenCode.
651
+ */
652
+ async handleListCommand(chatId, sessionId, command, args) {
653
+ log.info({ sessionId, command, args }, `Fetching ${command} list from OpenCode`);
654
+ try {
655
+ let message = '';
656
+ switch (command) {
657
+ case 'models': {
658
+ const providers = await this.opencode.getProviders();
659
+ message = '**🤖 可用模型列表**\n\n';
660
+ if (providers && Array.isArray(providers)) {
661
+ for (const provider of providers) {
662
+ message += `**${provider.name || provider.id || 'Unknown'}**\n`;
663
+ if (provider.models && Array.isArray(provider.models)) {
664
+ for (const model of provider.models) {
665
+ message += `- ${model.id || model.name || 'Unknown'}`;
666
+ if (model.default)
667
+ message += ' (默认)';
668
+ message += '\n';
669
+ }
670
+ }
671
+ message += '\n';
672
+ }
673
+ }
674
+ else {
675
+ message += '暂无模型信息\n';
676
+ }
677
+ break;
678
+ }
679
+ case 'agents': {
680
+ const agents = await this.opencode.getAgents();
681
+ message = '**🎯 可用代理列表**\n\n';
682
+ if (agents && Array.isArray(agents)) {
683
+ for (const agent of agents) {
684
+ message += `- ${agent.name || agent.id || 'Unknown'}`;
685
+ if (agent.description)
686
+ message += `: ${agent.description}`;
687
+ message += '\n';
688
+ }
689
+ }
690
+ else {
691
+ message += '暂无代理信息\n';
692
+ }
693
+ break;
694
+ }
695
+ case 'commands': {
696
+ const commands = await this.opencode.getCommands();
697
+ message = '**⌨️ 可用命令列表**\n\n';
698
+ if (commands && Array.isArray(commands)) {
699
+ for (const cmd of commands) {
700
+ message += `- /${cmd.name || cmd.id || 'Unknown'}`;
701
+ if (cmd.description)
702
+ message += `: ${cmd.description}`;
703
+ message += '\n';
704
+ }
705
+ }
706
+ else {
707
+ message += '暂无命令信息\n';
708
+ }
709
+ break;
710
+ }
711
+ case 'sessions': {
712
+ const sessions = await this.opencode.getSessions();
713
+ message = '**💬 会话列表**\n\n';
714
+ if (sessions && Array.isArray(sessions)) {
715
+ for (const sess of sessions) {
716
+ message += `- ${sess.title || sess.id || 'Unknown'}`;
717
+ if (sess.createdAt)
718
+ message += ` (${new Date(sess.createdAt).toLocaleString()})`;
719
+ message += '\n';
720
+ }
721
+ }
722
+ else {
723
+ message += '暂无会话信息\n';
724
+ }
725
+ break;
726
+ }
727
+ case 'tools': {
728
+ const tools = await this.opencode.getTools();
729
+ message = '**🔧 可用工具列表**\n\n';
730
+ if (tools && Array.isArray(tools)) {
731
+ for (const tool of tools) {
732
+ message += `- ${tool.name || tool.id || 'Unknown'}`;
733
+ if (tool.description)
734
+ message += `: ${tool.description}`;
735
+ message += '\n';
736
+ }
737
+ }
738
+ else {
739
+ message += '暂无工具信息\n';
740
+ }
741
+ break;
742
+ }
743
+ case 'worktrees': {
744
+ const worktrees = await this.opencode.getWorktrees();
745
+ message = '**🌳 工作树列表**\n\n';
746
+ if (worktrees && Array.isArray(worktrees)) {
747
+ for (const wt of worktrees) {
748
+ message += `- ${wt.name || wt.id || 'Unknown'}`;
749
+ if (wt.path)
750
+ message += ` (${wt.path})`;
751
+ message += '\n';
752
+ }
753
+ }
754
+ else {
755
+ message += '暂无工作树信息\n';
756
+ }
757
+ break;
758
+ }
759
+ case 'files': {
760
+ const files = await this.opencode.getFiles(args);
761
+ message = `**📁 文件列表${args ? ` (${args})` : ''}**\n\n`;
762
+ if (files && Array.isArray(files)) {
763
+ for (const file of files) {
764
+ message += `- ${file.name || file.id || 'Unknown'}`;
765
+ if (file.type)
766
+ message += ` [${file.type}]`;
767
+ message += '\n';
768
+ }
769
+ }
770
+ else {
771
+ message += '暂无文件信息\n';
772
+ }
773
+ break;
774
+ }
775
+ case 'status': {
776
+ const status = await this.opencode.getStatus();
777
+ message = '**📊 项目状态**\n\n';
778
+ if (status) {
779
+ message += `- 分支: ${status.branch || 'Unknown'}\n`;
780
+ message += `- 提交: ${status.commit || 'Unknown'}\n`;
781
+ if (status.files && Array.isArray(status.files)) {
782
+ message += `- 变更文件: ${status.files.length}\n`;
783
+ for (const file of status.files) {
784
+ message += ` ${file.status || '?'} ${file.name || 'Unknown'}\n`;
785
+ }
786
+ }
787
+ }
788
+ else {
789
+ message += '暂无状态信息\n';
790
+ }
791
+ break;
792
+ }
793
+ case 'config': {
794
+ const config = await this.opencode.getConfig();
795
+ message = '**⚙️ 配置信息**\n\n';
796
+ if (config) {
797
+ message += `- 项目: ${config.name || 'Unknown'}\n`;
798
+ message += `- 目录: ${config.directory || 'Unknown'}\n`;
799
+ if (config.providers && Array.isArray(config.providers)) {
800
+ message += `- Providers: ${config.providers.length}\n`;
801
+ }
802
+ if (config.agents && Array.isArray(config.agents)) {
803
+ message += `- Agents: ${config.agents.length}\n`;
804
+ }
805
+ }
806
+ else {
807
+ message += '暂无配置信息\n';
808
+ }
809
+ break;
810
+ }
811
+ }
812
+ await this.feishuApi.sendText(chatId, message);
813
+ this.sessionManager.updateStatus(chatId, 'idle');
814
+ this.sessionManager.clearCurrentMessage(chatId);
815
+ log.info({ command }, `${command} list sent successfully`);
816
+ }
817
+ catch (err) {
818
+ log.error({ err, command }, `Failed to fetch ${command} list`);
819
+ await this.feishuApi.sendText(chatId, `❌ 获取${command}列表失败`);
820
+ this.sessionManager.updateStatus(chatId, 'idle');
821
+ this.sessionManager.clearCurrentMessage(chatId);
822
+ }
823
+ }
320
824
  /**
321
825
  * Parse a slash command from text.
322
826
  * Returns { command, args } if text starts with /, otherwise null.
@@ -335,48 +839,31 @@ export class MessageHandler {
335
839
  const args = withoutPrefix.slice(firstSpace + 1).trim();
336
840
  return { command, args: args || undefined };
337
841
  }
338
- /**
339
- * Detect document creation intent from user message.
340
- * Supports patterns like: "创建文档 XXX", "新建一个文档", "写个文档"
341
- */
342
- detectDocCreationIntent(text) {
343
- const patterns = [
344
- /(?:创建|新建|写个?)(?:一个|个)?(?:飞书)?(?:云)?文档[,::]?\s*["「『【]([^"」』】]+)["」』】]/i,
345
- /(?:创建|新建|写个?)(?:一个|个)?(?:飞书)?(?:云)?文档[,::]?\s*(.+?)(?:\n|$)/i,
346
- /(?:创建|新建|写个?)(?:一个|个)?(.+?)(?:的|的)?(?:飞书)?(?:云)?文档/i,
347
- /(?:帮我|请)?(?:创建|新建|写)(?:一个|个)?(.+)/i,
348
- ];
349
- for (const pattern of patterns) {
350
- const match = text.match(pattern);
351
- if (match && match[1]) {
352
- const title = match[1].trim().replace(/^(?:一个|个|这份|这个|的)\s*/, '');
353
- if (title && title.length > 0 && title.length < 100) {
354
- return { intent: true, title };
355
- }
356
- }
357
- }
358
- // Simple keyword detection as fallback
359
- if (/^(?:创建|新建|写个?)文档/.test(text.trim())) {
360
- return { intent: true, title: '未命名文档' };
361
- }
362
- return { intent: false, title: '' };
363
- }
364
842
  /**
365
843
  * Unified media download handler for images, files, audio, and video.
844
+ * Returns a text placeholder for the message and optionally the file info
845
+ * for forwarding to OpenCode.
366
846
  */
367
847
  async downloadMedia(messageId, fileKey, resourceType, fileName, mimeType, typeLabel) {
368
848
  if (!fileKey) {
369
- return `[${typeLabel}消息]`;
849
+ return { text: `[${typeLabel}消息]` };
370
850
  }
371
851
  try {
372
852
  log.info({ messageId, fileKey, fileName }, `Downloading ${typeLabel}...`);
373
853
  const buffer = await this.feishuApi.downloadMedia(messageId, fileKey, resourceType);
374
854
  const downloaded = await this.fileDownloader.saveBuffer(buffer, fileName, mimeType);
375
- return `[${typeLabel}已上传: ${downloaded.filePath}]`;
855
+ return {
856
+ text: `[${typeLabel}已上传: ${downloaded.filePath}]`,
857
+ file: {
858
+ filePath: downloaded.filePath,
859
+ fileName: downloaded.fileName,
860
+ mimeType,
861
+ },
862
+ };
376
863
  }
377
864
  catch (err) {
378
865
  log.error({ err, messageId, fileKey }, `Failed to download ${typeLabel}`);
379
- return `[${typeLabel}消息(下载失败)]`;
866
+ return { text: `[${typeLabel}消息(下载失败)]` };
380
867
  }
381
868
  }
382
869
  }