@wu529778790/open-im 1.0.2-beta.2 → 1.0.2

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.
@@ -2,14 +2,17 @@ import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { AccessControl } from '../access/access-control.js';
4
4
  import { RequestQueue } from '../queue/request-queue.js';
5
- import { sendThinkingMessage, updateMessage, sendFinalMessages, sendTextReply, startTypingLoop, sendImageReply, } from './message-sender.js';
6
- import { registerPermissionSender } from '../hook/permission-server.js';
5
+ import { sendThinkingMessage, updateMessage, sendFinalMessages, sendTextReply, sendTextReplyByOpenId, startTypingLoop, sendImageReply, createFeishuButtonCard, sendModeCard, createFeishuModeCardReadOnly, delayUpdateCard, } from './message-sender.js';
6
+ import { registerPermissionSender, resolvePermissionById } from '../hook/permission-server.js';
7
+ import { setPermissionMode } from '../permission-mode/session-mode.js';
8
+ import { MODE_LABELS } from '../permission-mode/types.js';
7
9
  import { CommandHandler } from '../commands/handler.js';
8
10
  import { getAdapter } from '../adapters/registry.js';
9
11
  import { runAITask } from '../shared/ai-task.js';
10
12
  import { startTaskCleanup } from '../shared/task-cleanup.js';
11
13
  import { THROTTLE_MS, IMAGE_DIR } from '../constants.js';
12
14
  import { setActiveChatId } from '../shared/active-chats.js';
15
+ import { setChatUser } from '../shared/chat-user-map.js';
13
16
  import { createLogger } from '../logger.js';
14
17
  const log = createLogger('FeishuHandler');
15
18
  async function downloadFeishuImage(client, imageKey) {
@@ -56,6 +59,48 @@ async function downloadFeishuImage(client, imageKey) {
56
59
  await writeFile(imagePath, buffer);
57
60
  return imagePath;
58
61
  }
62
+ /**
63
+ * Send permission prompt card with interactive buttons
64
+ */
65
+ async function sendPermissionCard(chatId, requestId, toolName, toolInput) {
66
+ const { getClient } = await import('./client.js');
67
+ const client = getClient();
68
+ // Format tool input for display
69
+ let formattedInput;
70
+ if (toolInput.length > 300) {
71
+ formattedInput = toolInput.slice(0, 300) + '...';
72
+ }
73
+ else {
74
+ formattedInput = toolInput;
75
+ }
76
+ const content = `**工具:** \`${toolName}\`
77
+
78
+ **参数:**
79
+ \`\`\`
80
+ ${formattedInput}
81
+ \`\`\`
82
+
83
+ **请求 ID:** \`${requestId.slice(-8)}\``;
84
+ const cardContent = createFeishuButtonCard('权限请求', content, [
85
+ { label: '✅ 允许', value: `allow_${requestId}`, type: 'primary' },
86
+ { label: '❌ 拒绝', value: `deny_${requestId}`, type: 'default' },
87
+ ]);
88
+ try {
89
+ await client.im.message.create({
90
+ data: {
91
+ receive_id: chatId,
92
+ msg_type: 'interactive',
93
+ content: cardContent,
94
+ },
95
+ params: { receive_id_type: 'chat_id' },
96
+ });
97
+ log.info(`Permission card sent for request ${requestId}`);
98
+ }
99
+ catch (err) {
100
+ log.error('Failed to send permission card:', err);
101
+ throw err;
102
+ }
103
+ }
59
104
  export function setupFeishuHandlers(config, sessionManager) {
60
105
  const accessControl = new AccessControl(config.feishuAllowedUserIds);
61
106
  const requestQueue = new RequestQueue();
@@ -65,10 +110,10 @@ export function setupFeishuHandlers(config, sessionManager) {
65
110
  config,
66
111
  sessionManager,
67
112
  requestQueue,
68
- sender: { sendTextReply },
113
+ sender: { sendTextReply, sendModeCard },
69
114
  getRunningTasksSize: () => runningTasks.size,
70
115
  });
71
- registerPermissionSender('feishu', {});
116
+ registerPermissionSender('feishu', { sendTextReply, sendPermissionCard });
72
117
  async function handleAIRequest(userId, chatId, prompt, workDir, convId, _threadCtx, replyToMessageId) {
73
118
  log.info(`[AI_REQUEST] userId=${userId}, chatId=${chatId}, promptLength=${prompt.length}`);
74
119
  log.info(`[AI_REQUEST] Full prompt: "${prompt}"`);
@@ -120,26 +165,183 @@ export function setupFeishuHandlers(config, sessionManager) {
120
165
  sendImage: (path) => sendImageReply(chatId, path),
121
166
  });
122
167
  }
