@neomei/opencode-feishu 0.2.5 → 0.2.7

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 (47) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/dist/core/message-handler.d.ts +27 -9
  3. package/dist/core/message-handler.d.ts.map +1 -1
  4. package/dist/core/message-handler.js +362 -79
  5. package/dist/core/message-handler.js.map +1 -1
  6. package/dist/core/session-manager.d.ts +4 -1
  7. package/dist/core/session-manager.d.ts.map +1 -1
  8. package/dist/core/session-manager.js +15 -0
  9. package/dist/core/session-manager.js.map +1 -1
  10. package/dist/core/types.d.ts +49 -0
  11. package/dist/core/types.d.ts.map +1 -1
  12. package/dist/feishu/api.d.ts +6 -0
  13. package/dist/feishu/api.d.ts.map +1 -1
  14. package/dist/feishu/api.js +71 -6
  15. package/dist/feishu/api.js.map +1 -1
  16. package/dist/feishu/card.d.ts +5 -2
  17. package/dist/feishu/card.d.ts.map +1 -1
  18. package/dist/feishu/card.js +145 -18
  19. package/dist/feishu/card.js.map +1 -1
  20. package/dist/feishu/event-source.d.ts +6 -0
  21. package/dist/feishu/event-source.d.ts.map +1 -1
  22. package/dist/feishu/event-source.js +54 -0
  23. package/dist/feishu/event-source.js.map +1 -1
  24. package/dist/opencode/client.d.ts +4 -0
  25. package/dist/opencode/client.d.ts.map +1 -1
  26. package/dist/opencode/client.js +55 -5
  27. package/dist/opencode/client.js.map +1 -1
  28. package/dist/opencode/event-handler.d.ts +6 -0
  29. package/dist/opencode/event-handler.d.ts.map +1 -1
  30. package/dist/opencode/event-handler.js +170 -10
  31. package/dist/opencode/event-handler.js.map +1 -1
  32. package/dist/plugin.d.ts.map +1 -1
  33. package/dist/plugin.js +10 -3
  34. package/dist/plugin.js.map +1 -1
  35. package/dist/services/doc-service.d.ts +11 -45
  36. package/dist/services/doc-service.d.ts.map +1 -1
  37. package/dist/services/doc-service.js +72 -174
  38. package/dist/services/doc-service.js.map +1 -1
  39. package/dist/services/im-service.d.ts.map +1 -1
  40. package/dist/services/im-service.js +10 -6
  41. package/dist/services/im-service.js.map +1 -1
  42. package/dist/standalone.d.ts.map +1 -1
  43. package/dist/standalone.js +10 -3
  44. package/dist/standalone.js.map +1 -1
  45. package/dist/types/extended.d.ts +2 -1
  46. package/dist/types/extended.d.ts.map +1 -1
  47. package/package.json +5 -5
package/CHANGELOG.md CHANGED
@@ -81,9 +81,26 @@ sessions, preflight diagnostics).
81
81
  - Image / file message input (download support added).
82
82
  - Feishu document reading and editing (full DocService added).
83
83
 
84
+ ## [0.2.6] — 2026-05-06
85
+
86
+ ### Added
87
+
88
+ - **OpenCode interactive event support** — Bridges TUI permission/choice prompts to Feishu:
89
+ - `permission.asked`: Displays a 🔒 permission request card with operation scope; user replies with `确认` (once), `始终` (always), or `拒绝` (reject)
90
+ - `question.asked`: Displays a ❓ multiple-choice card; user replies with option numbers or labels (e.g. `1` or `1,3` for multi-select)
91
+ - `permission.replied` / `question.replied` / `question.rejected`: Automatically clear the interaction prompt
92
+ - **Slash command support** — Messages starting with `/` are routed to OpenCode's `session.command` API instead of `sendPrompt`. Supports command arguments (e.g. `/compact all`)
93
+ - `replyPermission()` and `replyQuestion()` methods on `OpenCodeClient`
94
+ - `PendingInteraction` tracking in `SessionManager`
95
+ - Extended `FeishuCard` with inline interaction display and dedicated permission/question cards
96
+
97
+ ### Resolved
98
+
99
+ - Slash commands (now supported via `session.command` API).
100
+ - Interactive permission/choice prompts from OpenCode tools (now bridged to Feishu cards).
101
+
84
102
  ### Known gaps (planned for subsequent releases)
85
103
 
86
- - Slash commands (`/new`, `/sessions`, `/model`, etc.).
87
104
  - `feishu_notify` tool (agent pushing progress mid-task).
88
105
  - Multi-agent / multi-channel abstraction.
89
106
 
