@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.
- package/CHANGELOG.md +18 -1
- package/dist/core/message-handler.d.ts +27 -9
- package/dist/core/message-handler.d.ts.map +1 -1
- package/dist/core/message-handler.js +362 -79
- package/dist/core/message-handler.js.map +1 -1
- package/dist/core/session-manager.d.ts +4 -1
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +15 -0
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/types.d.ts +49 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/feishu/api.d.ts +6 -0
- package/dist/feishu/api.d.ts.map +1 -1
- package/dist/feishu/api.js +71 -6
- package/dist/feishu/api.js.map +1 -1
- package/dist/feishu/card.d.ts +5 -2
- package/dist/feishu/card.d.ts.map +1 -1
- package/dist/feishu/card.js +145 -18
- package/dist/feishu/card.js.map +1 -1
- package/dist/feishu/event-source.d.ts +6 -0
- package/dist/feishu/event-source.d.ts.map +1 -1
- package/dist/feishu/event-source.js +54 -0
- package/dist/feishu/event-source.js.map +1 -1
- package/dist/opencode/client.d.ts +4 -0
- package/dist/opencode/client.d.ts.map +1 -1
- package/dist/opencode/client.js +55 -5
- package/dist/opencode/client.js.map +1 -1
- package/dist/opencode/event-handler.d.ts +6 -0
- package/dist/opencode/event-handler.d.ts.map +1 -1
- package/dist/opencode/event-handler.js +170 -10
- package/dist/opencode/event-handler.js.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +10 -3
- package/dist/plugin.js.map +1 -1
- package/dist/services/doc-service.d.ts +11 -45
- package/dist/services/doc-service.d.ts.map +1 -1
- package/dist/services/doc-service.js +72 -174
- package/dist/services/doc-service.js.map +1 -1
- package/dist/services/im-service.d.ts.map +1 -1
- package/dist/services/im-service.js +10 -6
- package/dist/services/im-service.js.map +1 -1
- package/dist/standalone.d.ts.map +1 -1
- package/dist/standalone.js +10 -3
- package/dist/standalone.js.map +1 -1
- package/dist/types/extended.d.ts +2 -1
- package/dist/types/extended.d.ts.map +1 -1
- 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
|
-
|
|
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
|
-
*
|
|
21
|
-
*
|
|
16
|
+
* Try to parse the user message as a reply to a pending interaction.
|
|
17
|
+
* Returns true if handled.
|
|
22
18
|
*/
|
|
23
|
-
private
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
168
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
*
|
|
226
|
-
*
|
|
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
|
-
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|