autosnippet 3.2.6 → 3.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.
@@ -0,0 +1,1138 @@
1
+ /**
2
+ * Remote Command Router — 飞书 Bot → IDE 编程桥接
3
+ *
4
+ * 架构(长连接模式):
5
+ * Mac 本地启动 WSClient 长连接 → 飞书推送消息到本机
6
+ * → 解析系统命令 / 写入 remote_commands 队列
7
+ * → VSCode 扩展轮询 GET /pending → 注入 Copilot Chat
8
+ * → 扩展回写 POST /result → 飞书 Bot 回复用户
9
+ *
10
+ * 健壮性:
11
+ * ✓ 飞书 WS 随路由加载自动启动
12
+ * ✓ 系统命令: /help /status /queue /cancel /clear /ping /screen
13
+ * ✓ 超时自动清理(pending 120s / running 600s)
14
+ * ✓ 消息去重 + 非文本提示
15
+ * ✓ SDK Client 回复 + REST 回退
16
+ */
17
+
18
+ import crypto from 'node:crypto';
19
+ import { execSync } from 'node:child_process';
20
+ import { readFileSync, unlinkSync, existsSync, statSync } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import express from 'express';
24
+ import Logger from '../../infrastructure/logging/Logger.js';
25
+ import { getServiceContainer } from '../../injection/ServiceContainer.js';
26
+ import { asyncHandler } from '../middleware/errorHandler.js';
27
+
28
+ const router = express.Router();
29
+ const logger = Logger.getInstance();
30
+
31
+ // ─── 常量 ───────────────────────────────────────────
32
+
33
+ const PENDING_TIMEOUT_SEC = 120; // pending 超过 2 分钟 → timeout
34
+ const RUNNING_TIMEOUT_SEC = 600; // running 超过 10 分钟 → timeout
35
+ const CLEANUP_INTERVAL_MS = 30_000; // 每 30 秒清理一次
36
+
37
+ // ─── 数据库辅助 ─────────────────────────────────────
38
+
39
+ function getDb() {
40
+ const container = getServiceContainer();
41
+ const database = container.get('database');
42
+ return typeof database?.getDb === 'function' ? database.getDb() : database;
43
+ }
44
+
45
+ let _tableReady = false;
46
+ function ensureTable(db) {
47
+ if (_tableReady) return;
48
+ db.exec(`
49
+ CREATE TABLE IF NOT EXISTS remote_commands (
50
+ id TEXT PRIMARY KEY,
51
+ source TEXT NOT NULL DEFAULT 'lark',
52
+ chat_id TEXT,
53
+ message_id TEXT,
54
+ user_id TEXT,
55
+ user_name TEXT,
56
+ command TEXT NOT NULL,
57
+ status TEXT NOT NULL DEFAULT 'pending',
58
+ result TEXT,
59
+ created_at INTEGER NOT NULL,
60
+ claimed_at INTEGER,
61
+ completed_at INTEGER
62
+ )
63
+ `);
64
+ _tableReady = true;
65
+ }
66
+
67
+ function genId() {
68
+ return `rcmd_${Date.now().toString(36)}_${crypto.randomBytes(3).toString('hex')}`;
69
+ }
70
+
71
+ // ─── 飞书配置 ───────────────────────────────────────
72
+
73
+ function getLarkConfig() {
74
+ return {
75
+ appId: process.env.ASD_LARK_APP_ID || '',
76
+ appSecret: process.env.ASD_LARK_APP_SECRET || '',
77
+ verificationToken: process.env.ASD_LARK_VERIFICATION_TOKEN || '',
78
+ encryptKey: process.env.ASD_LARK_ENCRYPT_KEY || '',
79
+ };
80
+ }
81
+
82
+ // ─── 发送者白名单 ──────────────────────────────────
83
+
84
+ /** 允许发送指令的飞书 user_id 列表(逗号分隔) */
85
+ const _allowedUserIds = (process.env.ASD_LARK_ALLOWED_USERS || '')
86
+ .split(',')
87
+ .map(s => s.trim())
88
+ .filter(Boolean);
89
+
90
+ function isUserAllowed(userId) {
91
+ // 未配置白名单 → 放行所有(向后兼容)
92
+ if (_allowedUserIds.length === 0) return true;
93
+ return _allowedUserIds.includes(userId);
94
+ }
95
+
96
+ // ─── 消息去重 ───────────────────────────────────────
97
+
98
+ const _processedMsgIds = new Map();
99
+ const MSG_DEDUP_TTL = 5 * 60 * 1000;
100
+
101
+ function isDuplicate(messageId) {
102
+ if (!messageId) return false;
103
+ if (_processedMsgIds.has(messageId)) return true;
104
+ _processedMsgIds.set(messageId, Date.now());
105
+ if (_processedMsgIds.size > 200) {
106
+ const now = Date.now();
107
+ for (const [id, ts] of _processedMsgIds) {
108
+ if (now - ts > MSG_DEDUP_TTL) _processedMsgIds.delete(id);
109
+ }
110
+ }
111
+ return false;
112
+ }
113
+
114
+ // ═══════════════════════════════════════════════════════
115
+ // 飞书 SDK 长连接
116
+ // ═══════════════════════════════════════════════════════
117
+
118
+ let _wsClient = null;
119
+ let _larkClient = null;
120
+ let _wsConnected = false;
121
+ let _wsStarting = false;
122
+
123
+ async function startLarkWS() {
124
+ // 如果已连接且对象存在 → 直接返回
125
+ if (_wsClient && _wsConnected) return { success: true, message: 'Already connected' };
126
+ if (_wsStarting) return { success: true, message: 'Connection in progress' };
127
+
128
+ // 如果 _wsClient 存在但已断连 → 先清理再重建
129
+ if (_wsClient && !_wsConnected) {
130
+ try { if (typeof _wsClient.close === 'function') _wsClient.close(); } catch {}
131
+ _wsClient = null;
132
+ _larkClient = null;
133
+ }
134
+
135
+ const config = getLarkConfig();
136
+ if (!config.appId || !config.appSecret) {
137
+ return { success: false, message: 'Missing ASD_LARK_APP_ID / ASD_LARK_APP_SECRET' };
138
+ }
139
+
140
+ _wsStarting = true;
141
+ try {
142
+ const lark = await import('@larksuiteoapi/node-sdk');
143
+
144
+ _larkClient = new lark.Client({
145
+ appId: config.appId,
146
+ appSecret: config.appSecret,
147
+ disableTokenCache: false,
148
+ });
149
+
150
+ const eventDispatcher = new lark.EventDispatcher({}).register({
151
+ 'im.message.receive_v1': async (data) => {
152
+ try {
153
+ await handleLarkMessage(data);
154
+ } catch (err) {
155
+ logger.error(`[Remote/Lark] Handler error: ${err.message}`);
156
+ }
157
+ },
158
+ });
159
+
160
+ _wsClient = new lark.WSClient({
161
+ appId: config.appId,
162
+ appSecret: config.appSecret,
163
+ loggerLevel: lark.LoggerLevel?.info ?? 2,
164
+ autoReconnect: true,
165
+ });
166
+
167
+ await _wsClient.start({ eventDispatcher });
168
+ _wsConnected = true;
169
+ _wsStarting = false;
170
+
171
+ // 恢复上次活跃的 chat_id(从数据库)
172
+ _restoreActiveChatId();
173
+
174
+ logger.info('[Remote/Lark] ✅ WebSocket long connection established');
175
+
176
+ // 向飞书发送上线通知(延迟 1 秒确保 chatId 已恢复)
177
+ setTimeout(() => {
178
+ sendLarkNotification([
179
+ '🟢 IDE 桥接已上线',
180
+ `时间: ${new Date().toLocaleString('zh-CN')}`,
181
+ `平台: macOS | Node ${process.version}`,
182
+ '',
183
+ '发送任意文字即可远程编程,/help 查看命令。',
184
+ ].join('\n')).catch(() => {});
185
+ }, 1000);
186
+
187
+ return { success: true, message: 'Connected via WebSocket' };
188
+ } catch (err) {
189
+ _wsClient = null;
190
+ _wsConnected = false;
191
+ _wsStarting = false;
192
+ logger.error(`[Remote/Lark] WSClient start failed: ${err.message}`);
193
+ return { success: false, message: err.message };
194
+ }
195
+ }
196
+
197
+ function stopLarkWS() {
198
+ if (!_wsClient) return { success: true, message: 'Not running' };
199
+ try {
200
+ if (typeof _wsClient.close === 'function') _wsClient.close();
201
+ } catch { /* ignore */ }
202
+ _wsClient = null;
203
+ _larkClient = null;
204
+ _wsConnected = false;
205
+ logger.info('[Remote/Lark] WebSocket connection stopped');
206
+ return { success: true, message: 'Stopped' };
207
+ }
208
+
209
+ // ─── 自动启动(路由加载时) ─────────────────────────
210
+
211
+ const { appId: _autoId, appSecret: _autoSecret } = getLarkConfig();
212
+ if (_autoId && _autoSecret) {
213
+ // 延迟 3 秒启动,等 express/DB 初始化完成
214
+ setTimeout(async () => {
215
+ logger.info('[Remote/Lark] Auto-starting WebSocket connection...');
216
+ const result = await startLarkWS();
217
+ if (!result.success) {
218
+ logger.warn(`[Remote/Lark] Auto-start failed: ${result.message}`);
219
+ }
220
+ }, 3000);
221
+ }
222
+
223
+ // ─── 连接健康检查 & 自动重连 ────────────────────────
224
+
225
+ const HEALTH_CHECK_INTERVAL = 30_000; // 30 秒检查一次
226
+
227
+ setInterval(async () => {
228
+ // 没有凭证 → 跳过
229
+ const cfg = getLarkConfig();
230
+ if (!cfg.appId || !cfg.appSecret) return;
231
+
232
+ // WSClient 对象存在但 SDK 内部可能已断开 → 尝试探活
233
+ if (_wsClient && _wsConnected) {
234
+ // 发一个轻量 API 调用来验证连通性
235
+ try {
236
+ if (_larkClient) {
237
+ await _larkClient.auth.tenantAccessToken.internal({
238
+ data: { app_id: cfg.appId, app_secret: cfg.appSecret },
239
+ });
240
+ }
241
+ // 有响应 → 正常
242
+ return;
243
+ } catch {
244
+ // 调用失败不代表 WS 断了(可能只是 API 暂时不通),保持状态
245
+ return;
246
+ }
247
+ }
248
+
249
+ // WSClient 不存在或已标记断开 → 自动重连
250
+ if (!_wsClient && !_wsStarting) {
251
+ logger.info('[Remote/Lark] Connection lost, auto-reconnecting...');
252
+ const result = await startLarkWS();
253
+ if (result.success) {
254
+ logger.info('[Remote/Lark] ✅ Auto-reconnected successfully');
255
+ // 重连成功通知
256
+ sendLarkNotification('🔄 IDE 桥接重连成功').catch(() => {});
257
+ } else {
258
+ logger.warn(`[Remote/Lark] Auto-reconnect failed: ${result.message}`);
259
+ }
260
+ }
261
+ }, HEALTH_CHECK_INTERVAL);
262
+
263
+ // ─── 超时清理定时器 ─────────────────────────────────
264
+
265
+ setInterval(() => {
266
+ try {
267
+ const db = getDb();
268
+ ensureTable(db);
269
+ const now = Math.floor(Date.now() / 1000);
270
+
271
+ // pending 超时
272
+ const pendingTimeout = db.prepare(
273
+ 'UPDATE remote_commands SET status = ?, completed_at = ? WHERE status = ? AND created_at < ?'
274
+ ).run('timeout', now, 'pending', now - PENDING_TIMEOUT_SEC);
275
+
276
+ // running 超时
277
+ const runningTimeout = db.prepare(
278
+ 'UPDATE remote_commands SET status = ?, completed_at = ? WHERE status = ? AND claimed_at < ?'
279
+ ).run('timeout', now, 'running', now - RUNNING_TIMEOUT_SEC);
280
+
281
+ const total = (pendingTimeout.changes || 0) + (runningTimeout.changes || 0);
282
+ if (total > 0) {
283
+ logger.info(`[Remote] Cleaned ${total} timed-out commands`);
284
+ }
285
+ } catch { /* DB 尚未就绪时静默 */ }
286
+ }, CLEANUP_INTERVAL_MS);
287
+
288
+ // ═══════════════════════════════════════════════════════
289
+ // 系统命令处理
290
+ // ═══════════════════════════════════════════════════════
291
+
292
+ const SYSTEM_COMMANDS = {
293
+ '/help': handleHelp,
294
+ '/status': handleStatus,
295
+ '/check': handleStatus,
296
+ '/queue': handleQueue,
297
+ '/cancel': handleCancel,
298
+ '/clear': handleClear,
299
+ '/ping': handlePing,
300
+ '/screen': handleScreen,
301
+ };
302
+
303
+ function isSystemCommand(text) {
304
+ const cmd = text.split(/\s/)[0].toLowerCase();
305
+ return SYSTEM_COMMANDS[cmd] || null;
306
+ }
307
+
308
+ async function handleHelp(_args, messageId) {
309
+ await replyLark(messageId, [
310
+ '🤖 AutoSnippet 远程编程 — 命令帮助',
311
+ '',
312
+ '直接发送文字 → 注入 Copilot Agent Mode 执行编程',
313
+ '',
314
+ '系统命令:',
315
+ ' /status — 连接诊断 + 队列状态',
316
+ ' /queue — 查看待执行队列',
317
+ ' /cancel — 取消所有 pending 指令',
318
+ ' /clear — 清空历史记录',
319
+ ' /ping — 测试连通性',
320
+ ' /screen — 截取 IDE 画面发到飞书',
321
+ ' /help — 显示此帮助',
322
+ '',
323
+ '💡 远程模式自动开启全局 Auto-Approve,',
324
+ ' Copilot 将自动执行工具调用/编辑/终端操作。',
325
+ ].join('\n'));
326
+ }
327
+
328
+ async function handleStatus(_args, messageId) {
329
+ const lines = ['📊 状态面板', ''];
330
+ const now = Math.floor(Date.now() / 1000);
331
+ let ideOk = false;
332
+
333
+ // 1. 飞书 WebSocket
334
+ lines.push(`① 飞书 WebSocket: ${_wsConnected ? '✅ 已连接' : '❌ 断开'}`);
335
+
336
+ // 2. API 服务器
337
+ lines.push('② API 服务器: ✅ 运行中 (port ' + (process.env.PORT || 3000) + ')');
338
+
339
+ // 3. 活跃会话
340
+ lines.push(`③ 活跃会话: ${_activeChatId ? '✅ ' + _activeChatId.slice(0, 16) + '...' : '⚠️ 无活跃会话'}`);
341
+
342
+ // 4. IDE 扩展
343
+ try {
344
+ const db = getDb();
345
+ ensureTable(db);
346
+
347
+ const hasWaiters = _waiters.size > 0;
348
+ const pollAge = _lastPollAt > 0 ? now - Math.floor(_lastPollAt / 1000) : -1;
349
+
350
+ if (hasWaiters) {
351
+ ideOk = true;
352
+ lines.push('④ IDE 扩展: ✅ 在线 (long-poll 连接中)');
353
+ } else if (pollAge >= 0 && pollAge < 30) {
354
+ ideOk = true;
355
+ lines.push(`④ IDE 扩展: ✅ 活跃 (${pollAge}秒前有心跳)`);
356
+ } else {
357
+ const recentClaim = db.prepare(
358
+ 'SELECT claimed_at FROM remote_commands WHERE claimed_at IS NOT NULL ORDER BY claimed_at DESC LIMIT 1'
359
+ ).get();
360
+ if (recentClaim?.claimed_at && (now - recentClaim.claimed_at) < 120) {
361
+ ideOk = true;
362
+ lines.push(`④ IDE 扩展: ✅ 活跃 (${now - recentClaim.claimed_at}秒前有 claim)`);
363
+ } else {
364
+ lines.push('④ IDE 扩展: ⚠️ 未检测到活跃连接');
365
+ }
366
+ }
367
+
368
+ // 5. 队列
369
+ const counts = {};
370
+ for (const s of ['pending', 'running', 'completed', 'timeout']) {
371
+ counts[s] = db.prepare('SELECT COUNT(*) as c FROM remote_commands WHERE status = ?').get(s)?.c || 0;
372
+ }
373
+ lines.push(`⑤ 队列: ${counts.pending} 待执行 | ${counts.running} 执行中 | ${counts.completed} 已完成 | ${counts.timeout} 超时`);
374
+ } catch (err) {
375
+ lines.push(`④ IDE 扩展: ❓ 查询失败 (${err.message})`);
376
+ lines.push('⑤ 队列: ❓ 查询失败');
377
+ }
378
+
379
+ // 6. 通知通道
380
+ lines.push(`⑥ 通知通道: ${isLarkNotificationReady() ? '✅ 就绪' : '❌ 未就绪'}`);
381
+
382
+ // 总结
383
+ const allGood = _wsConnected && _activeChatId && ideOk && isLarkNotificationReady();
384
+ lines.push('');
385
+ lines.push(allGood ? '🟢 全链路正常,可以远程编程!' : '🟡 部分链路异常,请检查上方标记。');
386
+
387
+ await replyLark(messageId, lines.join('\n'));
388
+ }
389
+
390
+ async function handleQueue(_args, messageId) {
391
+ try {
392
+ const db = getDb();
393
+ ensureTable(db);
394
+ const rows = db.prepare(
395
+ "SELECT id, command, status, created_at FROM remote_commands WHERE status IN ('pending', 'running') ORDER BY created_at ASC LIMIT 10"
396
+ ).all();
397
+
398
+ if (rows.length === 0) {
399
+ await replyLark(messageId, '📋 队列为空,没有待执行的指令。');
400
+ return;
401
+ }
402
+
403
+ const lines = rows.map((r, i) => {
404
+ const icon = r.status === 'running' ? '🔄' : '⏳';
405
+ const cmd = r.command.length > 40 ? r.command.slice(0, 40) + '...' : r.command;
406
+ return `${i + 1}. ${icon} ${cmd} (${r.id.slice(-8)})`;
407
+ });
408
+
409
+ await replyLark(messageId, `📋 当前队列 (${rows.length} 条)\n\n${lines.join('\n')}`);
410
+ } catch (err) {
411
+ await replyLark(messageId, `❌ 查询失败: ${err.message}`);
412
+ }
413
+ }
414
+
415
+ async function handleCancel(_args, messageId) {
416
+ try {
417
+ const db = getDb();
418
+ ensureTable(db);
419
+ const now = Math.floor(Date.now() / 1000);
420
+ const result = db.prepare(
421
+ 'UPDATE remote_commands SET status = ?, completed_at = ? WHERE status = ?'
422
+ ).run('cancelled', now, 'pending');
423
+ await replyLark(messageId, `🗑 已取消 ${result.changes} 条待执行指令。`);
424
+ } catch (err) {
425
+ await replyLark(messageId, `❌ 取消失败: ${err.message}`);
426
+ }
427
+ }
428
+
429
+ async function handleClear(_args, messageId) {
430
+ try {
431
+ const db = getDb();
432
+ ensureTable(db);
433
+ const result = db.prepare(
434
+ "DELETE FROM remote_commands WHERE status IN ('completed', 'timeout', 'cancelled')"
435
+ ).run();
436
+ await replyLark(messageId, `🧹 已清理 ${result.changes} 条历史记录。`);
437
+ } catch (err) {
438
+ await replyLark(messageId, `❌ 清理失败: ${err.message}`);
439
+ }
440
+ }
441
+
442
+ async function handlePing(_args, messageId) {
443
+ await replyLark(messageId, `🏓 pong! (${new Date().toLocaleTimeString('zh-CN')})`);
444
+ }
445
+
446
+ /**
447
+ * /screen — 截取 IDE 窗口截图并发送到飞书
448
+ */
449
+ async function handleScreen(_args, messageId) {
450
+ await replyLark(messageId, '📸 正在截取 IDE 画面...');
451
+ try {
452
+ const result = await sendLarkScreenshot('');
453
+ if (!result.success) {
454
+ await replyLark(messageId, `❌ 截图失败: ${result.message}`);
455
+ }
456
+ // 成功时 sendLarkScreenshot 已自动发送图片消息
457
+ } catch (err) {
458
+ await replyLark(messageId, `❌ 截图异常: ${err.message}`);
459
+ }
460
+ }
461
+
462
+ // ═══════════════════════════════════════════════════════
463
+ // 飞书消息处理
464
+ // ═══════════════════════════════════════════════════════
465
+
466
+ async function handleLarkMessage(data) {
467
+ const message = data?.message || data?.event?.message || {};
468
+ const sender = data?.sender || data?.event?.sender || {};
469
+ const messageId = message.message_id;
470
+ const chatId = message.chat_id;
471
+ const msgType = message.message_type;
472
+
473
+ if (isDuplicate(messageId)) return;
474
+
475
+ // ── 发送者白名单校验 ──
476
+ const senderId = sender.sender_id?.user_id || sender.sender_id?.open_id || '';
477
+ if (!isUserAllowed(senderId)) {
478
+ logger.warn(`[Remote/Lark] Blocked unauthorized user: ${senderId}`);
479
+ await replyLark(messageId, '🔒 权限不足,你不在授权用户列表中。');
480
+ return;
481
+ }
482
+
483
+ if (msgType !== 'text') {
484
+ await replyLark(messageId, '🤖 目前只支持文本指令。\n发 /help 查看帮助。');
485
+ return;
486
+ }
487
+
488
+ let textContent = '';
489
+ try {
490
+ const content = JSON.parse(message.content || '{}');
491
+ textContent = (content.text || '').trim();
492
+ } catch {
493
+ textContent = '';
494
+ }
495
+ if (!textContent) return;
496
+
497
+ textContent = textContent.replace(/@_user_\d+/g, '').trim();
498
+ if (!textContent) return;
499
+
500
+ // ── 系统命令拦截 ──
501
+ const sysHandler = isSystemCommand(textContent);
502
+ if (sysHandler) {
503
+ const args = textContent.split(/\s+/).slice(1).join(' ');
504
+ await sysHandler(args, messageId);
505
+ return;
506
+ }
507
+
508
+ // ── 写入编程指令队列 ──
509
+ const db = getDb();
510
+ ensureTable(db);
511
+ const id = genId();
512
+ const now = Math.floor(Date.now() / 1000);
513
+
514
+ const userId = sender.sender_id?.user_id || sender.sender_id?.open_id || '';
515
+ const userName = sender.sender_id?.user_id || 'lark_user';
516
+
517
+ // 记录活跃会话(供主动通知使用)
518
+ if (chatId) {
519
+ _activeChatId = chatId;
520
+ _persistActiveChatId(chatId);
521
+ }
522
+
523
+ db.prepare(`
524
+ INSERT INTO remote_commands (id, source, chat_id, message_id, user_id, user_name, command, status, created_at)
525
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)
526
+ `).run(id, 'lark', chatId || '', messageId || '', userId, userName, textContent, now);
527
+
528
+ logger.info(`[Remote/Lark] Command queued: ${id} — "${textContent.slice(0, 50)}"`);
529
+
530
+ // 立即唤醒 long-poll 等待中的扩展端
531
+ wakeWaiters();
532
+
533
+ // 查当前队列深度
534
+ const queueDepth = db.prepare("SELECT COUNT(*) as c FROM remote_commands WHERE status IN ('pending', 'running')").get()?.c || 1;
535
+ const queueInfo = queueDepth > 1 ? `\n\n当前队列: ${queueDepth} 条指令` : '';
536
+
537
+ await replyLark(messageId, `📝 收到,已加入执行队列。${queueInfo}`);
538
+ }
539
+
540
+ // ═══════════════════════════════════════════════════════
541
+ // 飞书连接管理端点
542
+ // ═══════════════════════════════════════════════════════
543
+
544
+ router.post('/lark/start', asyncHandler(async (_req, res) => {
545
+ res.json(await startLarkWS());
546
+ }));
547
+
548
+ router.post('/lark/stop', asyncHandler(async (_req, res) => {
549
+ res.json(stopLarkWS());
550
+ }));
551
+
552
+ router.get('/lark/status', asyncHandler(async (_req, res) => {
553
+ const config = getLarkConfig();
554
+ let queueInfo = {};
555
+ try {
556
+ const db = getDb();
557
+ ensureTable(db);
558
+ for (const s of ['pending', 'running', 'completed', 'timeout']) {
559
+ queueInfo[s] = db.prepare('SELECT COUNT(*) as c FROM remote_commands WHERE status = ?').get(s)?.c || 0;
560
+ }
561
+ } catch { /* DB 未就绪 */ }
562
+
563
+ res.json({
564
+ success: true,
565
+ data: {
566
+ connected: _wsConnected,
567
+ hasCredentials: !!(config.appId && config.appSecret),
568
+ appId: config.appId ? `${config.appId.slice(0, 8)}...` : '',
569
+ activeChatId: _activeChatId ? `${_activeChatId.slice(0, 12)}...` : '',
570
+ notificationReady: isLarkNotificationReady(),
571
+ queue: queueInfo,
572
+ },
573
+ });
574
+ }));
575
+
576
+ // ═══════════════════════════════════════════════════════
577
+ // 飞书 Webhook 回调(备用)
578
+ // ═══════════════════════════════════════════════════════
579
+
580
+ router.post('/lark/event', asyncHandler(async (req, res) => {
581
+ const body = req.body;
582
+ if (body.type === 'url_verification') {
583
+ return res.json({ challenge: body.challenge });
584
+ }
585
+ const header = body.header || {};
586
+ const event = body.event || {};
587
+ const larkConfig = getLarkConfig();
588
+ if (larkConfig.verificationToken && header.token !== larkConfig.verificationToken) {
589
+ return res.status(403).json({ success: false, message: 'Invalid token' });
590
+ }
591
+ if (header.event_type === 'im.message.receive_v1') {
592
+ await handleLarkMessage(event);
593
+ }
594
+ res.json({ success: true });
595
+ }));
596
+
597
+ // ═══════════════════════════════════════════════════════
598
+ // VSCode 扩展 API
599
+ // ═══════════════════════════════════════════════════════
600
+
601
+ router.get('/pending', asyncHandler(async (_req, res) => {
602
+ _lastPollAt = Date.now();
603
+ const db = getDb();
604
+ ensureTable(db);
605
+ const row = db.prepare(
606
+ 'SELECT * FROM remote_commands WHERE status = ? ORDER BY created_at ASC LIMIT 1'
607
+ ).get('pending');
608
+ res.json({
609
+ success: true,
610
+ data: row ? { id: row.id, command: row.command, source: row.source, userName: row.user_name, messageId: row.message_id, createdAt: row.created_at } : null,
611
+ });
612
+ }));
613
+
614
+ router.post('/claim/:id', asyncHandler(async (req, res) => {
615
+ const { id } = req.params;
616
+ const db = getDb();
617
+ ensureTable(db);
618
+ const result = db.prepare(
619
+ 'UPDATE remote_commands SET status = ?, claimed_at = ? WHERE id = ? AND status = ?'
620
+ ).run('running', Math.floor(Date.now() / 1000), id, 'pending');
621
+ if (result.changes === 0) {
622
+ return res.json({ success: false, message: 'Not found or already claimed' });
623
+ }
624
+ // 通知飞书用户:IDE 已开始执行
625
+ const row = db.prepare('SELECT message_id, command FROM remote_commands WHERE id = ?').get(id);
626
+ if (row?.message_id) {
627
+ replyLark(row.message_id, `🚀 IDE 已开始执行...\n\n> ${(row.command || '').slice(0, 60)}`).catch(() => {});
628
+ }
629
+ res.json({ success: true });
630
+ }));
631
+
632
+ router.post('/result/:id', asyncHandler(async (req, res) => {
633
+ const { id } = req.params;
634
+ const { result, status = 'completed' } = req.body;
635
+ const db = getDb();
636
+ ensureTable(db);
637
+ const row = db.prepare('SELECT * FROM remote_commands WHERE id = ?').get(id);
638
+ if (!row) return res.json({ success: false, message: 'Not found' });
639
+
640
+ db.prepare(
641
+ 'UPDATE remote_commands SET status = ?, result = ?, completed_at = ? WHERE id = ?'
642
+ ).run(status, result || '', Math.floor(Date.now() / 1000), id);
643
+
644
+ // 回复飞书
645
+ if (row.message_id && result) {
646
+ const truncated = result.length > 2000
647
+ ? result.slice(0, 2000) + '\n\n... (截断)'
648
+ : result;
649
+ if (status === 'completed') {
650
+ await replyLark(row.message_id, truncated);
651
+ } else {
652
+ const emoji = status === 'failed' ? '❌' : '⚠️';
653
+ const label = status === 'failed' ? '执行失败' : status;
654
+ await replyLark(row.message_id, `${emoji} ${label}\n\n${truncated}`);
655
+ }
656
+ }
657
+ res.json({ success: true });
658
+ }));
659
+
660
+ router.get('/history', asyncHandler(async (req, res) => {
661
+ const db = getDb();
662
+ ensureTable(db);
663
+ const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100);
664
+ const rows = db.prepare('SELECT * FROM remote_commands ORDER BY created_at DESC LIMIT ?').all(limit);
665
+ res.json({ success: true, data: rows });
666
+ }));
667
+
668
+ // ═══════════════════════════════════════════════════════
669
+ // Long-Poll — 新消息到达时立即唤醒扩展端
670
+ // ═══════════════════════════════════════════════════════
671
+
672
+ /** 等待新消息的 resolve 回调队列 */
673
+ const _waiters = new Set();
674
+
675
+ /** IDE 扩展最后一次轮询/连接时间戳(用于 /check 诊断) */
676
+ let _lastPollAt = 0;
677
+
678
+ /**
679
+ * 唤醒所有等待中的 long-poll 客户端
680
+ * 在 handleLarkMessage 写入新指令后调用
681
+ */
682
+ function wakeWaiters() {
683
+ for (const resolve of _waiters) {
684
+ resolve({ hasNew: true });
685
+ }
686
+ _waiters.clear();
687
+ }
688
+
689
+ router.get('/wait', (req, res) => {
690
+ _lastPollAt = Date.now();
691
+ const timeout = Math.min(parseInt(req.query.timeout) || 25000, 60000);
692
+ let resolved = false;
693
+
694
+ const resolve = (data) => {
695
+ if (resolved) return;
696
+ resolved = true;
697
+ _waiters.delete(resolve);
698
+ clearTimeout(timer);
699
+ res.json(data);
700
+ };
701
+
702
+ const timer = setTimeout(() => resolve({ hasNew: false }), timeout);
703
+ _waiters.add(resolve);
704
+
705
+ // 客户端断开时清理
706
+ req.on('close', () => {
707
+ if (!resolved) {
708
+ resolved = true;
709
+ _waiters.delete(resolve);
710
+ clearTimeout(timer);
711
+ }
712
+ });
713
+ });
714
+
715
+ // POST /flush — IDE 重连时清理所有积压的 pending 指令
716
+ router.post('/flush', asyncHandler(async (req, res) => {
717
+ const db = getDb();
718
+ ensureTable(db);
719
+
720
+ // 查出所有 pending 指令的摘要
721
+ const pending = db.prepare(
722
+ "SELECT id, command, created_at FROM remote_commands WHERE status = 'pending' ORDER BY created_at ASC"
723
+ ).all();
724
+
725
+ if (pending.length === 0) {
726
+ return res.json({ success: true, flushed: 0, commands: [] });
727
+ }
728
+
729
+ // 批量标记为 cancelled
730
+ const now = Math.floor(Date.now() / 1000);
731
+ db.prepare(
732
+ "UPDATE remote_commands SET status = 'cancelled', result = '🗑 IDE 重连时自动清理(积压指令)', completed_at = ? WHERE status = 'pending'"
733
+ ).run(now);
734
+
735
+ const summaries = pending.map(r => ({
736
+ id: r.id,
737
+ command: r.command?.slice(0, 60) || '',
738
+ age: now - r.created_at,
739
+ }));
740
+
741
+ logger.info(`[Remote] Flushed ${pending.length} stale pending commands on IDE reconnect`);
742
+
743
+ // 飞书通知
744
+ const lines = summaries.map((s, i) =>
745
+ ` ${i + 1}. ${s.command}${s.command.length >= 60 ? '…' : ''} (${s.age}s ago)`
746
+ );
747
+ sendLarkNotification(
748
+ `🗑 IDE 重连,已清理 ${pending.length} 条积压指令:\n${lines.join('\n')}`
749
+ ).catch(() => {});
750
+
751
+ res.json({ success: true, flushed: pending.length, commands: summaries });
752
+ }));
753
+
754
+ router.post('/send', asyncHandler(async (req, res) => {
755
+ const { command } = req.body;
756
+ if (!command?.trim()) return res.status(400).json({ success: false, message: 'command required' });
757
+ const db = getDb();
758
+ ensureTable(db);
759
+ const id = genId();
760
+ db.prepare('INSERT INTO remote_commands (id, source, command, status, user_name, created_at) VALUES (?, ?, ?, ?, ?, ?)')
761
+ .run(id, 'manual', command.trim(), 'pending', 'developer', Math.floor(Date.now() / 1000));
762
+ res.json({ success: true, data: { id, command: command.trim() } });
763
+ }));
764
+
765
+ // POST /api/v1/remote/notify — 通用通知(扩展/外部模块主动推送飞书)
766
+ router.post('/notify', asyncHandler(async (req, res) => {
767
+ const { text } = req.body;
768
+ if (!text?.trim()) return res.status(400).json({ success: false, message: 'text required' });
769
+ const sent = await sendLarkNotification(text.trim());
770
+ res.json({ success: sent, message: sent ? 'Sent' : 'Lark not connected or no active chat' });
771
+ }));
772
+
773
+ // POST /api/v1/remote/screenshot — 截取 IDE 窗口并发送到飞书
774
+ router.post('/screenshot', asyncHandler(async (req, res) => {
775
+ const { caption } = req.body || {};
776
+ const result = await sendLarkScreenshot(caption || '');
777
+ res.json(result);
778
+ }));
779
+
780
+ // ═══════════════════════════════════════════════════════
781
+ // 飞书回复辅助
782
+ // ═══════════════════════════════════════════════════════
783
+
784
+ let _tenantToken = '';
785
+ let _tenantTokenExpiry = 0;
786
+
787
+ async function getTenantToken() {
788
+ if (_tenantToken && Date.now() < _tenantTokenExpiry) return _tenantToken;
789
+ const config = getLarkConfig();
790
+ if (!config.appId || !config.appSecret) return '';
791
+ try {
792
+ const resp = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
793
+ method: 'POST',
794
+ headers: { 'Content-Type': 'application/json' },
795
+ body: JSON.stringify({ app_id: config.appId, app_secret: config.appSecret }),
796
+ });
797
+ const data = await resp.json();
798
+ if (data.code === 0 && data.tenant_access_token) {
799
+ _tenantToken = data.tenant_access_token;
800
+ _tenantTokenExpiry = Date.now() + (data.expire - 300) * 1000;
801
+ return _tenantToken;
802
+ }
803
+ return '';
804
+ } catch { return ''; }
805
+ }
806
+
807
+ async function replyLark(messageId, text) {
808
+ if (!messageId) return;
809
+
810
+ // SDK Client 优先
811
+ if (_larkClient) {
812
+ try {
813
+ await _larkClient.im.message.reply({
814
+ path: { message_id: messageId },
815
+ data: { content: JSON.stringify({ text }), msg_type: 'text' },
816
+ });
817
+ return;
818
+ } catch (err) {
819
+ logger.warn(`[Remote/Lark] SDK reply failed: ${err.message}`);
820
+ }
821
+ }
822
+
823
+ // REST 回退
824
+ const token = await getTenantToken();
825
+ if (!token) return;
826
+ try {
827
+ await fetch(`https://open.feishu.cn/open-apis/im/v1/messages/${messageId}/reply`, {
828
+ method: 'POST',
829
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
830
+ body: JSON.stringify({ content: JSON.stringify({ text }), msg_type: 'text' }),
831
+ });
832
+ } catch { /* silent */ }
833
+ }
834
+
835
+ // ═══════════════════════════════════════════════════════
836
+ // IDE 窗口截图 + 飞书发送
837
+ // ═══════════════════════════════════════════════════════
838
+
839
+ /**
840
+ * 获取 VSCode 主窗口 CGWindowID(macOS only)
841
+ * 通过 swift 调用 CoreGraphics API 找到 owner 包含 "Code" 且面积最大的窗口
842
+ */
843
+ function _getVSCodeWindowId() {
844
+ try {
845
+ const script = `
846
+ import CoreGraphics
847
+ let list = CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID) as! [[String: Any]]
848
+ var best = 0, bestArea = 0
849
+ for w in list {
850
+ guard let o = w["kCGWindowOwnerName"] as? String, o.contains("Code"),
851
+ let b = w["kCGWindowBounds"] as? [String: Double],
852
+ let width = b["Width"], let height = b["Height"],
853
+ let id = w["kCGWindowNumber"] as? Int,
854
+ width > 100, height > 100 else { continue }
855
+ let area = Int(width * height)
856
+ if area > bestArea { bestArea = area; best = id }
857
+ }
858
+ print(best)
859
+ `;
860
+ const out = execSync(`swift -e '${script.replace(/'/g, "'\\''")}'`, {
861
+ timeout: 10000,
862
+ encoding: 'utf-8',
863
+ stdio: ['pipe', 'pipe', 'pipe'],
864
+ }).trim();
865
+ const wid = parseInt(out, 10);
866
+ logger.info(`[Remote/Screenshot] VSCode windowId: ${wid}`);
867
+ return wid > 0 ? wid : 0;
868
+ } catch (err) {
869
+ const stderr = err.stderr ? err.stderr.toString().slice(0, 200) : '';
870
+ logger.warn(`[Remote/Screenshot] Failed to get VSCode windowId: ${err.message}${stderr ? ' | stderr: ' + stderr : ''}`);
871
+ return 0;
872
+ }
873
+ }
874
+
875
+ /**
876
+ * 截取 IDE 窗口截图
877
+ * @returns {{ path: string|null, error: string|null }} 临时图片文件路径,或错误信息
878
+ */
879
+ function captureIDEScreenshot() {
880
+ const tmpFile = join(tmpdir(), `asd-screenshot-${Date.now()}.jpg`);
881
+
882
+ // ── 尝试方案列表(按优先级)──
883
+ const attempts = [];
884
+
885
+ try {
886
+ const wid = _getVSCodeWindowId();
887
+ if (wid > 0) {
888
+ attempts.push({ label: 'window', cmd: `screencapture -x -t jpg -l${wid} "${tmpFile}"` });
889
+ }
890
+ } catch { /* swift failed, skip window capture */ }
891
+
892
+ // 全屏截图作为回退
893
+ attempts.push({ label: 'fullscreen', cmd: `screencapture -x -t jpg "${tmpFile}"` });
894
+
895
+ for (const { label, cmd } of attempts) {
896
+ try {
897
+ logger.info(`[Remote/Screenshot] Trying ${label}: ${cmd}`);
898
+ execSync(cmd, { timeout: 8000, stdio: ['pipe', 'pipe', 'pipe'] });
899
+ if (existsSync(tmpFile)) {
900
+ const { size } = statSync(tmpFile);
901
+ if (size > 0) {
902
+ logger.info(`[Remote/Screenshot] Captured via ${label}: ${tmpFile} (${size} bytes)`);
903
+ return { path: tmpFile, error: null };
904
+ }
905
+ // 文件存在但为空
906
+ logger.warn(`[Remote/Screenshot] ${label}: file created but empty`);
907
+ try { unlinkSync(tmpFile); } catch { /* ignore */ }
908
+ } else {
909
+ logger.warn(`[Remote/Screenshot] ${label}: file not created`);
910
+ }
911
+ } catch (err) {
912
+ const stderr = err.stderr ? err.stderr.toString().slice(0, 300) : '';
913
+ const detail = `${err.message}${stderr ? ' | stderr: ' + stderr : ''}`;
914
+ logger.warn(`[Remote/Screenshot] ${label} failed: ${detail}`);
915
+ // 继续尝试下一种方案
916
+ }
917
+ }
918
+
919
+ return { path: null, error: '所有截图方案均失败。请在「系统设置 → 隐私与安全性 → 屏幕录制」中授权启动 asd ui 的终端应用(如 iTerm2、Terminal.app)。' };
920
+ }
921
+
922
+ /**
923
+ * 上传图片到飞书 Image API
924
+ * @param {string} filePath — 本地图片路径
925
+ * @returns {Promise<{imageKey: string|null, error: string|null}>}
926
+ */
927
+ async function _uploadImageToLark(filePath) {
928
+ const token = await getTenantToken();
929
+ if (!token) return { imageKey: null, error: '获取 tenant_access_token 失败' };
930
+ try {
931
+ const fileData = readFileSync(filePath);
932
+ const blob = new Blob([fileData], { type: 'image/jpeg' });
933
+ const form = new FormData();
934
+ form.append('image_type', 'message');
935
+ form.append('image', blob, 'screenshot.jpg');
936
+
937
+ const resp = await fetch('https://open.feishu.cn/open-apis/im/v1/images', {
938
+ method: 'POST',
939
+ headers: { Authorization: `Bearer ${token}` },
940
+ body: form,
941
+ });
942
+ const data = await resp.json();
943
+ if (data.code === 0 && data.data?.image_key) {
944
+ return { imageKey: data.data.image_key, error: null };
945
+ }
946
+ const errMsg = `飞书图片上传失败 (code=${data.code}): ${data.msg || '未知错误'}`;
947
+ logger.warn(`[Remote/Screenshot] Upload failed: code=${data.code} msg=${data.msg}`);
948
+ return { imageKey: null, error: errMsg };
949
+ } catch (err) {
950
+ logger.warn(`[Remote/Screenshot] Upload error: ${err.message}`);
951
+ return { imageKey: null, error: `上传异常: ${err.message}` };
952
+ }
953
+ }
954
+
955
+ /**
956
+ * 向飞书发送图片消息
957
+ * @param {string} imageKey
958
+ * @returns {Promise<boolean>}
959
+ */
960
+ async function _sendLarkImageMsg(imageKey) {
961
+ if (!_activeChatId || !_wsConnected) return false;
962
+
963
+ // SDK Client 优先
964
+ if (_larkClient) {
965
+ try {
966
+ await _larkClient.im.message.create({
967
+ params: { receive_id_type: 'chat_id' },
968
+ data: {
969
+ receive_id: _activeChatId,
970
+ content: JSON.stringify({ image_key: imageKey }),
971
+ msg_type: 'image',
972
+ },
973
+ });
974
+ return true;
975
+ } catch (err) {
976
+ logger.warn(`[Remote/Screenshot] SDK image send failed: ${err.message}`);
977
+ }
978
+ }
979
+
980
+ // REST 回退
981
+ const token = await getTenantToken();
982
+ if (!token) return false;
983
+ try {
984
+ const resp = await fetch('https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id', {
985
+ method: 'POST',
986
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
987
+ body: JSON.stringify({
988
+ receive_id: _activeChatId,
989
+ content: JSON.stringify({ image_key: imageKey }),
990
+ msg_type: 'image',
991
+ }),
992
+ });
993
+ const data = await resp.json();
994
+ return data.code === 0;
995
+ } catch {
996
+ return false;
997
+ }
998
+ }
999
+
1000
+ /**
1001
+ * 截取 IDE 窗口 → 上传飞书 → 发送图片消息(完整流水线)
1002
+ * @param {string} [caption] — 可选文字说明(会先发一条文本)
1003
+ * @returns {Promise<{success: boolean, message: string}>}
1004
+ */
1005
+ export async function sendLarkScreenshot(caption = '') {
1006
+ if (!_activeChatId || !_wsConnected) {
1007
+ return { success: false, message: 'Lark not connected or no active chat' };
1008
+ }
1009
+
1010
+ // 1. 截图
1011
+ const capture = captureIDEScreenshot();
1012
+ if (!capture.path) {
1013
+ return { success: false, message: capture.error || 'Screenshot capture failed' };
1014
+ }
1015
+
1016
+ const filePath = capture.path;
1017
+ try {
1018
+ // 2. 可选:先发文字说明
1019
+ if (caption.trim()) {
1020
+ await sendLarkNotification(caption.trim());
1021
+ }
1022
+
1023
+ // 3. 上传
1024
+ const upload = await _uploadImageToLark(filePath);
1025
+ if (!upload.imageKey) {
1026
+ return { success: false, message: upload.error || 'Image upload to Lark failed' };
1027
+ }
1028
+
1029
+ // 4. 发送图片消息
1030
+ const sent = await _sendLarkImageMsg(upload.imageKey);
1031
+ return {
1032
+ success: sent,
1033
+ message: sent ? 'Screenshot sent' : 'Failed to send image message',
1034
+ };
1035
+ } finally {
1036
+ // 清理临时文件
1037
+ try { unlinkSync(filePath); } catch { /* ignore */ }
1038
+ }
1039
+ }
1040
+
1041
+ // ═══════════════════════════════════════════════════════
1042
+ // 主动通知能力(供 task.js 等外部模块调用)
1043
+ // ═══════════════════════════════════════════════════════
1044
+
1045
+ /** 最近活跃的飞书 chat_id(收到消息时更新) */
1046
+ let _activeChatId = '';
1047
+
1048
+ /** 持久化 active chat_id 到数据库 */
1049
+ function _persistActiveChatId(chatId) {
1050
+ try {
1051
+ const db = getDb();
1052
+ db.exec(`CREATE TABLE IF NOT EXISTS remote_state (key TEXT PRIMARY KEY, value TEXT, updated_at INTEGER)`);
1053
+ db.prepare('INSERT OR REPLACE INTO remote_state (key, value, updated_at) VALUES (?, ?, ?)')
1054
+ .run('active_chat_id', chatId, Math.floor(Date.now() / 1000));
1055
+ } catch { /* DB 未就绪 */ }
1056
+ }
1057
+
1058
+ /** 从数据库恢复 active chat_id */
1059
+ function _restoreActiveChatId() {
1060
+ try {
1061
+ const db = getDb();
1062
+ db.exec(`CREATE TABLE IF NOT EXISTS remote_state (key TEXT PRIMARY KEY, value TEXT, updated_at INTEGER)`);
1063
+
1064
+ // 优先从 remote_state 恢复
1065
+ const row = db.prepare('SELECT value FROM remote_state WHERE key = ?').get('active_chat_id');
1066
+ if (row?.value) {
1067
+ _activeChatId = row.value;
1068
+ logger.info(`[Remote/Lark] Restored active chat from state: ${_activeChatId.slice(0, 12)}...`);
1069
+ return;
1070
+ }
1071
+
1072
+ // 回退:从 remote_commands 取最近有 chat_id 的记录
1073
+ const cmdRow = db.prepare(
1074
+ "SELECT chat_id FROM remote_commands WHERE chat_id != '' ORDER BY created_at DESC LIMIT 1"
1075
+ ).get();
1076
+ if (cmdRow?.chat_id) {
1077
+ _activeChatId = cmdRow.chat_id;
1078
+ _persistActiveChatId(cmdRow.chat_id);
1079
+ logger.info(`[Remote/Lark] Restored active chat from history: ${_activeChatId.slice(0, 12)}...`);
1080
+ }
1081
+ } catch { /* DB 未就绪 */ }
1082
+ }
1083
+
1084
+ /**
1085
+ * 向飞书活跃会话发送主动通知(非回复)
1086
+ * 用于任务进度、Guard 结果等非指令触发的通知
1087
+ *
1088
+ * @param {string} text — 纯文本通知内容
1089
+ * @returns {Promise<boolean>} — 发送是否成功
1090
+ */
1091
+ export async function sendLarkNotification(text) {
1092
+ if (!_activeChatId || !_wsConnected) return false;
1093
+
1094
+ // SDK Client 优先
1095
+ if (_larkClient) {
1096
+ try {
1097
+ await _larkClient.im.message.create({
1098
+ params: { receive_id_type: 'chat_id' },
1099
+ data: {
1100
+ receive_id: _activeChatId,
1101
+ content: JSON.stringify({ text }),
1102
+ msg_type: 'text',
1103
+ },
1104
+ });
1105
+ return true;
1106
+ } catch (err) {
1107
+ logger.warn(`[Remote/Lark] SDK send failed: ${err.message}`);
1108
+ }
1109
+ }
1110
+
1111
+ // REST 回退
1112
+ const token = await getTenantToken();
1113
+ if (!token) return false;
1114
+ try {
1115
+ const resp = await fetch('https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id', {
1116
+ method: 'POST',
1117
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
1118
+ body: JSON.stringify({
1119
+ receive_id: _activeChatId,
1120
+ content: JSON.stringify({ text }),
1121
+ msg_type: 'text',
1122
+ }),
1123
+ });
1124
+ const data = await resp.json();
1125
+ return data.code === 0;
1126
+ } catch {
1127
+ return false;
1128
+ }
1129
+ }
1130
+
1131
+ /**
1132
+ * 查询飞书通知是否可用
1133
+ */
1134
+ export function isLarkNotificationReady() {
1135
+ return !!(_activeChatId && _wsConnected);
1136
+ }
1137
+
1138
+ export default router;