@wu529778790/open-im 0.3.12 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,255 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { AccessControl } from '../access/access-control.js';
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';
7
+ import { CommandHandler } from '../commands/handler.js';
8
+ import { getAdapter } from '../adapters/registry.js';
9
+ import { runAITask } from '../shared/ai-task.js';
10
+ import { startTaskCleanup } from '../shared/task-cleanup.js';
11
+ import { MessageDedup } from '../shared/message-dedup.js';
12
+ import { THROTTLE_MS, IMAGE_DIR } from '../constants.js';
13
+ import { setActiveChatId } from '../shared/active-chats.js';
14
+ import { createLogger } from '../logger.js';
15
+ const log = createLogger('FeishuHandler');
16
+ async function downloadFeishuImage(client, imageKey) {
17
+ await mkdir(IMAGE_DIR, { recursive: true });
18
+ // Get tenant access token
19
+ const tokenResp = await client.auth.tenantAccessToken.internal({
20
+ data: {
21
+ app_id: client.appId,
22
+ app_secret: client.appSecret,
23
+ },
24
+ });
25
+ if (tokenResp.code !== 0 || !tokenResp.data) {
26
+ throw new Error(`Failed to get tenant access token: ${tokenResp.msg}`);
27
+ }
28
+ const token = tokenResp.data.tenant_access_token;
29
+ // Get the image download URL using the correct API endpoint
30
+ const resourceResp = await fetch(`https://open.feishu.cn/open-apis/im/v1/images/${imageKey}`, {
31
+ method: 'GET',
32
+ headers: {
33
+ Authorization: `Bearer ${token}`,
34
+ },
35
+ });
36
+ if (!resourceResp.ok) {
37
+ throw new Error(`Failed to get image resource: ${resourceResp.statusText}`);
38
+ }
39
+ const resourceData = await resourceResp.json();
40
+ if (resourceData.code !== 0) {
41
+ throw new Error(`Failed to get image resource: ${resourceData.msg}`);
42
+ }
43
+ // Download the image
44
+ const imageUrl = resourceData.data?.file_download_url || resourceData.data?.url;
45
+ if (!imageUrl) {
46
+ throw new Error('No image URL found in response');
47
+ }
48
+ const imgResp = await fetch(imageUrl, {
49
+ signal: AbortSignal.timeout(30000),
50
+ });
51
+ if (!imgResp.ok) {
52
+ throw new Error(`Failed to download image: ${imgResp.statusText}`);
53
+ }
54
+ const buffer = Buffer.from(await imgResp.arrayBuffer());
55
+ const safeId = imageKey.replace(/[^a-zA-Z0-9_-]/g, '_');
56
+ const imagePath = join(IMAGE_DIR, `${Date.now()}-${safeId.slice(-8)}.jpg`);
57
+ await writeFile(imagePath, buffer);
58
+ return imagePath;
59
+ }
60
+ export function setupFeishuHandlers(config, sessionManager) {
61
+ const accessControl = new AccessControl(config.feishuAllowedUserIds);
62
+ const requestQueue = new RequestQueue();
63
+ const runningTasks = new Map();
64
+ const stopTaskCleanup = startTaskCleanup(runningTasks);
65
+ const dedup = new MessageDedup();
66
+ const commandHandler = new CommandHandler({
67
+ config,
68
+ sessionManager,
69
+ requestQueue,
70
+ sender: { sendTextReply },
71
+ getRunningTasksSize: () => runningTasks.size,
72
+ });
73
+ registerPermissionSender('feishu', {});
74
+ async function handleAIRequest(userId, chatId, prompt, workDir, convId, _threadCtx, replyToMessageId) {
75
+ log.info(`[handleAIRequest] START: userId=${userId}, chatId=${chatId}, prompt="${prompt.slice(0, 50)}..."`);
76
+ const toolAdapter = getAdapter(config.aiCommand);
77
+ if (!toolAdapter) {
78
+ log.error(`[handleAIRequest] No adapter found for: ${config.aiCommand}`);
79
+ await sendTextReply(chatId, `未配置 AI 工具: ${config.aiCommand}`);
80
+ return;
81
+ }
82
+ log.info(`[handleAIRequest] Adapter found, getting session...`);
83
+ const sessionId = convId ? sessionManager.getSessionIdForConv(userId, convId) : undefined;
84
+ log.info(`[handleAIRequest] Running ${config.aiCommand} for user ${userId}, sessionId=${sessionId ?? 'new'}`);
85
+ const toolId = config.aiCommand;
86
+ let msgId;
87
+ try {
88
+ msgId = await sendThinkingMessage(chatId, replyToMessageId, toolId);
89
+ }
90
+ catch (err) {
91
+ log.error('Failed to send thinking message:', err);
92
+ return;
93
+ }
94
+ const stopTyping = startTypingLoop(chatId);
95
+ const taskKey = `${userId}:${msgId}`;
96
+ await runAITask({ config, sessionManager }, { userId, chatId, workDir, sessionId, convId, platform: 'feishu', taskKey }, prompt, toolAdapter, {
97
+ throttleMs: THROTTLE_MS,
98
+ streamUpdate: async (content, toolNote) => {
99
+ const note = toolNote ? '输出中...\n' + toolNote : '输出中...';
100
+ try {
101
+ await updateMessage(chatId, msgId, content, 'streaming', note, toolId);
102
+ }
103
+ catch (err) {
104
+ log.debug('Stream update failed (will retry on next update):', err);
105
+ }
106
+ },
107
+ sendComplete: async (content, note) => {
108
+ // Use sendFinalMessages to handle the final result
109
+ await sendFinalMessages(chatId, msgId, content, note ?? '', toolId);
110
+ },
111
+ sendError: async (error) => {
112
+ await updateMessage(chatId, msgId, `错误:${error}`, 'error', '执行失败', toolId);
113
+ },
114
+ extraCleanup: () => {
115
+ stopTyping();
116
+ runningTasks.delete(taskKey);
117
+ },
118
+ onTaskReady: (state) => {
119
+ runningTasks.set(taskKey, state);
120
+ },
121
+ sendImage: (path) => sendImageReply(chatId, path),
122
+ });
123
+ }
124
+ async function handleEvent(data) {
125
+ log.info('[handleEvent] Called with data:', JSON.stringify(data).slice(0, 500));
126
+ try {
127
+ log.info('[handleEvent] Starting processing');
128
+ // Parse the event data
129
+ // Feishu event structure (long connection mode):
130
+ // {
131
+ // "event_type": "im.message.receive_v1",
132
+ // "event_id": "...",
133
+ // "tenant_key": "...",
134
+ // "app_id": "...",
135
+ // "message": { "chat_id": "...", "content": "...", ... },
136
+ // "sender": { "sender_id": { "open_id": "..." } }
137
+ // }
138
+ const event = data;
139
+ const eventType = event?.event_type;
140
+ log.info('Feishu event type:', eventType);
141
+ // Handle message received events
142
+ if (eventType === 'im.message.receive_v1') {
143
+ log.info('[handleEvent] Processing im.message.receive_v1 event');
144
+ const message = event?.message;
145
+ if (!message) {
146
+ log.warn('No message data in event');
147
+ return;
148
+ }
149
+ const chatId = message.chat_id ?? '';
150
+ const messageId = message.message_id ?? '';
151
+ const msgType = message.message_type;
152
+ const contentStr = message.content ?? '{}';
153
+ log.info(`[handleEvent] Parsed: chatId=${chatId}, msgType=${msgType}`);
154
+ log.info(`Message: chatId=${chatId}, messageId=${messageId}, msgType=${msgType}`);
155
+ // Parse message content
156
+ let content;
157
+ try {
158
+ content = JSON.parse(contentStr);
159
+ log.info(`Parsed content:`, JSON.stringify(content).slice(0, 200));
160
+ }
161
+ catch (err) {
162
+ log.error('Failed to parse message content:', err);
163
+ return;
164
+ }
165
+ // Get user ID
166
+ const senderId = event?.sender?.sender_id?.open_id ?? '';
167
+ if (!senderId) {
168
+ log.warn('No sender ID in event');
169
+ return;
170
+ }
171
+ log.info(`Sender ID: ${senderId}`);
172
+ // Dedup check
173
+ const dedupKey = `${chatId}:${messageId}`;
174
+ if (dedup.isDuplicate(dedupKey)) {
175
+ log.info(`Duplicate message detected: ${dedupKey}`);
176
+ return;
177
+ }
178
+ // Access control check
179
+ if (!accessControl.isAllowed(senderId)) {
180
+ log.warn(`Access denied for sender: ${senderId}`);
181
+ sendTextReply(chatId, '抱歉,您没有访问权限。\n您的 ID: ' + senderId).catch(() => { });
182
+ return;
183
+ }
184
+ log.info(`Access granted for sender: ${senderId}`);
185
+ setActiveChatId('feishu', chatId);
186
+ // Handle different message types
187
+ if (msgType === 'text') {
188
+ const text = content.text?.trim() ?? '';
189
+ log.info(`Processing text message from ${senderId}: ${text}`);
190
+ // Handle commands
191
+ try {
192
+ const handled = await commandHandler.dispatch(text, chatId, senderId, 'feishu', handleAIRequest);
193
+ if (handled) {
194
+ log.info(`Command handled for message: ${text}`);
195
+ return;
196
+ }
197
+ }
198
+ catch (err) {
199
+ log.error('Error in commandHandler.dispatch:', err);
200
+ }
201
+ // Handle AI request
202
+ log.info(`Enqueueing AI request for: ${text}`);
203
+ const workDir = sessionManager.getWorkDir(senderId);
204
+ const convId = sessionManager.getConvId(senderId);
205
+ const enqueueResult = requestQueue.enqueue(senderId, convId, text, async (prompt) => {
206
+ log.info(`Executing AI request for: ${prompt}`);
207
+ await handleAIRequest(senderId, chatId, prompt, workDir, convId, undefined, messageId);
208
+ });
209
+ if (enqueueResult === 'rejected') {
210
+ sendTextReply(chatId, '请求队列已满,请稍后再试。').catch(() => { });
211
+ }
212
+ else if (enqueueResult === 'queued') {
213
+ sendTextReply(chatId, '您的请求已排队等待。').catch(() => { });
214
+ }
215
+ }
216
+ else if (msgType === 'image') {
217
+ const imageKey = content.image_key;
218
+ if (!imageKey)
219
+ return;
220
+ log.info(`Processing image message from ${senderId}, image_key: ${imageKey}`);
221
+ try {
222
+ const { getClient } = await import('./client.js');
223
+ const c = getClient();
224
+ let imagePath;
225
+ try {
226
+ imagePath = await downloadFeishuImage(c, imageKey);
227
+ }
228
+ catch (err) {
229
+ log.error('Failed to download image:', err);
230
+ sendTextReply(chatId, '图片下载失败。').catch(() => { });
231
+ return;
232
+ }
233
+ const prompt = `用户发送了一张图片,已保存到 ${imagePath}。请用 Read 工具查看并分析。`;
234
+ const workDir = sessionManager.getWorkDir(senderId);
235
+ const convId = sessionManager.getConvId(senderId);
236
+ requestQueue.enqueue(senderId, convId, prompt, async (p) => {
237
+ await handleAIRequest(senderId, chatId, p, workDir, convId, undefined, messageId);
238
+ });
239
+ }
240
+ catch (err) {
241
+ log.error('Error processing image message:', err);
242
+ }
243
+ }
244
+ }
245
+ }
246
+ catch (err) {
247
+ log.error('[handleEvent] Error processing event:', err);
248
+ }
249
+ }
250
+ return {
251
+ stop: () => stopTaskCleanup(),
252
+ getRunningTaskCount: () => runningTasks.size,
253
+ handleEvent,
254
+ };
255
+ }
@@ -0,0 +1,7 @@
1
+ export type MessageStatus = 'thinking' | 'streaming' | 'done' | 'error';
2
+ export declare function sendThinkingMessage(chatId: string, replyToMessageId: string | undefined, toolId?: string): Promise<string>;
3
+ export declare function updateMessage(chatId: string, messageId: string, content: string, status: MessageStatus, note?: string, toolId?: string): Promise<void>;
4
+ export declare function sendFinalMessages(chatId: string, messageId: string, fullContent: string, note: string, toolId?: string): Promise<void>;
5
+ export declare function sendTextReply(chatId: string, text: string): Promise<void>;
6
+ export declare function sendImageReply(chatId: string, imagePath: string): Promise<void>;
7
+ export declare function startTypingLoop(_chatId: string): () => void;
@@ -0,0 +1,253 @@
1
+ import { getClient } from './client.js';
2
+ import { messageCard } from '@larksuiteoapi/node-sdk';
3
+ import { readFileSync } from 'node:fs';
4
+ import { createLogger } from '../logger.js';
5
+ import { splitLongContent } from '../shared/utils.js';
6
+ import { MAX_FEISHU_MESSAGE_LENGTH } from '../constants.js';
7
+ const log = createLogger('FeishuSender');
8
+ const STATUS_ICONS = {
9
+ thinking: '🔵',
10
+ streaming: '🔵',
11
+ done: '🟢',
12
+ error: '🔴',
13
+ };
14
+ const TOOL_DISPLAY_NAMES = {
15
+ claude: 'claude-code',
16
+ codex: 'codex',
17
+ cursor: 'cursor',
18
+ };
19
+ function getToolTitle(toolId, status) {
20
+ const name = TOOL_DISPLAY_NAMES[toolId] ?? toolId;
21
+ if (status === 'thinking')
22
+ return `${name} - 思考中...`;
23
+ if (status === 'error')
24
+ return `${name} - 错误`;
25
+ return name;
26
+ }
27
+ async function getTenantAccessToken() {
28
+ const client = getClient();
29
+ const resp = await client.auth.tenantAccessToken.internal({
30
+ data: {
31
+ app_id: client.appId,
32
+ app_secret: client.appSecret,
33
+ },
34
+ });
35
+ if (resp.code !== 0 || !resp.data) {
36
+ throw new Error(`Failed to get tenant access token: ${resp.msg}`);
37
+ }
38
+ return resp.data.tenant_access_token;
39
+ }
40
+ export async function sendThinkingMessage(chatId, replyToMessageId, toolId = 'claude') {
41
+ const client = getClient();
42
+ // Use SDK's built-in card builder for simpler messages
43
+ const cardContent = messageCard.defaultCard({
44
+ title: `${STATUS_ICONS.thinking} ${getToolTitle(toolId, 'thinking')}`,
45
+ content: '正在思考...\n\n请稍候...',
46
+ });
47
+ try {
48
+ log.info(`Sending thinking message to chat ${chatId}, replyTo: ${replyToMessageId}`);
49
+ // 注意:飞书 create 接口不支持 uuid 参数,传 uuid 会导致请求失败
50
+ const resp = await client.im.message.create({
51
+ data: {
52
+ receive_id: chatId,
53
+ msg_type: 'interactive',
54
+ content: cardContent,
55
+ },
56
+ params: { receive_id_type: 'chat_id' },
57
+ });
58
+ if (!resp.data || !resp.data.message_id) {
59
+ throw new Error(`Failed to send message: ${resp.msg}`);
60
+ }
61
+ log.info(`Thinking message created with ID: ${resp.data.message_id}`);
62
+ return resp.data.message_id;
63
+ }
64
+ catch (err) {
65
+ log.error('Failed to send thinking message:', err);
66
+ throw err;
67
+ }
68
+ }
69
+ export async function updateMessage(chatId, messageId, content, status, note, toolId = 'claude') {
70
+ const client = getClient();
71
+ // Build content with note
72
+ let fullContent = content;
73
+ if (note) {
74
+ fullContent = `${content}\n\n─────────\n${note}`;
75
+ }
76
+ const icon = STATUS_ICONS[status];
77
+ const title = getToolTitle(toolId, status);
78
+ const cardContent = messageCard.defaultCard({
79
+ title: `${icon} ${title}`,
80
+ content: fullContent,
81
+ });
82
+ // Try to use patch API for in-place update (streaming)
83
+ try {
84
+ const resp = await client.im.message.patch({
85
+ path: { message_id: messageId },
86
+ data: {
87
+ content: cardContent,
88
+ },
89
+ });
90
+ if (resp.code === 0) {
91
+ log.info(`Message updated in-place: ${messageId}`);
92
+ return;
93
+ }
94
+ // If patch failed with validation error, fall back to delete+create
95
+ log.warn(`Patch API failed (code: ${resp.code}, msg: ${resp.msg}), falling back to delete+create`);
96
+ }
97
+ catch (err) {
98
+ // Log but don't throw - we'll fall back to delete+create
99
+ const errorMsg = err instanceof Error ? err.message : String(err);
100
+ log.debug(`Patch API error: ${errorMsg}, falling back to delete+create`);
101
+ }
102
+ // Fallback: Delete old message and send new one
103
+ try {
104
+ log.info(`Deleting old message ${messageId}`);
105
+ await client.im.message.delete({
106
+ path: { message_id: messageId },
107
+ });
108
+ log.info(`Old message deleted successfully`);
109
+ }
110
+ catch (err) {
111
+ log.warn('Failed to delete old message:', err);
112
+ }
113
+ // Send new message
114
+ try {
115
+ const resp = await client.im.message.create({
116
+ data: {
117
+ receive_id: chatId,
118
+ msg_type: 'interactive',
119
+ content: cardContent,
120
+ },
121
+ params: { receive_id_type: 'chat_id' },
122
+ });
123
+ log.info(`New message created with ID: ${resp.data?.message_id}`);
124
+ }
125
+ catch (err) {
126
+ log.error('Failed to send new message:', err);
127
+ throw err;
128
+ }
129
+ }
130
+ export async function sendFinalMessages(chatId, messageId, fullContent, note, toolId = 'claude') {
131
+ const client = getClient();
132
+ const parts = splitLongContent(fullContent, MAX_FEISHU_MESSAGE_LENGTH);
133
+ // If content fits in one message, try patch for smooth transition
134
+ if (parts.length === 1) {
135
+ const cardContent = messageCard.defaultCard({
136
+ title: `${STATUS_ICONS.done} ${getToolTitle(toolId, 'done')}`,
137
+ content: fullContent,
138
+ });
139
+ // Try to use patch API for in-place update
140
+ try {
141
+ const resp = await client.im.message.patch({
142
+ path: { message_id: messageId },
143
+ data: {
144
+ content: cardContent,
145
+ },
146
+ });
147
+ if (resp.code === 0) {
148
+ log.info(`Final message updated in-place: ${messageId}`);
149
+ return;
150
+ }
151
+ log.warn(`Patch API failed (code: ${resp.code}), falling back to delete+create`);
152
+ }
153
+ catch (err) {
154
+ const errorMsg = err instanceof Error ? err.message : String(err);
155
+ log.debug(`Patch API error: ${errorMsg}, falling back to delete+create`);
156
+ }
157
+ }
158
+ // Fallback: Delete old message first (for multi-part or failed patch)
159
+ try {
160
+ log.info(`Deleting old message ${messageId}`);
161
+ await client.im.message.delete({
162
+ path: { message_id: messageId },
163
+ });
164
+ log.info(`Old message deleted successfully`);
165
+ }
166
+ catch (err) {
167
+ log.warn('Failed to delete old message:', err);
168
+ }
169
+ // Send new messages
170
+ for (let i = 0; i < parts.length; i++) {
171
+ try {
172
+ const cardContent = messageCard.defaultCard({
173
+ title: `${STATUS_ICONS.done} ${getToolTitle(toolId, 'done')}`,
174
+ content: i === 0 ? parts[0] : parts[i] + `\n\n(续 ${i + 1}/${parts.length})`,
175
+ });
176
+ await client.im.message.create({
177
+ data: {
178
+ receive_id: chatId,
179
+ msg_type: 'interactive',
180
+ content: cardContent,
181
+ },
182
+ params: { receive_id_type: 'chat_id' },
183
+ });
184
+ }
185
+ catch (err) {
186
+ log.error(`Failed to send part ${i + 1}:`, err);
187
+ }
188
+ }
189
+ }
190
+ export async function sendTextReply(chatId, text) {
191
+ const client = getClient();
192
+ // Use SDK's built-in card builder for simpler messages
193
+ const cardContent = messageCard.defaultCard({
194
+ title: 'open-im',
195
+ content: text,
196
+ });
197
+ try {
198
+ await client.im.message.create({
199
+ data: {
200
+ receive_id: chatId,
201
+ msg_type: 'interactive',
202
+ content: cardContent,
203
+ },
204
+ params: { receive_id_type: 'chat_id' },
205
+ });
206
+ }
207
+ catch (err) {
208
+ log.error('Failed to send text:', err);
209
+ }
210
+ }
211
+ export async function sendImageReply(chatId, imagePath) {
212
+ const client = getClient();
213
+ try {
214
+ // First, upload the image to get an image key
215
+ const imageBuffer = readFileSync(imagePath);
216
+ const form = new FormData();
217
+ form.append('file', new Blob([imageBuffer]), 'image.jpg');
218
+ form.append('image_type', 'message');
219
+ const token = await getTenantAccessToken();
220
+ const uploadResp = await fetch('https://open.feishu.cn/open-apis/im/v1/images', {
221
+ method: 'POST',
222
+ headers: {
223
+ Authorization: `Bearer ${token}`,
224
+ },
225
+ body: form,
226
+ });
227
+ if (!uploadResp.ok) {
228
+ throw new Error(`Failed to upload image: ${uploadResp.statusText}`);
229
+ }
230
+ const uploadData = await uploadResp.json();
231
+ if (uploadData.code !== 0) {
232
+ throw new Error(`Failed to upload image: ${uploadData.msg}`);
233
+ }
234
+ const imageKey = uploadData.data.image_key;
235
+ // Now send the image message
236
+ await client.im.message.create({
237
+ data: {
238
+ receive_id: chatId,
239
+ msg_type: 'image',
240
+ content: JSON.stringify({ image_key: imageKey }),
241
+ },
242
+ params: { receive_id_type: 'chat_id' },
243
+ });
244
+ }
245
+ catch (err) {
246
+ log.error('Failed to send image:', err);
247
+ }
248
+ }
249
+ export function startTypingLoop(_chatId) {
250
+ // Feishu doesn't have a typing indicator like Telegram
251
+ // Return a no-op function
252
+ return () => { };
253
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { needsSetup } from './config.js';
2
- import { runInteractiveSetup } from './setup.js';
1
+ import { needsSetup } from "./config.js";
2
+ import { runInteractiveSetup } from "./setup.js";
3
3
  export { needsSetup, runInteractiveSetup };
4
4
  export declare function main(): Promise<void>;