cursor-feedback 1.2.0 → 2.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.
- package/CHANGELOG.md +7 -0
- package/README.md +40 -7
- package/README_CN.md +40 -7
- package/dist/extension.js +480 -53
- package/dist/feishu.js +667 -0
- package/dist/i18n/en.json +49 -7
- package/dist/i18n/index.js +46 -4
- package/dist/i18n/zh-CN.json +49 -7
- package/dist/mcp-server.js +514 -20
- package/dist/webview/index.html +193 -43
- package/dist/webview/script.js +795 -145
- package/dist/webview/styles.css +921 -298
- package/package.json +29 -2
- package/.github/workflows/release-please.yml +0 -19
- package/.husky/pre-commit +0 -2
- package/.husky/pre-push +0 -1
- package/.versionrc.json +0 -15
- package/.vscode/launch.json +0 -28
- package/.vscode/tasks.json +0 -22
- package/.vscodeignore +0 -11
- package/demo.gif +0 -0
- package/dist/extension.js.map +0 -1
- package/dist/i18n/index.js.map +0 -1
- package/dist/mcp-server.js.map +0 -1
- package/icon.png +0 -0
- package/mcp-install-dark.png +0 -0
- package/resources/icon.svg +0 -4
- package/resources/vendor/marked.min.js +0 -69
- package/scripts/check-changelog.js +0 -42
- package/src/extension.ts +0 -759
- package/src/i18n/en.json +0 -34
- package/src/i18n/index.ts +0 -131
- package/src/i18n/zh-CN.json +0 -34
- package/src/mcp-server.ts +0 -768
- package/src/webview/index.html +0 -69
- package/src/webview/script.js +0 -318
- package/src/webview/styles.css +0 -422
- package/tsconfig.json +0 -17
package/dist/feishu.js
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 飞书机器人桥接模块
|
|
4
|
+
*
|
|
5
|
+
* 职责:
|
|
6
|
+
* - 通过「长连接」接收用户发给机器人的消息(im.message.receive_v1)
|
|
7
|
+
* - 通过 API 主动推送「反馈请求」交互卡片
|
|
8
|
+
* - 维护「卡片 message_id ↔ requestId」映射,支持用户「回复某条卡片」时精确路由
|
|
9
|
+
*
|
|
10
|
+
* 设计要点:
|
|
11
|
+
* - 飞书 SDK 用动态 import 按需加载:用户没配置飞书时,这个重依赖根本不会被 require,零开销。
|
|
12
|
+
* - 本模块只封装「飞书侧能力 + 映射表」,不掺杂业务路由决策;
|
|
13
|
+
* 路由(resolve 哪个 pending / 是否跨实例转发)由 mcp-server 通过注入的回调处理。
|
|
14
|
+
*/
|
|
15
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
18
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
19
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
20
|
+
}
|
|
21
|
+
Object.defineProperty(o, k2, desc);
|
|
22
|
+
}) : (function(o, m, k, k2) {
|
|
23
|
+
if (k2 === undefined) k2 = k;
|
|
24
|
+
o[k2] = m[k];
|
|
25
|
+
}));
|
|
26
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
27
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
28
|
+
}) : function(o, v) {
|
|
29
|
+
o["default"] = v;
|
|
30
|
+
});
|
|
31
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
32
|
+
var ownKeys = function(o) {
|
|
33
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
34
|
+
var ar = [];
|
|
35
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
36
|
+
return ar;
|
|
37
|
+
};
|
|
38
|
+
return ownKeys(o);
|
|
39
|
+
};
|
|
40
|
+
return function (mod) {
|
|
41
|
+
if (mod && mod.__esModule) return mod;
|
|
42
|
+
var result = {};
|
|
43
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
44
|
+
__setModuleDefault(result, mod);
|
|
45
|
+
return result;
|
|
46
|
+
};
|
|
47
|
+
})();
|
|
48
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
49
|
+
exports.FeishuBridge = void 0;
|
|
50
|
+
const fs = __importStar(require("fs"));
|
|
51
|
+
const os = __importStar(require("os"));
|
|
52
|
+
const path = __importStar(require("path"));
|
|
53
|
+
function flog(message) {
|
|
54
|
+
console.error(`[${new Date().toISOString()}] [feishu] ${message}`);
|
|
55
|
+
}
|
|
56
|
+
class FeishuBridge {
|
|
57
|
+
constructor() {
|
|
58
|
+
this.lark = null;
|
|
59
|
+
this.client = null; // Lark.Client:调用 API 发消息
|
|
60
|
+
this.wsClient = null; // Lark.WSClient:长连接收事件
|
|
61
|
+
this.config = null;
|
|
62
|
+
this.connected = false;
|
|
63
|
+
this.enabled = true;
|
|
64
|
+
/** Get 表情轻回执开关(false 时用户回复后不加表情、也不发文字兜底) */
|
|
65
|
+
this.ackReaction = true;
|
|
66
|
+
/** 绑定关系(appId -> chat_id)持久化到磁盘:多个 server 进程共享、reload 不丢 */
|
|
67
|
+
this.bindStorePath = path.join(os.homedir(), '.cursor-feedback', 'feishu-bind.json');
|
|
68
|
+
/** 飞书凭证持久化到磁盘:多个 server 进程共享、reload/重启不丢,作为凭证的全局真相源 */
|
|
69
|
+
this.configStorePath = path.join(os.homedir(), '.cursor-feedback', 'feishu-config.json');
|
|
70
|
+
// 卡片 message_id -> requestId(仅记录本实例发出的卡片)
|
|
71
|
+
this.cardToRequest = new Map();
|
|
72
|
+
// requestId -> 卡片 message_id(清理用)
|
|
73
|
+
this.requestToCard = new Map();
|
|
74
|
+
// requestId -> 摘要信息(多窗口「列清单」提示用)
|
|
75
|
+
this.pendingSummaries = new Map();
|
|
76
|
+
/** 收到用户回复时的回调(由 mcp-server 注入,负责路由 / 转发) */
|
|
77
|
+
this.onReply = null;
|
|
78
|
+
/** 绑定的 chat_id 变化时的回调(由 mcp-server 注入,用于通知 extension 持久化) */
|
|
79
|
+
this.onBindChange = null;
|
|
80
|
+
/** 按 chatId 暂存待合并的入站消息 */
|
|
81
|
+
this.inboundBuffer = new Map();
|
|
82
|
+
}
|
|
83
|
+
setOnReply(fn) {
|
|
84
|
+
this.onReply = fn;
|
|
85
|
+
}
|
|
86
|
+
setOnBindChange(fn) {
|
|
87
|
+
this.onBindChange = fn;
|
|
88
|
+
}
|
|
89
|
+
getStatus() {
|
|
90
|
+
return {
|
|
91
|
+
configured: !!this.config,
|
|
92
|
+
connected: this.connected,
|
|
93
|
+
enabled: this.enabled,
|
|
94
|
+
ackReaction: this.ackReaction,
|
|
95
|
+
appId: this.config?.appId || '',
|
|
96
|
+
appSecret: this.config?.appSecret || '',
|
|
97
|
+
boundChatId: this.getBoundChatId(),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
isConfigured() {
|
|
101
|
+
return !!this.config;
|
|
102
|
+
}
|
|
103
|
+
/** 读取当前 appId 对应的绑定 chat_id(来自磁盘,跨进程共享) */
|
|
104
|
+
getBoundChatId() {
|
|
105
|
+
if (!this.config?.appId)
|
|
106
|
+
return null;
|
|
107
|
+
return this.readBindStore()[this.config.appId] || null;
|
|
108
|
+
}
|
|
109
|
+
/** 记录绑定(仅在变化时写盘,避免频繁 IO) */
|
|
110
|
+
setBoundChatId(chatId) {
|
|
111
|
+
if (!this.config?.appId)
|
|
112
|
+
return;
|
|
113
|
+
const store = this.readBindStore();
|
|
114
|
+
if (store[this.config.appId] === chatId)
|
|
115
|
+
return;
|
|
116
|
+
store[this.config.appId] = chatId;
|
|
117
|
+
this.writeBindStore(store);
|
|
118
|
+
flog('已绑定接收方: ' + chatId);
|
|
119
|
+
}
|
|
120
|
+
/** 解除绑定(用户「删绑定」后可重新发消息绑定) */
|
|
121
|
+
unbind() {
|
|
122
|
+
const store = this.readBindStore();
|
|
123
|
+
if (this.config?.appId) {
|
|
124
|
+
delete store[this.config.appId];
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
for (const k of Object.keys(store))
|
|
128
|
+
delete store[k];
|
|
129
|
+
}
|
|
130
|
+
this.writeBindStore(store);
|
|
131
|
+
flog('已解除绑定');
|
|
132
|
+
}
|
|
133
|
+
readBindStore() {
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(fs.readFileSync(this.bindStorePath, 'utf-8')) || {};
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return {};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
writeBindStore(data) {
|
|
142
|
+
try {
|
|
143
|
+
fs.mkdirSync(path.dirname(this.bindStorePath), { recursive: true });
|
|
144
|
+
fs.writeFileSync(this.bindStorePath, JSON.stringify(data), 'utf-8');
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
flog('写绑定文件失败: ' + e);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** 读取持久化的飞书凭证(无文件返回 null)。touched=true 表示用户在面板主动配过(含主动清空)。 */
|
|
151
|
+
readPersistedConfig() {
|
|
152
|
+
try {
|
|
153
|
+
const raw = JSON.parse(fs.readFileSync(this.configStorePath, 'utf-8'));
|
|
154
|
+
return raw && typeof raw === 'object' ? raw : null;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** 持久化飞书凭证到磁盘(跨 server 进程共享、重启不丢)。 */
|
|
161
|
+
writePersistedConfig(config) {
|
|
162
|
+
try {
|
|
163
|
+
fs.mkdirSync(path.dirname(this.configStorePath), { recursive: true });
|
|
164
|
+
fs.writeFileSync(this.configStorePath, JSON.stringify(config), 'utf-8');
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
flog('写飞书凭证文件失败: ' + e);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* 配置 / 重新配置飞书。
|
|
172
|
+
* - enabled 开关随时更新(与凭证无关,关了则不推卡片但保留长连接)
|
|
173
|
+
* - 凭证未变:不重建连接(绑定走磁盘共享文件,无需在此补)
|
|
174
|
+
* - 凭证变化:停掉旧连接并以新凭证重建长连接
|
|
175
|
+
* - 凭证为空:视为「关闭飞书」
|
|
176
|
+
*/
|
|
177
|
+
async configure(config) {
|
|
178
|
+
// 通知开关 / Get 表情回执开关随时可改(与凭证无关),总是更新
|
|
179
|
+
this.enabled = config.enabled !== false;
|
|
180
|
+
this.ackReaction = config.ackReaction !== false;
|
|
181
|
+
const sameCred = this.config &&
|
|
182
|
+
this.config.appId === config.appId &&
|
|
183
|
+
this.config.appSecret === config.appSecret;
|
|
184
|
+
// 凭证没变:仅开关变化,无需重建连接
|
|
185
|
+
if (sameCred)
|
|
186
|
+
return;
|
|
187
|
+
// 凭证变化 → 重建
|
|
188
|
+
await this.stop();
|
|
189
|
+
if (!config.appId || !config.appSecret) {
|
|
190
|
+
this.config = null;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
this.config = config;
|
|
194
|
+
if (!this.lark) {
|
|
195
|
+
try {
|
|
196
|
+
this.lark = await Promise.resolve().then(() => __importStar(require('@larksuiteoapi/node-sdk')));
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
flog('飞书 SDK 加载失败(请确认依赖已安装): ' + e);
|
|
200
|
+
this.config = null;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const Lark = this.lark;
|
|
205
|
+
try {
|
|
206
|
+
this.client = new Lark.Client({ appId: config.appId, appSecret: config.appSecret });
|
|
207
|
+
this.wsClient = new Lark.WSClient({
|
|
208
|
+
appId: config.appId,
|
|
209
|
+
appSecret: config.appSecret,
|
|
210
|
+
loggerLevel: Lark.LoggerLevel.error,
|
|
211
|
+
});
|
|
212
|
+
const dispatcher = new Lark.EventDispatcher({}).register({
|
|
213
|
+
'im.message.receive_v1': async (data) => {
|
|
214
|
+
await this.handleInbound(data);
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
this.wsClient.start({ eventDispatcher: dispatcher });
|
|
218
|
+
this.connected = true;
|
|
219
|
+
flog('飞书长连接已启动');
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
flog('飞书长连接启动失败: ' + e);
|
|
223
|
+
this.connected = false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** 下载资源的临时落盘目录(系统临时区,跨进程一致) */
|
|
227
|
+
get inboundDir() {
|
|
228
|
+
return path.join(os.tmpdir(), 'cursor-feedback-inbound');
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* 处理用户发来的消息:支持 text / image / file / audio / media / post / sticker。
|
|
232
|
+
* - 图片:小图转 base64 走视觉通道,大图落盘走文件通道
|
|
233
|
+
* - 文件 / 音频 / 视频:下载落盘,回传绝对路径
|
|
234
|
+
* - 富文本 post:拼接文字 + 下载内嵌图片
|
|
235
|
+
* - 其他:降级为占位文字说明
|
|
236
|
+
*/
|
|
237
|
+
async handleInbound(data) {
|
|
238
|
+
try {
|
|
239
|
+
const msg = data?.message;
|
|
240
|
+
if (!msg)
|
|
241
|
+
return;
|
|
242
|
+
const chatId = msg.chat_id || '';
|
|
243
|
+
if (chatId) {
|
|
244
|
+
// 任何来自用户的消息都把「推送目标」更新到磁盘(多进程共享、reload 不丢)
|
|
245
|
+
this.setBoundChatId(chatId);
|
|
246
|
+
this.onBindChange?.(chatId);
|
|
247
|
+
}
|
|
248
|
+
const messageId = msg.message_id || '';
|
|
249
|
+
const msgType = msg.message_type || '';
|
|
250
|
+
const parentId = msg.parent_id || null;
|
|
251
|
+
let content = {};
|
|
252
|
+
try {
|
|
253
|
+
content = JSON.parse(msg.content || '{}');
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// 容错:content 非 JSON 时按空对象处理
|
|
257
|
+
}
|
|
258
|
+
let text = '';
|
|
259
|
+
const images = [];
|
|
260
|
+
const files = [];
|
|
261
|
+
switch (msgType) {
|
|
262
|
+
case 'text':
|
|
263
|
+
text = (content.text || '').trim();
|
|
264
|
+
break;
|
|
265
|
+
case 'image': {
|
|
266
|
+
const r = await this.ingestImage(messageId, content.image_key, images, files);
|
|
267
|
+
if (!r.ok)
|
|
268
|
+
text = `[图片接收失败:${r.reason || '未知原因'}]`;
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
case 'file':
|
|
272
|
+
case 'audio':
|
|
273
|
+
case 'media': {
|
|
274
|
+
const fallbackName = msgType === 'audio' ? 'audio.opus' : msgType === 'media' ? 'video.mp4' : 'file.bin';
|
|
275
|
+
const r = await this.ingestFile(messageId, content.file_key, content.file_name || fallbackName, files);
|
|
276
|
+
if (!r.ok)
|
|
277
|
+
text = `[${msgType} 接收失败:${r.reason || '未知原因'}]`;
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
case 'post': {
|
|
281
|
+
const r = await this.ingestPost(messageId, content);
|
|
282
|
+
text = r.text;
|
|
283
|
+
images.push(...r.images);
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
case 'sticker':
|
|
287
|
+
text = '[表情]';
|
|
288
|
+
break;
|
|
289
|
+
default:
|
|
290
|
+
text = `[暂不支持的消息类型: ${msgType || 'unknown'}]`;
|
|
291
|
+
}
|
|
292
|
+
this.bufferInbound(chatId, parentId, messageId, (text || '').trim(), images, files);
|
|
293
|
+
}
|
|
294
|
+
catch (e) {
|
|
295
|
+
flog('处理飞书消息出错: ' + e);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* 把单条消息并入合并缓冲:每来一条就重置定时器,静默 INBOUND_MERGE_MS 后
|
|
300
|
+
* 将累积的文字 / 图片 / 文件合并为一次 onReply,根治飞书「图文拆条」导致的丢图与多回执。
|
|
301
|
+
*/
|
|
302
|
+
bufferInbound(chatId, parentId, messageId, text, images, files) {
|
|
303
|
+
const key = chatId || '_';
|
|
304
|
+
const existing = this.inboundBuffer.get(key);
|
|
305
|
+
if (existing)
|
|
306
|
+
clearTimeout(existing.timer);
|
|
307
|
+
const buf = existing || {
|
|
308
|
+
parentId: null,
|
|
309
|
+
lastMessageId: '',
|
|
310
|
+
texts: [],
|
|
311
|
+
images: [],
|
|
312
|
+
files: [],
|
|
313
|
+
timer: undefined,
|
|
314
|
+
};
|
|
315
|
+
if (!buf.parentId && parentId)
|
|
316
|
+
buf.parentId = parentId;
|
|
317
|
+
if (messageId)
|
|
318
|
+
buf.lastMessageId = messageId;
|
|
319
|
+
if (text)
|
|
320
|
+
buf.texts.push(text);
|
|
321
|
+
buf.images.push(...images);
|
|
322
|
+
buf.files.push(...files);
|
|
323
|
+
buf.timer = setTimeout(() => {
|
|
324
|
+
this.inboundBuffer.delete(key);
|
|
325
|
+
this.onReply?.({
|
|
326
|
+
parentId: buf.parentId,
|
|
327
|
+
text: buf.texts.join('\n').trim(),
|
|
328
|
+
chatId,
|
|
329
|
+
messageId: buf.lastMessageId,
|
|
330
|
+
images: buf.images,
|
|
331
|
+
files: buf.files,
|
|
332
|
+
});
|
|
333
|
+
}, FeishuBridge.INBOUND_MERGE_MS);
|
|
334
|
+
this.inboundBuffer.set(key, buf);
|
|
335
|
+
}
|
|
336
|
+
/** 下载图片:小图走 base64(images),大图落盘(files)。成功 ok=true,失败带 reason */
|
|
337
|
+
async ingestImage(messageId, imageKey, images, files) {
|
|
338
|
+
const { buf, reason } = await this.fetchResource(messageId, imageKey, 'image');
|
|
339
|
+
if (!buf)
|
|
340
|
+
return { ok: false, reason };
|
|
341
|
+
if (buf.length <= FeishuBridge.MAX_IMAGE_BASE64) {
|
|
342
|
+
images.push({ name: `${imageKey}.jpg`, data: buf.toString('base64'), size: buf.length });
|
|
343
|
+
return { ok: true };
|
|
344
|
+
}
|
|
345
|
+
const p = this.saveToTmp(buf, `${imageKey}.jpg`);
|
|
346
|
+
if (p) {
|
|
347
|
+
files.push(p);
|
|
348
|
+
return { ok: true };
|
|
349
|
+
}
|
|
350
|
+
return { ok: false, reason: '图片落盘失败' };
|
|
351
|
+
}
|
|
352
|
+
/** 下载文件 / 音频 / 视频并落盘,回传绝对路径。成功 ok=true,失败带 reason */
|
|
353
|
+
async ingestFile(messageId, fileKey, name, files) {
|
|
354
|
+
const { buf, reason } = await this.fetchResource(messageId, fileKey, 'file');
|
|
355
|
+
if (!buf)
|
|
356
|
+
return { ok: false, reason };
|
|
357
|
+
const p = this.saveToTmp(buf, name);
|
|
358
|
+
if (p) {
|
|
359
|
+
files.push(p);
|
|
360
|
+
return { ok: true };
|
|
361
|
+
}
|
|
362
|
+
return { ok: false, reason: '文件落盘失败' };
|
|
363
|
+
}
|
|
364
|
+
/** 解析富文本 post:拼接文字 + 下载内嵌图片 */
|
|
365
|
+
async ingestPost(messageId, content) {
|
|
366
|
+
const images = [];
|
|
367
|
+
const parts = [];
|
|
368
|
+
if (content?.title)
|
|
369
|
+
parts.push(String(content.title));
|
|
370
|
+
const rows = Array.isArray(content?.content) ? content.content : [];
|
|
371
|
+
for (const row of rows) {
|
|
372
|
+
if (!Array.isArray(row))
|
|
373
|
+
continue;
|
|
374
|
+
for (const el of row) {
|
|
375
|
+
if (!el || typeof el !== 'object')
|
|
376
|
+
continue;
|
|
377
|
+
if (el.tag === 'text' && el.text)
|
|
378
|
+
parts.push(String(el.text));
|
|
379
|
+
else if (el.tag === 'a' && el.text)
|
|
380
|
+
parts.push(`${el.text}(${el.href || ''})`);
|
|
381
|
+
else if (el.tag === 'at')
|
|
382
|
+
parts.push(`@${el.user_name || el.user_id || ''}`);
|
|
383
|
+
else if (el.tag === 'img' && el.image_key) {
|
|
384
|
+
const { buf, reason } = await this.fetchResource(messageId, el.image_key, 'image');
|
|
385
|
+
if (buf && buf.length <= FeishuBridge.MAX_IMAGE_BASE64) {
|
|
386
|
+
images.push({
|
|
387
|
+
name: `${el.image_key}.jpg`,
|
|
388
|
+
data: buf.toString('base64'),
|
|
389
|
+
size: buf.length,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
else if (buf) {
|
|
393
|
+
const p = this.saveToTmp(buf, `${el.image_key}.jpg`);
|
|
394
|
+
if (p)
|
|
395
|
+
parts.push(`[大图已存: ${path.basename(p)}]`);
|
|
396
|
+
}
|
|
397
|
+
else if (reason) {
|
|
398
|
+
parts.push(`[图片接收失败:${reason}]`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return { text: parts.join(' ').trim(), images };
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* 把飞书「获取消息资源」接口的错误码翻译成可读提示,直接回执给用户
|
|
407
|
+
* (飞书里就能看到原因),不必翻 MCP 日志。码值含义见官方文档「获取消息中的资源文件」。
|
|
408
|
+
*/
|
|
409
|
+
static resourceErrorHint(code, status, msg) {
|
|
410
|
+
switch (code) {
|
|
411
|
+
case 234009:
|
|
412
|
+
return '飞书应用缺少读取消息资源的权限:请到开放平台「权限管理」开启 im:message(获取与发送单聊、群组消息)权限,重新发布版本后重试。';
|
|
413
|
+
case 234004:
|
|
414
|
+
return '机器人不在该消息所在的会话中(App not in chat)。';
|
|
415
|
+
case 234003:
|
|
416
|
+
return '资源与消息不匹配(File not in message)。';
|
|
417
|
+
case 234037:
|
|
418
|
+
return '资源超过 100MB 下载上限。';
|
|
419
|
+
case 234001:
|
|
420
|
+
return '请求参数有误(Invalid request param)。';
|
|
421
|
+
default:
|
|
422
|
+
return `飞书返回错误(code=${code ?? '?'} status=${status ?? '?'}${msg ? ' msg=' + msg : ''})。`;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/** 调用飞书「获取消息资源」接口;成功返回 { buf },失败返回 { reason } */
|
|
426
|
+
async fetchResource(messageId, fileKey, type) {
|
|
427
|
+
if (!this.client)
|
|
428
|
+
return { reason: '飞书未连接' };
|
|
429
|
+
if (!messageId || !fileKey)
|
|
430
|
+
return { reason: '缺少消息或资源标识' };
|
|
431
|
+
try {
|
|
432
|
+
const resp = await this.client.im.v1.messageResource.get({
|
|
433
|
+
path: { message_id: messageId, file_key: fileKey },
|
|
434
|
+
params: { type },
|
|
435
|
+
});
|
|
436
|
+
const stream = resp.getReadableStream();
|
|
437
|
+
const chunks = [];
|
|
438
|
+
for await (const chunk of stream) {
|
|
439
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
440
|
+
}
|
|
441
|
+
return { buf: Buffer.concat(chunks) };
|
|
442
|
+
}
|
|
443
|
+
catch (e) {
|
|
444
|
+
// response 对象含 socket/agent 循环引用,不能整体 JSON.stringify,只摘关键字段
|
|
445
|
+
const status = e?.response?.status ?? e?.status;
|
|
446
|
+
const data = e?.response?.data;
|
|
447
|
+
const code = data?.code ?? e?.code;
|
|
448
|
+
const msg = data?.msg ?? e?.msg ?? e?.message;
|
|
449
|
+
flog(`下载资源失败(${type}/${fileKey}): status=${status} code=${code} msg=${msg}`);
|
|
450
|
+
return { reason: FeishuBridge.resourceErrorHint(code, status, msg) };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/** 把二进制写入临时目录,返回绝对路径(失败返回 null)。顺手清理过期文件 */
|
|
454
|
+
saveToTmp(buf, name) {
|
|
455
|
+
try {
|
|
456
|
+
const dir = this.inboundDir;
|
|
457
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
458
|
+
this.cleanupInbound(dir);
|
|
459
|
+
const safe = (name || 'file').replace(/[^\w.\-]+/g, '_').slice(-80);
|
|
460
|
+
const p = path.join(dir, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${safe}`);
|
|
461
|
+
fs.writeFileSync(p, buf);
|
|
462
|
+
return p;
|
|
463
|
+
}
|
|
464
|
+
catch (e) {
|
|
465
|
+
flog('落盘失败: ' + e);
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
/** 清理 inbound 目录中早于 TTL 的文件,避免磁盘堆积 */
|
|
470
|
+
cleanupInbound(dir) {
|
|
471
|
+
try {
|
|
472
|
+
const now = Date.now();
|
|
473
|
+
for (const f of fs.readdirSync(dir)) {
|
|
474
|
+
const fp = path.join(dir, f);
|
|
475
|
+
try {
|
|
476
|
+
if (now - fs.statSync(fp).mtimeMs > FeishuBridge.INBOUND_TTL_MS)
|
|
477
|
+
fs.unlinkSync(fp);
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
// 单个文件清理失败忽略
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
// 目录不存在等忽略
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* 推送「反馈请求」交互卡片,返回卡片 message_id(失败返回 null)。
|
|
490
|
+
*/
|
|
491
|
+
async sendFeedbackCard(requestId, summary, projectDir) {
|
|
492
|
+
if (!this.enabled)
|
|
493
|
+
return null;
|
|
494
|
+
const boundChatId = this.getBoundChatId();
|
|
495
|
+
if (!this.client || !boundChatId)
|
|
496
|
+
return null;
|
|
497
|
+
try {
|
|
498
|
+
const card = this.buildCard(summary, projectDir);
|
|
499
|
+
const res = await this.client.im.v1.message.create({
|
|
500
|
+
params: { receive_id_type: 'chat_id' },
|
|
501
|
+
data: {
|
|
502
|
+
receive_id: boundChatId,
|
|
503
|
+
msg_type: 'interactive',
|
|
504
|
+
content: JSON.stringify(card),
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
const messageId = res?.data?.message_id || null;
|
|
508
|
+
if (messageId) {
|
|
509
|
+
// 卡片映射在请求结束后仍保留(用于「回复旧卡片 → 回执已结束」),用 FIFO 上界防止长期运行无限增长
|
|
510
|
+
const MAX_CARDS = 200;
|
|
511
|
+
while (this.cardToRequest.size >= MAX_CARDS) {
|
|
512
|
+
const oldest = this.cardToRequest.keys().next().value;
|
|
513
|
+
if (oldest === undefined)
|
|
514
|
+
break;
|
|
515
|
+
const oldReq = this.cardToRequest.get(oldest);
|
|
516
|
+
this.cardToRequest.delete(oldest);
|
|
517
|
+
if (oldReq !== undefined)
|
|
518
|
+
this.requestToCard.delete(oldReq);
|
|
519
|
+
}
|
|
520
|
+
this.cardToRequest.set(messageId, requestId);
|
|
521
|
+
this.requestToCard.set(requestId, messageId);
|
|
522
|
+
this.pendingSummaries.set(requestId, { summary, projectDir });
|
|
523
|
+
}
|
|
524
|
+
return messageId;
|
|
525
|
+
}
|
|
526
|
+
catch (e) {
|
|
527
|
+
flog('飞书发送卡片失败: ' + e);
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
/** 用 parent_id 反查 requestId(命中返回 requestId,否则 null) */
|
|
532
|
+
resolveParent(parentId) {
|
|
533
|
+
if (!parentId)
|
|
534
|
+
return null;
|
|
535
|
+
return this.cardToRequest.get(parentId) || null;
|
|
536
|
+
}
|
|
537
|
+
/** 本实例当前待回复的请求数(用于多窗口判断) */
|
|
538
|
+
pendingCount() {
|
|
539
|
+
return this.pendingSummaries.size;
|
|
540
|
+
}
|
|
541
|
+
/** 本实例当前待回复请求清单 */
|
|
542
|
+
listPending() {
|
|
543
|
+
return Array.from(this.pendingSummaries.entries()).map(([requestId, v]) => ({
|
|
544
|
+
requestId,
|
|
545
|
+
summary: v.summary,
|
|
546
|
+
projectDir: v.projectDir,
|
|
547
|
+
}));
|
|
548
|
+
}
|
|
549
|
+
/** 本实例是否持有某 requestId 的卡片 */
|
|
550
|
+
hasRequest(requestId) {
|
|
551
|
+
return this.requestToCard.has(requestId);
|
|
552
|
+
}
|
|
553
|
+
/** 取唯一待回复请求的 id(仅当恰好 1 个时有意义) */
|
|
554
|
+
theOnlyPendingId() {
|
|
555
|
+
if (this.pendingSummaries.size !== 1)
|
|
556
|
+
return null;
|
|
557
|
+
return this.pendingSummaries.keys().next().value || null;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* 请求结束(超时 / 插件已提交 / 飞书已回复)→ 仅清「待回复」计数,保留卡片 message_id 映射。
|
|
561
|
+
* 保留映射让用户回复一张已结束的旧卡片时仍能被 resolveParent 命中、回执「已结束」,
|
|
562
|
+
* 而不是因映射丢失被当成陌生卡片跨窗口广播、最终石沉大海。
|
|
563
|
+
* 映射的无限增长由 sendFeedbackCard 的 FIFO 上界兜底。
|
|
564
|
+
*/
|
|
565
|
+
clearPending(requestId) {
|
|
566
|
+
this.pendingSummaries.delete(requestId);
|
|
567
|
+
}
|
|
568
|
+
/** 给某个会话回执一句话(纯文本) */
|
|
569
|
+
async replyText(chatId, text) {
|
|
570
|
+
if (!this.client || !chatId)
|
|
571
|
+
return;
|
|
572
|
+
try {
|
|
573
|
+
await this.client.im.v1.message.create({
|
|
574
|
+
params: { receive_id_type: 'chat_id' },
|
|
575
|
+
data: {
|
|
576
|
+
receive_id: chatId,
|
|
577
|
+
msg_type: 'text',
|
|
578
|
+
content: JSON.stringify({ text }),
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
catch (e) {
|
|
583
|
+
flog('飞书回执失败: ' + e);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* 给某条消息加 Get 表情作为「轻回执」:不产生新消息、不产生未读。
|
|
588
|
+
* 失败(如该消息类型不支持表情)时回退文字回执,保证用户知道已收到。
|
|
589
|
+
*/
|
|
590
|
+
async reactDone(messageId, chatIdForFallback) {
|
|
591
|
+
// Get 表情回执子开关关闭,或飞书通知主开关关闭 → 整个轻回执静默(不加表情、也不发文字兜底)
|
|
592
|
+
if (!this.enabled || !this.ackReaction)
|
|
593
|
+
return;
|
|
594
|
+
if (!this.client || !messageId) {
|
|
595
|
+
if (chatIdForFallback) {
|
|
596
|
+
await this.replyText(chatIdForFallback, '✅ 已收到,已同步回 Cursor,等待AI回复中...');
|
|
597
|
+
}
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
try {
|
|
601
|
+
await this.client.im.v1.messageReaction.create({
|
|
602
|
+
path: { message_id: messageId },
|
|
603
|
+
data: { reaction_type: { emoji_type: 'Get' } },
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
catch (e) {
|
|
607
|
+
flog('表情回应失败,回退文字: ' + e);
|
|
608
|
+
if (chatIdForFallback) {
|
|
609
|
+
await this.replyText(chatIdForFallback, '✅ 已收到,已同步回 Cursor,等待AI回复中...');
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
async stop() {
|
|
614
|
+
try {
|
|
615
|
+
this.wsClient?.stop?.();
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
// ignore
|
|
619
|
+
}
|
|
620
|
+
this.wsClient = null;
|
|
621
|
+
this.client = null;
|
|
622
|
+
this.connected = false;
|
|
623
|
+
}
|
|
624
|
+
/** 构建反馈请求卡片(JSON 2.0 卡片 + markdown 组件,纯展示 + 引导「回复」) */
|
|
625
|
+
buildCard(summary, projectDir) {
|
|
626
|
+
const projectName = projectDir.replace(/\\/g, '/').replace(/\/+$/, '').split('/').pop() || projectDir;
|
|
627
|
+
// 飞书卡片有长度限制,过长摘要做截断(完整内容仍在 Cursor 面板)
|
|
628
|
+
const MAX = 3000;
|
|
629
|
+
let body = summary || '(无摘要)';
|
|
630
|
+
if (body.length > MAX) {
|
|
631
|
+
body = body.slice(0, MAX) + '\n\n…(内容过长已截断,完整内容见 Cursor 面板)';
|
|
632
|
+
}
|
|
633
|
+
// 飞书 JSON 2.0 卡片 + markdown 组件:相比 1.0 的 lark_md,额外支持标题(#)、
|
|
634
|
+
// 引用(>)、表格、有序/无序列表、代码块等,让 AI 摘要在飞书里排版更完整。
|
|
635
|
+
return {
|
|
636
|
+
schema: '2.0',
|
|
637
|
+
config: { wide_screen_mode: true },
|
|
638
|
+
header: {
|
|
639
|
+
template: 'carmine',
|
|
640
|
+
title: { tag: 'plain_text', content: `Cursor 反馈请求 · ${projectName}` },
|
|
641
|
+
},
|
|
642
|
+
body: {
|
|
643
|
+
elements: [
|
|
644
|
+
{ tag: 'markdown', content: body },
|
|
645
|
+
{ tag: 'hr' },
|
|
646
|
+
{
|
|
647
|
+
tag: 'markdown',
|
|
648
|
+
text_size: 'notation',
|
|
649
|
+
content: '直接「回复」本条消息把反馈发回 Cursor(多窗口时请回复对应卡片)',
|
|
650
|
+
},
|
|
651
|
+
],
|
|
652
|
+
},
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
exports.FeishuBridge = FeishuBridge;
|
|
657
|
+
/** 单条 base64 图片体积上限:超过则改为落盘走文件通道,避免撑爆 MCP 消息 */
|
|
658
|
+
FeishuBridge.MAX_IMAGE_BASE64 = 8 * 1024 * 1024;
|
|
659
|
+
/** inbound 临时文件保留时长:清理早于此的文件,防止磁盘堆积 */
|
|
660
|
+
FeishuBridge.INBOUND_TTL_MS = 24 * 60 * 60 * 1000;
|
|
661
|
+
/**
|
|
662
|
+
* 入站消息合并窗口:飞书会把「图片 + 文字」「多张图」拆成多条独立消息,
|
|
663
|
+
* 若逐条结算会出现「第一条占用本轮反馈、其余丢失」+ 多次回执。
|
|
664
|
+
* 这里在该窗口内按 chatId 把连续多条合并为一次反馈。
|
|
665
|
+
*/
|
|
666
|
+
FeishuBridge.INBOUND_MERGE_MS = 1200;
|
|
667
|
+
//# sourceMappingURL=feishu.js.map
|