@@ -1,8 +1,7 @@
1
- import type { FeishuConfig, FeishuMessage } from '../core/types.js';
1
+ import type { FeishuConfig, FeishuMessage, FeishuCardAction, CardContent } from '../core/types.js';
2
2
  import type { SessionManager } from '../core/session-manager.js';
3
3
  import type { FeishuAPI } from '../feishu/api.js';
4
4
  import type { OpenCodeClient } from '../opencode/client.js';
5
- import type { DocService } from '../services/doc-service.js';
6
5
  export declare class MessageHandler {
7
6
  private config;
8
7
  private sessionManager;
@@ -10,19 +9,38 @@ export declare class MessageHandler {
10
9
  private opencode;
11
10
  private dedup;
12
11
  private fileDownloader;
13
- private docService?;
14
12
  private botName;
15
- private thinkingTimers;
16
- constructor(config: FeishuConfig, sessionManager: SessionManager, feishuApi: FeishuAPI, opencode: OpenCodeClient, docService?: DocService, botName?: string);
13
+ constructor(config: FeishuConfig, sessionManager: SessionManager, feishuApi: FeishuAPI, opencode: OpenCodeClient, botName?: string);
17
14
  handleMessage(message: FeishuMessage): Promise<void>;
18
- private stopThinkingAnimation;
19
15
  /**
20
- * Detect document creation intent from user message.
21
- * Supports patterns like: "创建文档 XXX", "新建一个文档", "写个文档"
16
+ * Try to parse the user message as a reply to a pending interaction.
17
+ * Returns true if handled.
22
18
  */
23
- private detectDocCreationIntent;
19
+ private handleInteractionReply;
20
+ /**
21
+ * Handle a card button click (card.action.trigger event).
22
+ * Parses the button value and routes to the appropriate OpenCode API.
23
+ * Returns a card callback response for Feishu (toast / updated card).
24
+ */
25
+ handleCardAction(action: FeishuCardAction): Promise<{
26
+ toast?: {
27
+ type: string;
28
+ content: string;
29
+ };
30
+ card?: CardContent;
31
+ } | undefined>;
32
+ private handlePermissionCardAction;
33
+ private handleQuestionCardAction;
34
+ /**
35
+ * Parse a slash command from text.
36
+ * Returns { command, args } if text starts with /, otherwise null.
37
+ * Examples: `/help` → { command: 'help' }, `/compact all` → { command: 'compact', args: 'all' }
38
+ */
39
+ private parseSlashCommand;
24
40
  /**
25
41
  * Unified media download handler for images, files, audio, and video.
42
+ * Returns a text placeholder for the message and optionally the file info
43
+ * for forwarding to OpenCode.
26
44
  */
27
45
  private downloadMedia;
28
46
  }
@@ -1 +1 @@
1
- {"version":3,"file":"message-handler.d.ts","sourceRoot":"","sources":["../../src/core/message-handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAC5D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;AAQ7D,qBAAa,cAAc;IACzB,OAAO,CAAC,MAAM,CAAe;IAC7B,OAAO,CAAC,cAAc,CAAiB;IACvC,OAAO,CAAC,SAAS,CAAY;IAC7B,OAAO,CAAC,QAAQ,CAAiB;IACjC,OAAO,CAAC,KAAK,CAAsB;IACnC,OAAO,CAAC,cAAc,CAAiB;IACvC,OAAO,CAAC,UAAU,CAAC,CAAa;IAChC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,cAAc,CAAqD;gBAGzE,MAAM,EAAE,YAAY,EACpB,cAAc,EAAE,cAAc,EAC9B,SAAS,EAAE,SAAS,EACpB,QAAQ,EAAE,cAAc,EACxB,UAAU,CAAC,EAAE,UAAU,EACvB,OAAO,SAAO;IAYV,aAAa,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IA4Q1D,OAAO,CAAC,qBAAqB;IAQ7B;;;OAGG;IACH,OAAO,CAAC,uBAAuB;IA0B/B;;OAEG;YACW,aAAa;CAsB5B"}
1
+ {"version":3,"file":"message-handler.d.ts","sourceRoot":"","sources":["../../src/core/message-handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACnG,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAQ5D,qBAAa,cAAc;IACzB,OAAO,CAAC,MAAM,CAAe;IAC7B,OAAO,CAAC,cAAc,CAAiB;IACvC,OAAO,CAAC,SAAS,CAAY;IAC7B,OAAO,CAAC,QAAQ,CAAiB;IACjC,OAAO,CAAC,KAAK,CAAsB;IACnC,OAAO,CAAC,cAAc,CAAiB;IACvC,OAAO,CAAC,OAAO,CAAS;gBAGtB,MAAM,EAAE,YAAY,EACpB,cAAc,EAAE,cAAc,EAC9B,SAAS,EAAE,SAAS,EACpB,QAAQ,EAAE,cAAc,EACxB,OAAO,SAAO;IAWV,aAAa,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IAsR1D;;;OAGG;YACW,sBAAsB;IAqFpC;;;;OAIG;IACG,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC;QAAE,KAAK,CAAC,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,OAAO,EAAE,MAAM,CAAA;SAAE,CAAC;QAAC,IAAI,CAAC,EAAE,WAAW,CAAA;KAAE,GAAG,SAAS,CAAC;YA8F1H,0BAA0B;YAsE1B,wBAAwB;IAqDtC;;;;OAIG;IACH,OAAO,CAAC,iBAAiB;IAgBzB;;;;OAIG;YACW,aAAa;CA6B5B"}
@@ -10,15 +10,12 @@ 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
+ constructor(config, sessionManager, feishuApi, opencode, botName = '点点') {
17
15
  this.config = config;
18
16
  this.sessionManager = sessionManager;
19
17
  this.feishuApi = feishuApi;
20
18
  this.opencode = opencode;
21
- this.docService = docService;
22
19
  this.botName = botName;
23
20
  this.dedup = new MessageDeduplicator(config.dedupTtl || 600_000);
24
21
  this.fileDownloader = new FileDownloader();
@@ -85,6 +82,9 @@ export class MessageHandler {
85
82
  await this.feishuApi.sendText(chatId, '⏳ 正在处理上一条消息,请稍候...');
86
83
  return;
87
84
  }
85
+ // Clear previous turn's card reference so a new card is created for this turn.
86
+ // (EventHandler no longer clears this on session.idle to avoid race conditions.)
87
+ this.sessionManager.clearCurrentMessage(chatId);
88
88
  // Resolve sender name (with cache)
89
89
  const senderUnionId = message.sender.sender_id?.union_id || 'unknown';
90
90
  const senderName = await this.feishuApi.getUserName(senderUnionId);
@@ -98,18 +98,34 @@ export class MessageHandler {
98
98
  case 'text':
99
99
  text = content.text || '';
100
100
  break;
101
- case 'image':
102
- text = await this.downloadMedia(message.message_id, content.image_key, 'image', 'image.jpg', 'image/jpeg', '图片');
101
+ case 'image': {
102
+ const result = await this.downloadMedia(message.message_id, content.image_key, 'image', 'image.jpg', 'image/jpeg', '图片');
103
+ text = result.text;
104
+ if (result.file)
105
+ files.push(result.file);
103
106
  break;
104
- case 'file':
105
- text = await this.downloadMedia(message.message_id, content.file_key, 'file', content.file_name || 'unknown', 'application/octet-stream', '文件');
107
+ }
108
+ case 'file': {
109
+ const result = await this.downloadMedia(message.message_id, content.file_key, 'file', content.file_name || 'unknown', 'application/octet-stream', '文件');
110
+ text = result.text;
111
+ if (result.file)
112
+ files.push(result.file);
106
113
  break;
107
- case 'audio':
108
- text = await this.downloadMedia(message.message_id, content.file_key, 'file', 'audio.opus', 'audio/opus', '语音');
114
+ }
115
+ case 'audio': {
116
+ const result = await this.downloadMedia(message.message_id, content.file_key, 'file', 'audio.opus', 'audio/opus', '语音');
117
+ text = result.text;
118
+ if (result.file)
119
+ files.push(result.file);
109
120
  break;
110
- case 'media':
111
- text = await this.downloadMedia(message.message_id, content.file_key, 'file', content.file_name || 'video.mp4', 'video/mp4', '视频');
121
+ }
122
+ case 'media': {
123
+ const result = await this.downloadMedia(message.message_id, content.file_key, 'file', content.file_name || 'video.mp4', 'video/mp4', '视频');
124
+ text = result.text;
125
+ if (result.file)
126
+ files.push(result.file);
112
127
  break;
128
+ }
113
129
  case 'sticker':
114
130
  text = `[表情消息]`;
115
131
  break;
@@ -138,22 +154,26 @@ export class MessageHandler {
138
154
  return;
139
155
  }
140
156
  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
- }
157
+ // Check for pending interaction reply before proceeding with normal message flow
158
+ const pendingInteraction = this.sessionManager.getPendingInteraction?.(chatId);
159
+ if (pendingInteraction) {
160
+ try {
161
+ const handled = await this.handleInteractionReply(chatId, text.trim(), pendingInteraction);
162
+ if (handled)
163
+ return;
164
+ // The user's message does not match an interaction reply pattern.
165
+ // Do NOT clear the pending interaction the user may still click the
166
+ // card buttons. Instead, tell them to finish the interaction first.
167
+ }
168
+ catch {
169
+ // Error handling interaction reply: clear to prevent the user from
170
+ // getting permanently stuck.
171
+ this.sessionManager.clearPendingInteraction(chatId);
156
172
  }
173
+ await this.feishuApi.sendText(chatId, pendingInteraction.kind === 'permission'
174
+ ? '⏳ 请先处理上方的权限请求(点击卡片按钮),或等待当前任务完成。'
175
+ : '⏳ 请先处理上方的选择(点击卡片按钮或回复选项),或等待当前任务完成。');
176
+ return;
157
177
  }
158
178
  // Atomically check and set busy status to prevent race conditions
159
179
  const currentSession = this.sessionManager.getSession(chatId);
@@ -164,44 +184,43 @@ export class MessageHandler {
164
184
  this.sessionManager.updateStatus(chatId, 'busy');
165
185
  // Show thinking card immediately before sending prompt
166
186
  if (!this.config.showProcess) {
167
- const dots = ['.', '..', '...', '....'];
168
- let frame = 0;
169
- const thinkingCard = FeishuCard.createThinkingCard(this.botName, dots[frame]);
187
+ const thinkingCard = FeishuCard.createThinkingCard(this.botName);
188
+ log.info({ chatId }, 'Sending thinking card');
170
189
  const msg = await this.feishuApi.sendCard(chatId, thinkingCard);
190
+ log.info({ chatId, messageId: msg?.message_id }, 'Thinking card sent result');
171
191
  if (msg?.message_id) {
172
192
  this.sessionManager.setCurrentMessage(chatId, msg.message_id);
173
- // Animate the ellipsis until content arrives
174
- const timer = setInterval(async () => {
175
- const session = this.sessionManager.getSession(chatId);
176
- if (!session?.currentMessageId || !this.thinkingTimers.has(chatId)) {
177
- clearInterval(timer);
178
- return;
179
- }
180
- if (session.currentContent) {
181
- this.stopThinkingAnimation(chatId);
182
- return;
183
- }
184
- frame = (frame + 1) % dots.length;
185
- try {
186
- await this.feishuApi.updateCard(session.currentMessageId, FeishuCard.createThinkingCard(this.botName, dots[frame]));
187
- }
188
- catch { /* best-effort */ }
189
- }, 500);
190
- this.thinkingTimers.set(chatId, timer);
191
193
  }
192
194
  }
193
195
  // Send message to OpenCode
194
196
  try {
195
- log.info({ sessionId: session.id, files: files.length }, 'Sending prompt to OpenCode');
196
- await this.opencode.sendPrompt(session.id, text, files.length > 0 ? files : undefined);
197
- log.info('Prompt sent successfully');
197
+ const slashCommand = this.parseSlashCommand(text);
198
+ if (slashCommand) {
199
+ log.info({ sessionId: session.id, command: slashCommand.command, args: slashCommand.args }, 'Sending command to OpenCode');
200
+ await this.opencode.sendCommand(session.id, slashCommand.command, slashCommand.args);
201
+ log.info('Command sent successfully');
202
+ }
203
+ else {
204
+ log.info({ sessionId: session.id, files: files.length }, 'Sending prompt to OpenCode');
205
+ // Inject chat context so the AI knows the current chat_id for Feishu operations
206
+ const contextPrefix = `[系统上下文] 当前飞书对话ID: ${chatId}\n\n` +
207
+ `你配置了飞书 MCP 工具,可以使用以下工具来操作飞书文档、日历等:\n` +
208
+ `- docx.v1.document.create — 创建飞书文档\n` +
209
+ `- docx.v1.documentBlockChildren.create — 在文档中插入内容\n` +
210
+ `- docx.v1.documentBlock.patch — 更新文档块\n` +
211
+ `- drive.v1.file.createFolder — 创建文件夹\n` +
212
+ `- drive.v1.media.uploadPrepare/uploadFinish — 上传文件\n` +
213
+ `当用户请求创建飞书文档时,请直接调用 MCP 工具创建,不要在回复中询问。\n` +
214
+ `重要:飞书文档的访问链接必须使用 https://www.feishu.cn/docx/ 域名,不要使用 https://open.feishu.cn/docx/ 域名。\n\n`;
215
+ await this.opencode.sendPrompt(session.id, contextPrefix + text, files.length > 0 ? files : undefined);
216
+ log.info('Prompt sent successfully');
217
+ }
198
218
  }
199
219
  catch (err) {
200
220
  log.error({ err }, 'Failed to send prompt');
201
221
  await this.feishuApi.sendCard(chatId, FeishuCard.createErrorCard(`发送消息失败: ${err instanceof Error ? err.message : String(err)}`));
202
222
  this.sessionManager.updateStatus(chatId, 'idle');
203
223
  this.sessionManager.clearCurrentMessage(chatId);
204
- this.stopThinkingAnimation(chatId);
205
224
  }
206
225
  }
207
226
  catch (err) {
@@ -214,55 +233,319 @@ export class MessageHandler {
214
233
  }
215
234
  }
216
235
  }
217
- stopThinkingAnimation(chatId) {
218
- const timer = this.thinkingTimers.get(chatId);
219
- if (timer) {
220
- clearInterval(timer);
221
- this.thinkingTimers.delete(chatId);
236
+ /**
237
+ * Try to parse the user message as a reply to a pending interaction.
238
+ * Returns true if handled.
239
+ */
240
+ async handleInteractionReply(chatId, text, interaction) {
241
+ try {
242
+ if (interaction.kind === 'permission') {
243
+ const perm = interaction.data;
244
+ let reply;
245
+ if (text === '确认' || text === '同意' || text === '允许' || text === 'yes' || text === 'y') {
246
+ reply = 'once';
247
+ }
248
+ else if (text === '始终' || text === '总是' || text === 'always') {
249
+ reply = 'always';
250
+ }
251
+ else if (text === '拒绝' || text === '否' || text === '不同意' || text === 'no' || text === 'n') {
252
+ reply = 'reject';
253
+ }
254
+ if (!reply)
255
+ return false;
256
+ log.info({ chatId, permissionId: perm.id, reply }, 'Replying to permission');
257
+ await this.opencode.replyPermission(perm.id, reply);
258
+ this.sessionManager.clearPendingInteraction(chatId);
259
+ await this.feishuApi.sendCard(chatId, FeishuCard.createInteractionRepliedCard('permission', reply));
260
+ return true;
261
+ }
262
+ if (interaction.kind === 'question') {
263
+ const q = interaction.data;
264
+ // Parse answers: comma or space separated indices/labels
265
+ const selections = text.split(/[,,\s]+/).filter(s => s.length > 0);
266
+ if (selections.length === 0)
267
+ return false;
268
+ const answers = [];
269
+ for (const [qIdx, question] of q.questions.entries()) {
270
+ const answer = [];
271
+ for (const sel of selections) {
272
+ // Try numeric index first
273
+ const idx = parseInt(sel, 10);
274
+ if (!isNaN(idx) && idx >= 1 && idx <= question.options.length) {
275
+ answer.push(question.options[idx - 1].label);
276
+ }
277
+ else {
278
+ // Try matching label
279
+ const match = question.options.find(o => o.label === sel || o.label.toLowerCase() === sel.toLowerCase());
280
+ if (match)
281
+ answer.push(match.label);
282
+ }
283
+ }
284
+ // Deduplicate
285
+ const unique = [...new Set(answer)];
286
+ if (unique.length > 0) {
287
+ answers.push(unique);
288
+ }
289
+ else if (qIdx < q.questions.length - 1) {
290
+ // This question has no valid answer but there are more questions
291
+ answers.push([]);
292
+ }
293
+ }
294
+ if (answers.length === 0 || answers.every(a => a.length === 0)) {
295
+ // Not a valid question reply, let normal processing handle it
296
+ return false;
297
+ }
298
+ log.info({ chatId, requestId: q.id, answers }, 'Replying to question');
299
+ await this.opencode.replyQuestion(q.id, answers);
300
+ this.sessionManager.clearPendingInteraction(chatId);
301
+ const label = answers.map(a => a.join(', ')).join('; ');
302
+ await this.feishuApi.sendCard(chatId, FeishuCard.createInteractionRepliedCard('question', label));
303
+ return true;
304
+ }
305
+ return false;
306
+ }
307
+ catch (err) {
308
+ log.error({ err, chatId, interactionKind: interaction.kind }, 'Failed to handle interaction reply');
309
+ // Don't return true on error — let the message fall through so user isn't stuck
310
+ return false;
222
311
  }
223
312
  }
224
313
  /**
225
- * Detect document creation intent from user message.
226
- * Supports patterns like: "创建文档 XXX", "新建一个文档", "写个文档"
314
+ * Handle a card button click (card.action.trigger event).
315
+ * Parses the button value and routes to the appropriate OpenCode API.
316
+ * Returns a card callback response for Feishu (toast / updated card).
227
317
  */
228
- detectDocCreationIntent(text) {
229
- const patterns = [
230
- /(?:创建|新建|写个?)(?:一个|个)?(?:飞书)?(?:云)?文档[,::]?\s*["「『【]([^"」』】]+)["」』】]/i,
231
- /(?:创建|新建|写个?)(?:一个|个)?(?:飞书)?(?:云)?文档[,::]?\s*(.+?)(?:\n|$)/i,
232
- /(?:创建|新建|写个?)(?:一个|个)?(.+?)(?:的|的)?(?:飞书)?(?:云)?文档/i,
233
- /(?:帮我|请)?(?:创建|新建|写)(?:一个|个)?(.+)/i,
234
- ];
235
- for (const pattern of patterns) {
236
- const match = text.match(pattern);
237
- if (match && match[1]) {
238
- const title = match[1].trim().replace(/^(?:一个|个|这份|这个|的)\s*/, '');
239
- if (title && title.length > 0 && title.length < 100) {
240
- return { intent: true, title };
318
+ async handleCardAction(action) {
319
+ const { chatId, messageId } = action;
320
+ const value = action.action.value;
321
+ if (!value || typeof value !== 'object') {
322
+ log.warn({ chatId, messageId, value }, 'Card action missing value');
323
+ return { toast: { type: 'error', content: '无效操作' } };
324
+ }
325
+ const actionType = value.action || value._oc;
326
+ log.info({ chatId, messageId, actionType, value }, 'Card action received');
327
+ if (actionType !== 'perm' && actionType !== 'q') {
328
+ log.warn({ chatId, actionType, value }, 'Unknown card action type');
329
+ return { toast: { type: 'error', content: '不支持的操作' } };
330
+ }
331
+ // Verify there is a pending interaction for this chat
332
+ const pending = this.sessionManager.getPendingInteraction(chatId);
333
+ const session = this.sessionManager.getSession(chatId);
334
+ log.info({ chatId, messageId, hasPending: !!pending, pendingKind: pending?.kind, currentMessageId: session?.currentMessageId }, 'Checking pending interaction');
335
+ if (!pending) {
336
+ log.info({ chatId, messageId, currentMessageId: session?.currentMessageId }, 'No pending interaction for this chat, ignoring card action');
337
+ const valueId = (value.id || '').toString();
338
+ // If the button looks like an AI-generated permission card (not from OpenCode's permission.asked event),
339
+ // simulate a text reply so the AI can continue processing.
340
+ const isAiGeneratedPerm = actionType === 'perm' && valueId.startsWith('perm-');
341
+ if (isAiGeneratedPerm) {
342
+ const replyMap = {
343
+ once: '确认',
344
+ always: '始终允许',
345
+ reject: '拒绝',
346
+ };
347
+ const replyText = replyMap[value.reply] || '确认';
348
+ const confirmMap = {
349
+ once: '已授权一次',
350
+ always: '已永久授权',
351
+ reject: '已拒绝',
352
+ };
353
+ const confirmText = confirmMap[value.reply] || '已授权';
354
+ try {
355
+ // Wait for card update to complete before sending prompt,
356
+ // so the user sees the confirmation state immediately.
357
+ await this.feishuApi.updateCard(messageId, FeishuCard.createInteractionRepliedCard('permission', value.reply));
358
+ log.info({ chatId, messageId }, 'Updated AI-generated perm card to confirmed state');
359
+ }
360
+ catch (err) {
361
+ log.warn({ err, chatId, messageId }, 'Failed to update AI-generated perm card');
362
+ }
363
+ // Re-bind currentMessageId to the clicked card so subsequent
364
+ // flushCard calls update the same card instead of creating a new one.
365
+ if (session) {
366
+ this.sessionManager.setCurrentMessage(chatId, messageId);
367
+ log.info({ chatId, messageId }, 'Re-bound currentMessageId to clicked card');
241
368
  }
369
+ // Simulate user text reply to OpenCode so the AI continues
370
+ if (session) {
371
+ try {
372
+ await this.opencode.sendPrompt(session.id, replyText);
373
+ log.info({ chatId, replyText }, 'Simulated permission reply sent to OpenCode');
374
+ }
375
+ catch (err) {
376
+ log.error({ err, chatId, replyText }, 'Failed to send simulated permission reply');
377
+ }
378
+ }
379
+ return { toast: { type: 'success', content: confirmText } };
380
+ }
381
+ // Real OpenCode permission IDs are `per_*`. If we see one with no pending,
382
+ // it's almost always a duplicate event (Feishu re-delivery or quick re-click)
383
+ // for a request we already handled. The card is already in confirmation state
384
+ // from the first click — return success silently rather than misleading
385
+ // "已过期" warning.
386
+ const isRealOpencodePerm = actionType === 'perm' && valueId.startsWith('per_');
387
+ if (isRealOpencodePerm) {
388
+ log.info({ chatId, messageId, valueId }, 'Permission already processed (likely duplicate event), returning success');
389
+ return { toast: { type: 'success', content: '已处理' } };
390
+ }
391
+ // Update the card to remove stale buttons so the user doesn't keep clicking
392
+ this.feishuApi.updateCard(messageId, FeishuCard.createExpiredCard())
393
+ .then(() => log.info({ chatId, messageId }, 'Updated stale card to expired state'))
394
+ .catch((err) => log.warn({ err, chatId, messageId }, 'Failed to update stale card to expired'));
395
+ return { toast: { type: 'warning', content: '该操作已过期' } };
396
+ }
397
+ // Route to the appropriate handler
398
+ if (actionType === 'perm') {
399
+ return this.handlePermissionCardAction(chatId, messageId, value, pending);
400
+ }
401
+ return this.handleQuestionCardAction(chatId, messageId, value, pending);
402
+ }
403
+ async handlePermissionCardAction(chatId, messageId, value, pending) {
404
+ if (pending.kind !== 'permission') {
405
+ return { toast: { type: 'error', content: '当前不是权限请求' } };
406
+ }
407
+ const reply = value.reply;
408
+ if (!reply || !['once', 'always', 'reject'].includes(reply)) {
409
+ return { toast: { type: 'error', content: '无效的权限响应' } };
410
+ }
411
+ const perm = pending.data;
412
+ log.info({ chatId, permissionId: perm.id, reply }, 'Card action: replying to permission');
413
+ const confirmText = reply === 'reject'
414
+ ? '已拒绝该权限请求。'
415
+ : reply === 'always'
416
+ ? '已永久授权该权限。'
417
+ : '已授权一次该权限。';
418
+ const confirmCard = FeishuCard.createInteractionRepliedCard('permission', reply);
419
+ // Update in-memory state synchronously so concurrent flushCard / re-clicks
420
+ // see the new state immediately. This must happen BEFORE we return so the
421
+ // caller doesn't process duplicate events.
422
+ this.sessionManager.clearPendingInteraction(chatId);
423
+ const session = this.sessionManager.getSession(chatId);
424
+ if (session) {
425
+ // Re-bind currentMessageId to the clicked card so subsequent flushCard
426
+ // calls update the same card the user sees (AI may have sent cards via
427
+ // MCP, causing currentMessageId to diverge from the clicked messageId).
428
+ if (messageId !== session.currentMessageId) {
429
+ this.sessionManager.setCurrentMessage(chatId, messageId);
430
+ }
431
+ // Mark that the interaction was handled via card click so flushCard
432
+ // won't overwrite the confirmation state with AI streaming output.
433
+ session.interactionReplied = true;
434
+ }
435
+ this.sessionManager.updateStatus(chatId, 'idle');
436
+ // Fire the network calls in the background and return the toast immediately.
437
+ // Feishu's UI has a strict callback timeout — awaiting both replyPermission
438
+ // and updateCard (~500ms total) was overrunning it and causing the client
439
+ // to display its own error popup despite our handler succeeding.
440
+ void (async () => {
441
+ try {
442
+ await this.opencode.replyPermission(perm.id, reply);
443
+ log.info({ chatId, permissionId: perm.id }, 'replyPermission relayed to OpenCode');
444
+ }
445
+ catch (err) {
446
+ log.error({ err, chatId, permissionId: perm.id }, 'replyPermission failed (background)');
447
+ }
448
+ try {
449
+ await this.feishuApi.updateCard(messageId, confirmCard);
450
+ log.info({ chatId, messageId, permissionId: perm.id }, 'Confirmation card updated');
451
+ }
452
+ catch (err) {
453
+ log.error({ err, chatId, messageId }, 'updateCard for confirmation failed (background)');
242
454
  }
455
+ })();
456
+ // NOTE: do NOT include the `card` field in the response. Feishu requires
457
+ // it wrapped as `{ type: 'raw'|'template', data: {...} }`; passing the raw
458
+ // CardContent directly is treated as a malformed response and the client
459
+ // shows its own error popup. We update the card via API in the background
460
+ // call above, so the response only needs a toast.
461
+ return { toast: { type: 'success', content: confirmText } };
462
+ }
463
+ async handleQuestionCardAction(chatId, messageId, value, pending) {
464
+ if (pending.kind !== 'question') {
465
+ return { toast: { type: 'error', content: '当前不是问题选择' } };
466
+ }
467
+ const answers = value.ans;
468
+ if (!answers || !Array.isArray(answers)) {
469
+ return { toast: { type: 'error', content: '无效的选择' } };
243
470
  }
244
- // Simple keyword detection as fallback
245
- if (/^(?:创建|新建|写个?)文档/.test(text.trim())) {
246
- return { intent: true, title: '未命名文档' };
471
+ const q = pending.data;
472
+ log.info({ chatId, requestId: q.id, answers }, 'Card action: replying to question');
473
+ const label = answers.map(a => a.join(', ')).join('; ');
474
+ const confirmCard = FeishuCard.createInteractionRepliedCard('question', label);
475
+ // Update in-memory state synchronously and return immediately, doing the
476
+ // network calls in the background — see handlePermissionCardAction for the
477
+ // rationale on returning fast.
478
+ this.sessionManager.clearPendingInteraction(chatId);
479
+ const session = this.sessionManager.getSession(chatId);
480
+ if (session) {
481
+ if (messageId !== session.currentMessageId) {
482
+ this.sessionManager.setCurrentMessage(chatId, messageId);
483
+ }
484
+ session.interactionReplied = true;
485
+ }
486
+ this.sessionManager.updateStatus(chatId, 'idle');
487
+ void (async () => {
488
+ try {
489
+ await this.opencode.replyQuestion(q.id, answers);
490
+ log.info({ chatId, requestId: q.id }, 'replyQuestion relayed to OpenCode');
491
+ }
492
+ catch (err) {
493
+ log.error({ err, chatId, requestId: q.id }, 'replyQuestion failed (background)');
494
+ }
495
+ try {
496
+ await this.feishuApi.updateCard(messageId, confirmCard);
497
+ log.info({ chatId, messageId, requestId: q.id }, 'Confirmation card updated');
498
+ }
499
+ catch (err) {
500
+ log.error({ err, chatId, messageId }, 'updateCard for confirmation failed (background)');
501
+ }
502
+ })();
503
+ // See note in handlePermissionCardAction about omitting the `card` field.
504
+ return { toast: { type: 'success', content: `已提交选择:${label}` } };
505
+ }
506
+ /**
507
+ * Parse a slash command from text.
508
+ * Returns { command, args } if text starts with /, otherwise null.
509
+ * Examples: `/help` → { command: 'help' }, `/compact all` → { command: 'compact', args: 'all' }
510
+ */
511
+ parseSlashCommand(text) {
512
+ const trimmed = text.trim();
513
+ if (!trimmed.startsWith('/'))
514
+ return null;
515
+ const withoutPrefix = trimmed.slice(1);
516
+ const firstSpace = withoutPrefix.search(/\s/);
517
+ if (firstSpace === -1) {
518
+ return { command: withoutPrefix };
247
519
  }
248
- return { intent: false, title: '' };
520
+ const command = withoutPrefix.slice(0, firstSpace);
521
+ const args = withoutPrefix.slice(firstSpace + 1).trim();
522
+ return { command, args: args || undefined };
249
523
  }
250
524
  /**
251
525
  * Unified media download handler for images, files, audio, and video.
526
+ * Returns a text placeholder for the message and optionally the file info
527
+ * for forwarding to OpenCode.
252
528
  */
253
529
  async downloadMedia(messageId, fileKey, resourceType, fileName, mimeType, typeLabel) {
254
530
  if (!fileKey) {
255
- return `[${typeLabel}消息]`;
531
+ return { text: `[${typeLabel}消息]` };
256
532
  }
257
533
  try {
258
534
  log.info({ messageId, fileKey, fileName }, `Downloading ${typeLabel}...`);
259
535
  const buffer = await this.feishuApi.downloadMedia(messageId, fileKey, resourceType);
260
536
  const downloaded = await this.fileDownloader.saveBuffer(buffer, fileName, mimeType);
261
- return `[${typeLabel}已上传: ${downloaded.filePath}]`;
537
+ return {
538
+ text: `[${typeLabel}已上传: ${downloaded.filePath}]`,
539
+ file: {
540
+ filePath: downloaded.filePath,
541
+ fileName: downloaded.fileName,
542
+ mimeType,
543
+ },
544
+ };
262
545
  }
263
546
  catch (err) {
264
547
  log.error({ err, messageId, fileKey }, `Failed to download ${typeLabel}`);
265
- return `[${typeLabel}消息(下载失败)]`;
548
+ return { text: `[${typeLabel}消息(下载失败)]` };
266
549
  }
267
550
  }
268
551
  }