168
+ /**
169
+ * Parse permission button value from card action (兼容多种格式)
170
+ */
171
+ function parsePermissionActionValue(raw) {
172
+ if (!raw)
173
+ return null;
174
+ let buttonValue;
175
+ if (typeof raw === 'string') {
176
+ try {
177
+ const parsed = JSON.parse(raw);
178
+ if (parsed.action === 'permission' && parsed.value)
179
+ buttonValue = parsed.value;
180
+ else if (raw.startsWith('allow_') || raw.startsWith('deny_'))
181
+ buttonValue = raw;
182
+ }
183
+ catch {
184
+ if (raw.startsWith('allow_') || raw.startsWith('deny_'))
185
+ buttonValue = raw;
186
+ }
187
+ }
188
+ else if (typeof raw === 'object' && raw !== null) {
189
+ const obj = raw;
190
+ if (obj.action === 'permission' && obj.value)
191
+ buttonValue = obj.value;
192
+ }
193
+ if (!buttonValue)
194
+ return null;
195
+ if (buttonValue.startsWith('allow_')) {
196
+ return { decision: 'allow', requestId: buttonValue.slice(6) };
197
+ }
198
+ if (buttonValue.startsWith('deny_')) {
199
+ return { decision: 'deny', requestId: buttonValue.slice(5) };
200
+ }
201
+ return null;
202
+ }
203
+ /**
204
+ * 解析 action value(兼容对象、JSON 字符串)
205
+ */
206
+ function parseActionValue(raw) {
207
+ if (!raw)
208
+ return null;
209
+ let obj = null;
210
+ if (typeof raw === 'string') {
211
+ try {
212
+ obj = JSON.parse(raw);
213
+ }
214
+ catch {
215
+ return null;
216
+ }
217
+ }
218
+ else if (typeof raw === 'object' && raw !== null) {
219
+ obj = raw;
220
+ }
221
+ return obj?.action && obj?.value ? obj : null;
222
+ }
223
+ /**
224
+ * 从卡片回调事件中提取延时更新 token(格式 c-xxxx)
225
+ * 飞书文档:从卡片交互返回内容获取,用于延时更新接口
226
+ */
227
+ function extractCardToken(data) {
228
+ const raw = data;
229
+ const event = (raw?.event ?? raw);
230
+ const action = event?.action;
231
+ const context = event?.context;
232
+ const candidates = [
233
+ event?.token,
234
+ event?.open_api_token,
235
+ raw?.token,
236
+ action?.token,
237
+ context?.token,
238
+ ].filter((t) => typeof t === 'string' && t.startsWith('c-'));
239
+ const token = candidates[0] ?? null;
240
+ if (!token) {
241
+ log.debug('[extractCardToken] No token found, event keys:', Object.keys(event ?? {}));
242
+ }
243
+ return token;
244
+ }
245
+ /**
246
+ * Handle card button click (card.action.trigger) - 需在 3 秒内返回响应
247
+ * 同步只返回 toast,避免 200672;用延时更新 API 异步替换为只读卡片,防止二次点击
248
+ */
249
+ async function handleCardAction(data) {
250
+ // 兼容 SDK 可能嵌套的 event 结构
251
+ const wrapped = data;
252
+ const event = (wrapped?.event ?? data);
253
+ const actionValue = event?.action?.value;
254
+ const chatId = event?.context?.open_chat_id ?? event?.context?.chat_id ?? event?.context?.open_id ?? '';
255
+ const userId = event?.sender?.sender_id?.open_id ?? '';
256
+ log.info(`[handleCardAction] chatId=${chatId}, userId=${userId}, actionValue=${JSON.stringify(actionValue)}`);
257
+ // 处理 mode 按钮(兼容 value 为对象或 JSON 字符串)
258
+ const modeAv = parseActionValue(actionValue);
259
+ if (modeAv?.action === 'mode' && modeAv.value) {
260
+ const mode = modeAv.value;
261
+ if (['ask', 'accept-edits', 'plan', 'yolo'].includes(mode)) {
262
+ setPermissionMode(userId, mode);
263
+ const toastContent = `✅ 已切换为 ${MODE_LABELS[mode]}`;
264
+ const label = MODE_LABELS[mode];
265
+ // 异步发送文本回复,不阻塞 3 秒内返回
266
+ const sendReply = () => {
267
+ if (chatId)
268
+ return sendTextReply(chatId, toastContent);
269
+ if (userId)
270
+ return sendTextReplyByOpenId(userId, toastContent);
271
+ log.warn('[handleCardAction] No chatId/userId, cannot send text reply');
272
+ };
273
+ const p = sendReply();
274
+ if (p)
275
+ p.catch((e) => log.warn('[handleCardAction] Send reply failed:', e));
276
+ // 同步只返回 toast,避免 200672(同步返回 card 格式易出错)
277
+ const cardToken = extractCardToken(data);
278
+ const readOnlyCard = createFeishuModeCardReadOnly(label);
279
+ if (cardToken && userId) {
280
+ // 延时更新:异步替换为只读卡片,防止二次点击
281
+ delayUpdateCard(cardToken, readOnlyCard, [userId]).catch((e) => log.warn('[handleCardAction] delayUpdateCard failed:', e));
282
+ }
283
+ else if (!cardToken) {
284
+ log.debug('[handleCardAction] No card token in event, cannot delay-update card');
285
+ }
286
+ return { toast: { type: 'success', content: toastContent } };
287
+ }
288
+ }
289
+ const parsed = parsePermissionActionValue(actionValue);
290
+ if (!parsed) {
291
+ log.info('[handleCardAction] Unrecognized action value, returning default toast');
292
+ return { toast: { type: 'warning', content: '未知操作' } };
293
+ }
294
+ const { decision, requestId } = parsed;
295
+ log.info(`[handleCardAction] Permission button: ${decision} for ${requestId}, chatId=${chatId}`);
296
+ const resolved = resolvePermissionById(requestId, decision);
297
+ const toastContent = resolved
298
+ ? decision === 'allow'
299
+ ? '✅ 权限已允许'
300
+ : '❌ 权限已拒绝'
301
+ : '⚠️ 权限请求已过期或不存在';
302
+ const sendPermReply = () => {
303
+ if (chatId)
304
+ return sendTextReply(chatId, toastContent);
305
+ if (userId)
306
+ return sendTextReplyByOpenId(userId, toastContent);
307
+ };
308
+ const permP = sendPermReply();
309
+ if (permP)
310
+ permP.catch((err) => log.warn('Failed to send permission reply:', err));
311
+ return { toast: { type: resolved ? 'success' : 'warning', content: toastContent } };
312
+ }
123
313
  async function handleEvent(data) {
124
- log.info('[handleEvent] Called with data:', JSON.stringify(data).slice(0, 500));
314
+ log.info('[handleEvent] Called with data:', JSON.stringify(data).slice(0, 800));
125
315
  try {
126
- log.info('[handleEvent] Starting processing');
127
- // Parse the event data
128
- // Feishu event structure (long connection mode):
129
- // {
130
- // "event_type": "im.message.receive_v1",
131
- // "event_id": "...",
132
- // "tenant_key": "...",
133
- // "app_id": "...",
134
- // "message": { "chat_id": "...", "content": "...", ... },
135
- // "sender": { "sender_id": { "open_id": "..." } }
136
- // }
137
- const event = data;
138
- const eventType = event?.event_type;
316
+ const raw = data;
317
+ const event = (raw?.event ?? raw);
318
+ const eventType = event?.event_type ?? event?.type;
139
319
  log.info('Feishu event type:', eventType);
140
- // Handle message received events
320
+ // 1. 卡片按钮点击 (card.action.trigger) - 需快速返回响应
321
+ if (eventType === 'card.action.trigger') {
322
+ const result = await handleCardAction(data);
323
+ return result ?? { toast: { type: 'success', content: '已处理' } };
324
+ }
325
+ // 2. 消息接收 (im.message.receive_v1)
141
326
  if (eventType === 'im.message.receive_v1') {
142
327
  log.info('[handleEvent] Processing im.message.receive_v1 event');
328
+ // 兼容:部分场景下卡片点击可能通过 im.message 携带 action
329
+ if (event?.action?.value) {
330
+ const parsed = parsePermissionActionValue(event.action.value);
331
+ if (parsed) {
332
+ const { decision, requestId } = parsed;
333
+ const chatId = event.message?.chat_id ?? '';
334
+ log.info(`[handleEvent] Permission (via msg): ${decision} for ${requestId}`);
335
+ const resolved = resolvePermissionById(requestId, decision);
336
+ if (resolved) {
337
+ await sendTextReply(chatId, decision === 'allow' ? '✅ 权限已允许' : '❌ 权限已拒绝');
338
+ }
339
+ else {
340
+ await sendTextReply(chatId, '⚠️ 权限请求已过期或不存在');
341
+ }
342
+ return;
343
+ }
344
+ }
143
345
  const message = event?.message;
144
346
  if (!message) {
145
347
  log.warn('No message data in event');
@@ -176,6 +378,7 @@ export function setupFeishuHandlers(config, sessionManager) {
176
378
  }
177
379
  log.info(`Access granted for sender: ${senderId}`);
178
380
  setActiveChatId('feishu', chatId);
381
+ setChatUser(chatId, senderId);
179
382
  // Handle different message types
180
383
  if (msgType === 'text') {
181
384
  const text = content.text?.trim() ?? '';
@@ -209,39 +412,42 @@ export function setupFeishuHandlers(config, sessionManager) {
209
412
  }
210
413
  else if (msgType === 'post') {
211
414
  // Feishu rich text/post messages - extract text content
212
- const post = content?.post;
415
+ // 支持 post.content zh_cn.content,content 可能是二维数组(段落→元素)
416
+ const post = content?.post
417
+ ?? content?.zh_cn;
418
+ const rawContent = post?.content;
213
419
  let text = '';
214
- if (post?.content && Array.isArray(post.content)) {
215
- // Log full structure for debugging
216
- log.info(`[MSG] Post content structure:`, JSON.stringify(post.content).slice(0, 500));
217
- // Extract text from rich text structure
218
- for (const section of post.content) {
219
- if (!section || typeof section !== 'object')
220
- continue;
221
- const tag = section?.tag;
222
- // Handle different content types
223
- if (tag === 'text' || tag === 'plain_text') {
224
- const t = section?.text ?? '';
225
- text += t;
420
+ function extractTextFromElement(el) {
421
+ if (!el || typeof el !== 'object')
422
+ return '';
423
+ const obj = el;
424
+ const tag = obj.tag;
425
+ if (tag === 'text' || tag === 'plain_text') {
426
+ return (obj.text ?? obj.content ?? '').toString();
427
+ }
428
+ if (tag === 'a')
429
+ return (obj.text ?? obj.content ?? '').toString();
430
+ if (tag === 'heading' || tag === 'heading1' || tag === 'heading2' || tag === 'heading3') {
431
+ const headingText = el.text;
432
+ if (typeof headingText === 'string')
433
+ return headingText;
434
+ if (Array.isArray(headingText)) {
435
+ return headingText.map(extractTextFromElement).join('');
226
436
  }
227
- else if (tag === 'heading' || tag === 'heading1' || tag === 'heading2' || tag === 'heading3') {
228
- // Handle headings - might be nested structure
229
- const headingText = section?.text;
230
- if (typeof headingText === 'string') {
231
- text += headingText;
232
- }
233
- else if (Array.isArray(headingText)) {
234
- // Nested text elements in heading
235
- for (const item of headingText) {
236
- if (item && typeof item === 'object' && 'text' in item) {
237
- text += item.text ?? '';
238
- }
239
- }
437
+ }
438
+ return '';
439
+ }
440
+ if (rawContent && Array.isArray(rawContent)) {
441
+ log.info(`[MSG] Post content structure:`, JSON.stringify(rawContent).slice(0, 500));
442
+ for (const section of rawContent) {
443
+ if (Array.isArray(section)) {
444
+ // 二维数组:段落内多个元素
445
+ for (const el of section) {
446
+ text += extractTextFromElement(el);
240
447
  }
241
448
  }
242
449
  else {
243
- // Log unhandled tags for debugging
244
- log.info(`[MSG] Unhandled post tag: ${tag}, section:`, JSON.stringify(section).slice(0, 200));
450
+ text += extractTextFromElement(section);
245
451
  }
246
452
  }
247
453
  }
@@ -1,7 +1,29 @@
1
1
  export type MessageStatus = 'thinking' | 'streaming' | 'done' | 'error';
2
+ /**
3
+ * Create Feishu card with action buttons
4
+ * Used for permission prompts and other interactive requests
5
+ */
6
+ export declare function createFeishuButtonCard(title: string, content: string, buttons: Array<{
7
+ label: string;
8
+ value: string;
9
+ type?: 'primary' | 'default';
10
+ }>): string;
11
+ /** 只读模式卡片(无按钮,用于回调后替换原卡片防止二次点击) */
12
+ export declare function createFeishuModeCardReadOnly(currentMode: string): Record<string, unknown>;
13
+ /**
14
+ * 延时更新消息卡片(POST /open-apis/im/v1/cards/update)
15
+ * 用于在卡片回调 3 秒内无法完成时,异步替换卡片为只读版本,防止二次点击
16
+ * @param token 从卡片交互事件中获取的 token(格式 c-xxxx)
17
+ * @param card 卡片内容 { config, header, elements }
18
+ * @param openIds 非共享卡片需指定更新的用户 open_id 列表
19
+ */
20
+ export declare function delayUpdateCard(token: string, card: Record<string, unknown>, openIds?: string[]): Promise<void>;
21
+ export declare function sendModeCard(chatId: string, _userId: string, currentMode: string): Promise<void>;
2
22
  export declare function sendThinkingMessage(chatId: string, replyToMessageId: string | undefined, toolId?: string): Promise<string>;
3
23
  export declare function updateMessage(chatId: string, messageId: string, content: string, status: MessageStatus, note?: string, toolId?: string): Promise<void>;
4
24
  export declare function sendFinalMessages(chatId: string, messageId: string, fullContent: string, note: string, toolId?: string): Promise<void>;
5
25
  export declare function sendTextReply(chatId: string, text: string): Promise<void>;
26
+ /** 使用 open_id 发送(私聊时 context 可能只有 open_id) */
27
+ export declare function sendTextReplyByOpenId(openId: string, text: string): Promise<void>;
6
28
  export declare function sendImageReply(chatId: string, imagePath: string): Promise<void>;
7
29
  export declare function startTypingLoop(_chatId: string): () => void;
@@ -61,6 +61,161 @@ function createFeishuCard(title, content, status, note) {
61
61
  };
62
62
  return JSON.stringify(card);
63
63
  }
64
+ /**
65
+ * Create Feishu card with action buttons
66
+ * Used for permission prompts and other interactive requests
67
+ */
68
+ export function createFeishuButtonCard(title, content, buttons) {
69
+ const elements = [];
70
+ // Main content
71
+ elements.push({
72
+ tag: 'div',
73
+ text: {
74
+ tag: 'lark_md',
75
+ content: content,
76
+ },
77
+ });
78
+ // Add separator
79
+ elements.push({ tag: 'hr' });
80
+ // Add action buttons
81
+ const actionGroups = [];
82
+ // Split buttons into rows (max 4 buttons per row in Feishu)
83
+ for (let i = 0; i < buttons.length; i += 4) {
84
+ const row = buttons.slice(i, i + 4).map((btn) => ({
85
+ tag: 'button',
86
+ text: {
87
+ tag: 'plain_text',
88
+ content: btn.label,
89
+ },
90
+ type: btn.type || 'default',
91
+ value: {
92
+ action: 'permission',
93
+ value: btn.value,
94
+ },
95
+ }));
96
+ actionGroups.push({
97
+ tag: 'action',
98
+ actions: row,
99
+ });
100
+ }
101
+ elements.push(...actionGroups);
102
+ const card = {
103
+ config: {
104
+ wide_screen_mode: true,
105
+ },
106
+ header: {
107
+ template: 'blue',
108
+ title: {
109
+ content: `🔐 ${title}`,
110
+ tag: 'plain_text',
111
+ },
112
+ },
113
+ elements,
114
+ };
115
+ return JSON.stringify(card);
116
+ }
117
+ /** 只读模式卡片(无按钮,用于回调后替换原卡片防止二次点击) */
118
+ export function createFeishuModeCardReadOnly(currentMode) {
119
+ return {
120
+ config: { wide_screen_mode: true },
121
+ header: {
122
+ template: 'green',
123
+ title: { content: '🔐 权限模式', tag: 'plain_text' },
124
+ },
125
+ elements: [
126
+ {
127
+ tag: 'div',
128
+ text: {
129
+ tag: 'lark_md',
130
+ content: `**当前模式:** ${currentMode}\n\n✅ 已切换成功,发送 \`/mode\` 可再次切换。`,
131
+ },
132
+ },
133
+ ],
134
+ };
135
+ }
136
+ /**
137
+ * 延时更新消息卡片(POST /open-apis/im/v1/cards/update)
138
+ * 用于在卡片回调 3 秒内无法完成时,异步替换卡片为只读版本,防止二次点击
139
+ * @param token 从卡片交互事件中获取的 token(格式 c-xxxx)
140
+ * @param card 卡片内容 { config, header, elements }
141
+ * @param openIds 非共享卡片需指定更新的用户 open_id 列表
142
+ */
143
+ export async function delayUpdateCard(token, card, openIds) {
144
+ const accessToken = await getTenantAccessToken();
145
+ // 非共享卡片需在 card 内指定 open_ids
146
+ const cardBody = { ...card };
147
+ if (openIds && openIds.length > 0) {
148
+ cardBody.open_ids = openIds;
149
+ }
150
+ const body = { token, card: cardBody };
151
+ const resp = await fetch('https://open.feishu.cn/open-apis/interactive/v1/card/update', {
152
+ method: 'POST',
153
+ headers: {
154
+ 'Content-Type': 'application/json',
155
+ Authorization: `Bearer ${accessToken}`,
156
+ },
157
+ body: JSON.stringify(body),
158
+ });
159
+ const data = (await resp.json());
160
+ if (data.code !== 0) {
161
+ log.warn(`[delayUpdateCard] Failed: code=${data.code}, msg=${data.msg}`);
162
+ return;
163
+ }
164
+ log.info('[delayUpdateCard] Card updated successfully');
165
+ }
166
+ /**
167
+ * Create mode switch card with action type for card callback
168
+ */
169
+ function createFeishuModeCard(currentMode, buttons) {
170
+ const elements = [];
171
+ elements.push({
172
+ tag: 'div',
173
+ text: {
174
+ tag: 'lark_md',
175
+ content: `**当前模式:** ${currentMode}\n\n点击下方按钮切换模式:\n\n_💡 若点击报错:开放平台 → 事件与回调 → 切到「回调」Tab → 添加「卡片回传交互」。或直接用 \`/mode ask\` 等命令切换。_`,
176
+ },
177
+ });
178
+ elements.push({ tag: 'hr' });
179
+ for (let i = 0; i < buttons.length; i += 4) {
180
+ const row = buttons.slice(i, i + 4).map((btn) => ({
181
+ tag: 'button',
182
+ text: { tag: 'plain_text', content: btn.label },
183
+ type: btn.type || 'default',
184
+ value: { action: 'mode', value: btn.value },
185
+ }));
186
+ elements.push({ tag: 'action', actions: row });
187
+ }
188
+ const card = {
189
+ config: { wide_screen_mode: true },
190
+ header: {
191
+ template: 'blue',
192
+ title: { content: '🔐 权限模式', tag: 'plain_text' },
193
+ },
194
+ elements,
195
+ };
196
+ return JSON.stringify(card);
197
+ }
198
+ export async function sendModeCard(chatId, _userId, currentMode) {
199
+ const { getClient } = await import('./client.js');
200
+ const { MODE_LABELS } = await import('../permission-mode/types.js');
201
+ const client = getClient();
202
+ const MODE_BTNS = [
203
+ { label: MODE_LABELS.ask, value: 'ask', type: 'default' },
204
+ { label: MODE_LABELS['accept-edits'], value: 'accept-edits', type: 'default' },
205
+ { label: MODE_LABELS.plan, value: 'plan', type: 'default' },
206
+ { label: MODE_LABELS.yolo, value: 'yolo', type: 'default' },
207
+ ];
208
+ const currentLabel = MODE_BTNS.find((b) => b.value === currentMode)?.label ?? currentMode;
209
+ const cardContent = createFeishuModeCard(currentLabel, MODE_BTNS);
210
+ await client.im.message.create({
211
+ data: {
212
+ receive_id: chatId,
213
+ msg_type: 'interactive',
214
+ content: cardContent,
215
+ },
216
+ params: { receive_id_type: 'chat_id' },
217
+ });
218
+ }
64
219
  async function getTenantAccessToken() {
65
220
  const client = getClient();
66
221
  const resp = await client.auth.tenantAccessToken.internal({
@@ -100,8 +255,7 @@ export async function sendThinkingMessage(chatId, replyToMessageId, toolId = 'cl
100
255
  }
101
256
  export async function updateMessage(chatId, messageId, content, status, note, toolId = 'claude') {
102
257
  const client = getClient();
103
- const icon = STATUS_CONFIG[status].icon;
104
- const title = `${icon} ${getToolTitle(toolId, status)}`;
258
+ const title = getToolTitle(toolId, status);
105
259
  const cardContent = createFeishuCard(title, content, status, note);
106
260
  // Try to use patch API for in-place update (streaming)
107
261
  try {
@@ -223,6 +377,24 @@ export async function sendTextReply(chatId, text) {
223
377
  log.error('Failed to send text:', err);
224
378
  }
225
379
  }
380
+ /** 使用 open_id 发送(私聊时 context 可能只有 open_id) */
381
+ export async function sendTextReplyByOpenId(openId, text) {
382
+ const client = getClient();
383
+ const cardContent = createFeishuCard('📢 open-im', text, 'done');
384
+ try {
385
+ await client.im.message.create({
386
+ data: {
387
+ receive_id: openId,
388
+ msg_type: 'interactive',
389
+ content: cardContent,
390
+ },
391
+ params: { receive_id_type: 'open_id' },
392
+ });
393
+ }
394
+ catch (err) {
395
+ log.error('Failed to send text by open_id:', err);
396
+ }
397
+ }
226
398
  export async function sendImageReply(chatId, imagePath) {
227
399
  const client = getClient();
228
400
  try {
@@ -1,4 +1,37 @@
1
- export declare function resolveLatestPermission(_chatId: string, _decision: 'allow' | 'deny'): string | null;
2
- export declare function getPendingCount(_chatId: string): number;
3
- export declare function resolvePermissionById(_requestId: string, _decision: 'allow' | 'deny'): boolean;
4
- export declare function registerPermissionSender(_platform: string, _sender: unknown): void;
1
+ /**
2
+ * Permission Server - Handles tool permission requests from Claude CLI
3
+ *
4
+ * When claudeSkipPermissions is false and not in yolo mode, Claude CLI will make
5
+ * HTTP requests to this server. We forward all requests to the user for approval;
6
+ * permission mode logic (ask/accept-edits/plan) is handled by Claude via --permission-mode.
7
+ */
8
+ interface MessageSender {
9
+ sendTextReply(chatId: string, text: string): Promise<void>;
10
+ sendPermissionCard?(chatId: string, requestId: string, toolName: string, toolInput: string): Promise<void>;
11
+ }
12
+ /**
13
+ * Start the permission HTTP server
14
+ */
15
+ export declare function startPermissionServer(port?: number): number;
16
+ /**
17
+ * Stop the permission HTTP server
18
+ */
19
+ export declare function stopPermissionServer(): void;
20
+ /**
21
+ * Register the message sender for sending permission prompts
22
+ */
23
+ export declare function registerPermissionSender(_platform: string, sender: MessageSender): void;
24
+ /**
25
+ * Get the number of pending permission requests for a chat
26
+ */
27
+ export declare function getPendingCount(chatId: string): number;
28
+ /**
29
+ * Resolve the latest pending permission request for a chat
30
+ * Returns the requestId if found, null otherwise
31
+ */
32
+ export declare function resolveLatestPermission(chatId: string, decision: 'allow' | 'deny'): string | null;
33
+ /**
34
+ * Resolve a specific permission request by ID
35
+ */
36
+ export declare function resolvePermissionById(requestId: string, decision: 'allow' | 'deny'): boolean;
37
+ export {};