UNPKG

autosnippet

Version:

Extract code patterns into a knowledge base for AI coding assistants

953 lines (952 loc) 38 kB
/** * Remote Command Router — 飞书 Bot ↔ IDE 编程桥接 + AI Agent 知识管理 * * 架构(自然语言路由模式 v2): * 飞书消息 → LarkTransport → IntentClassifier 意图分类 * → bot_agent: 知识管理任务 → AgentRuntime 直接处理 → 飞书回复 * → ide_agent: 编程任务 → remote_commands 队列 → VSCode 扩展 → Copilot Chat * → system: 状态/截图 → 本地直接处理 * * 设计原则: * ✓ 零命令交互 — 全部使用自然语言,AI 自动判断意图 * ✓ 双 Agent 分流 — 知识任务服务端处理,编程任务转发 IDE * ✓ 飞书 WS 随路由加载自动启动 * ✓ 超时自动清理(pending 120s / running 600s) * ✓ 消息去重 + 非文本提示 * ✓ SDK Client 回复 + REST 回退 */ import crypto from 'node:crypto'; import { readFileSync, unlinkSync } from 'node:fs'; import express from 'express'; import { LarkTransport } from '../../external/lark/LarkTransport.js'; import Logger from '../../infrastructure/logging/Logger.js'; import { getServiceContainer } from '../../injection/ServiceContainer.js'; import { resolveProjectRoot } from '../../shared/resolveProjectRoot.js'; import { RemoteHistoryQuery, RemoteNotifyBody, RemoteResultBody, RemoteSendBody, } from '../../shared/schemas/http-requests.js'; import { validate, validateQuery } from '../middleware/validate.js'; const router = express.Router(); const logger = Logger.getInstance(); // ─── 常量 ─────────────────────────────────────────── const PENDING_TIMEOUT_SEC = 120; // pending 超过 2 分钟 → timeout const RUNNING_TIMEOUT_SEC = 600; // running 超过 10 分钟 → timeout const CLEANUP_INTERVAL_MS = 30_000; // 每 30 秒清理一次 // ─── 数据库辅助 ───────────────────────────────────── /** 从 DI 容器获取 RemoteCommandRepository */ function getRepo() { const container = getServiceContainer(); return container.get('remoteCommandRepository'); } function genId() { return `rcmd_${Date.now().toString(36)}_${crypto.randomBytes(3).toString('hex')}`; } // ─── 飞书配置 ─────────────────────────────────────── function getLarkConfig() { return { appId: process.env.ASD_LARK_APP_ID || '', appSecret: process.env.ASD_LARK_APP_SECRET || '', verificationToken: process.env.ASD_LARK_VERIFICATION_TOKEN || '', encryptKey: process.env.ASD_LARK_ENCRYPT_KEY || '', }; } // ─── 发送者白名单 ────────────────────────────────── /** 允许发送指令的飞书 user_id 列表(逗号分隔) */ const _allowedUserIds = (process.env.ASD_LARK_ALLOWED_USERS || '') .split(',') .map((s) => s.trim()) .filter(Boolean); function isUserAllowed(userId) { // 未配置白名单 → 放行所有(向后兼容) if (_allowedUserIds.length === 0) { return true; } return _allowedUserIds.includes(userId); } // ─── 消息去重 ─────────────────────────────────────── const _processedMsgIds = new Map(); const MSG_DEDUP_TTL = 5 * 60 * 1000; function isDuplicate(messageId) { if (!messageId) { return false; } if (_processedMsgIds.has(messageId)) { return true; } _processedMsgIds.set(messageId, Date.now()); if (_processedMsgIds.size > 200) { const now = Date.now(); for (const [id, ts] of _processedMsgIds) { if (now - ts > MSG_DEDUP_TTL) { _processedMsgIds.delete(id); } } } return false; } // ═══════════════════════════════════════════════════════ // 飞书 SDK 长连接 // ═══════════════════════════════════════════════════════ let _wsClient = null; let _larkClient = null; let _wsConnected = false; let _wsStarting = false; /** * Lark SDK 日志适配器 — 将 SDK 内部的 console 输出路由到项目统一 Logger * 替代 SDK 默认的 console.log 直出,日志格式与项目其他模块保持一致。 * 前缀: [Remote/Lark/SDK] */ const larkSdkLogger = { error(...args) { logger.error(`[Remote/Lark/SDK] ${args.map(String).join(' ')}`); }, warn(...args) { logger.warn(`[Remote/Lark/SDK] ${args.map(String).join(' ')}`); }, info(...args) { logger.info(`[Remote/Lark/SDK] ${args.map(String).join(' ')}`); }, debug(...args) { logger.debug(`[Remote/Lark/SDK] ${args.map(String).join(' ')}`); }, trace(...args) { logger.debug(`[Remote/Lark/SDK] [trace] ${args.map(String).join(' ')}`); }, log(...args) { logger.info(`[Remote/Lark/SDK] ${args.map(String).join(' ')}`); }, }; async function startLarkWS({ silent = false } = {}) { // 如果已连接且对象存在 → 直接返回 if (_wsClient && _wsConnected) { return { success: true, message: 'Already connected' }; } if (_wsStarting) { return { success: true, message: 'Connection in progress' }; } // 如果 _wsClient 存在但已断连 → 先清理再重建 if (_wsClient && !_wsConnected) { try { if (typeof _wsClient.close === 'function') { _wsClient.close(); } } catch { } _wsClient = null; _larkClient = null; } const config = getLarkConfig(); if (!config.appId || !config.appSecret) { return { success: false, message: 'Missing ASD_LARK_APP_ID / ASD_LARK_APP_SECRET' }; } _wsStarting = true; try { const lark = await import('@larksuiteoapi/node-sdk'); _larkClient = new lark.Client({ appId: config.appId, appSecret: config.appSecret, disableTokenCache: false, }); const eventDispatcher = new lark.EventDispatcher({}).register({ 'im.message.receive_v1': async (data) => { try { await handleLarkMessage(data); } catch (err) { logger.error(`[Remote/Lark] Handler error: ${err.message}`); } }, }); _wsClient = new lark.WSClient({ appId: config.appId, appSecret: config.appSecret, loggerLevel: lark.LoggerLevel?.info ?? 2, autoReconnect: true, logger: larkSdkLogger, }); await _wsClient.start({ eventDispatcher }); _wsConnected = true; _wsStarting = false; // 恢复上次活跃的 chat_id(从数据库) _restoreActiveChatId(); logger.info('[Remote/Lark] ✅ WebSocket long connection established'); // 向飞书发送上线通知(仅首次启动,重连时静默) if (!silent) { setTimeout(() => { sendLarkNotification([ '🟢 IDE 桥接已上线', `时间: ${new Date().toLocaleString('zh-CN')}`, `平台: macOS | Node ${process.version}`, '', '发送任意文字即可远程编程,/help 查看命令。', ].join('\n')).catch(() => { }); }, 1000); } return { success: true, message: 'Connected via WebSocket' }; } catch (err) { _wsClient = null; _wsConnected = false; _wsStarting = false; logger.error(`[Remote/Lark] WSClient start failed: ${err.message}`); return { success: false, message: err.message }; } } function stopLarkWS() { if (!_wsClient) { return { success: true, message: 'Not running' }; } try { if (typeof _wsClient.close === 'function') { _wsClient.close(); } } catch { /* ignore */ } _wsClient = null; _larkClient = null; _wsConnected = false; logger.info('[Remote/Lark] WebSocket connection stopped'); return { success: true, message: 'Stopped' }; } // ─── 自动启动(路由加载时) ───────────────────────── const { appId: _autoId, appSecret: _autoSecret } = getLarkConfig(); if (_autoId && _autoSecret) { // 延迟启动:setImmediate 确保路由注册、DB init 全部完成后,再等 8s 启动飞书连接 // 不阻塞主服务启动流程 setImmediate(() => setTimeout(async () => { logger.info('[Remote/Lark] Auto-starting WebSocket connection...'); const result = await startLarkWS(); if (!result.success) { logger.warn(`[Remote/Lark] Auto-start failed: ${result.message}`); } }, 8000)); } // ─── 连接健康检查 & 自动重连 ──────────────────────── const HEALTH_CHECK_INTERVAL = 30_000; // 30 秒检查一次 setInterval(async () => { // 没有凭证 → 跳过 const cfg = getLarkConfig(); if (!cfg.appId || !cfg.appSecret) { return; } // WSClient 对象存在但 SDK 内部可能已断开 → 尝试探活 if (_wsClient && _wsConnected) { // 发一个轻量 API 调用来验证连通性 try { if (_larkClient) { await _larkClient.auth.tenantAccessToken.internal({ data: { app_id: cfg.appId, app_secret: cfg.appSecret }, }); } // 有响应 → 正常 return; } catch { // 调用失败不代表 WS 断了(可能只是 API 暂时不通),保持状态 return; } } // WSClient 不存在或已标记断开 → 自动重连(静默,不打扰用户) if (!_wsClient && !_wsStarting) { logger.info('[Remote/Lark] Connection lost, auto-reconnecting...'); const result = await startLarkWS({ silent: true }); if (result.success) { logger.info('[Remote/Lark] ✅ Auto-reconnected successfully'); } else { logger.warn(`[Remote/Lark] Auto-reconnect failed: ${result.message}`); } } }, HEALTH_CHECK_INTERVAL); // ─── 超时清理定时器 ───────────────────────────────── setInterval(() => { try { const repo = getRepo(); const total = repo.cleanupTimeouts(PENDING_TIMEOUT_SEC, RUNNING_TIMEOUT_SEC); if (total > 0) { logger.info(`[Remote] Cleaned ${total} timed-out commands`); } } catch { /* DB 尚未就绪时静默 */ } }, CLEANUP_INTERVAL_MS); // ═══════════════════════════════════════════════════════ // LarkTransport — 自然语言意图路由 // ═══════════════════════════════════════════════════════ let _larkTransport = null; /** * 获取或创建 LarkTransport 实例 * 延迟初始化,等待 ServiceContainer 就绪 */ function getLarkTransport() { if (_larkTransport) { return _larkTransport; } try { const container = getServiceContainer(); const agentFactory = container.get('agentFactory'); const aiProvider = container.get('aiProvider'); if (!agentFactory) { logger.warn('[Remote/Lark] AgentFactory not available, transport not ready'); return null; } _larkTransport = new LarkTransport({ agentFactory, aiProvider: aiProvider ?? undefined, replyFn: replyLark, sendFn: sendLarkNotification, sendImageFn: sendLarkScreenshot, getStatusFn: getStatusText, enqueueIdeFn: enqueueIdeCommand, isUserAllowed, projectRoot: resolveProjectRoot(container), }); logger.info('[Remote/Lark] LarkTransport initialized'); return _larkTransport; } catch (err) { logger.warn(`[Remote/Lark] LarkTransport init failed: ${err.message}`); return null; } } /** 生成系统状态文本 (给 LarkTransport 系统操作使用) */ async function getStatusText() { const lines = ['📊 状态面板', '']; const now = Math.floor(Date.now() / 1000); let ideOk = false; lines.push(`① 飞书 WebSocket: ${_wsConnected ? '✅ 已连接' : '❌ 断开'}`); lines.push(`② API 服务器: ✅ 运行中 (port ${process.env.PORT || 3000})`); lines.push(`③ 活跃会话: ${_activeChatId ? `✅ ${_activeChatId.slice(0, 16)}...` : '⚠️ 无活跃会话'}`); try { const repo = getRepo(); const hasWaiters = _waiters.size > 0; const pollAge = _lastPollAt > 0 ? now - Math.floor(_lastPollAt / 1000) : -1; if (hasWaiters) { ideOk = true; lines.push('④ IDE 扩展: ✅ 在线 (long-poll 连接中)'); } else if (pollAge >= 0 && pollAge < 30) { ideOk = true; lines.push(`④ IDE 扩展: ✅ 活跃 (${pollAge}秒前有心跳)`); } else { const recentClaim = repo.findRecentClaim(); if (recentClaim && now - recentClaim.claimedAt < 120) { ideOk = true; lines.push(`④ IDE 扩展: ✅ 活跃 (${now - recentClaim.claimedAt}秒前有 claim)`); } else { lines.push('④ IDE 扩展: ⚠️ 未检测到活跃连接'); } } const counts = repo.getStatusCounts(); lines.push(`⑤ 队列: ${counts.pending} 待执行 | ${counts.running} 执行中 | ${counts.completed} 已完成 | ${counts.timeout} 超时`); } catch (err) { lines.push(`④ IDE 扩展: ❓ 查询失败 (${err.message})`); lines.push('⑤ 队列: ❓ 查询失败'); } lines.push(`⑥ 通知通道: ${isLarkNotificationReady() ? '✅ 就绪' : '❌ 未就绪'}`); const allGood = _wsConnected && _activeChatId && ideOk && isLarkNotificationReady(); lines.push(''); lines.push(allGood ? '🟢 全链路正常,可以远程编程!' : '🟡 部分链路异常,请检查上方标记。'); return lines.join('\n'); } /** * 写入 IDE 编程指令队列 (供 LarkTransport 的 ide_agent 路由使用) * * @param command 自然语言编程指令 * @param meta { chatId, messageId, senderId, senderName } * @returns >} */ async function enqueueIdeCommand(command, meta = {}) { const repo = getRepo(); const id = genId(); if (meta.chatId) { _activeChatId = meta.chatId; _persistActiveChatId(meta.chatId); } repo.enqueue({ id, source: 'lark', chatId: meta.chatId, messageId: meta.messageId, userId: meta.senderId, userName: meta.senderName, command, }); logger.info(`[Remote/Lark] IDE command queued: ${id} — "${command.slice(0, 50)}"`); wakeWaiters(); return { id }; } function _getProjectRoot() { return resolveProjectRoot(getServiceContainer()); } // ═══════════════════════════════════════════════════════ // 飞书消息处理 — 通过 LarkTransport 路由 // ═══════════════════════════════════════════════════════ async function handleLarkMessage(data) { const message = data?.message || data?.event?.message || {}; const messageId = message.message_id; const chatId = message.chat_id; if (isDuplicate(messageId)) { return; } // 更新活跃会话 if (chatId) { _activeChatId = chatId; _persistActiveChatId(chatId); } // 通过 LarkTransport 路由 (自然语言意图分类) const transport = getLarkTransport(); if (transport) { await transport.receive(data); } else { // Transport 未就绪 → 降级模式 logger.warn('[Remote/Lark] Transport not ready, falling back to queue mode'); const sender = data?.sender || data?.event?.sender || {}; let text = ''; try { const content = JSON.parse(message.content || '{}'); text = (content.text || '') .trim() .replace(/@_user_\d+/g, '') .trim(); } catch { text = ''; } if (text) { // ── 降级模式下仍需识别系统指令,避免"状态"等命令被盲目转发 IDE ── const FALLBACK_SYSTEM_RE = /^(状态|status|截图|screenshot|帮助|help|ping|队列|queue|取消|cancel|清[理空])$/i; const FALLBACK_SYSTEM_CONTAINS_RE = /状态|status|截图|screenshot|screen|截屏|帮助|help|诊断|链路|连接.*状态|服务.*状态/i; if (FALLBACK_SYSTEM_RE.test(text) || FALLBACK_SYSTEM_CONTAINS_RE.test(text)) { // 系统指令 — 在降级模式下直接回复状态 logger.info(`[Remote/Lark] Fallback: system command detected — "${text}"`); const statusText = await getStatusText(); await replyLark(messageId, statusText || '📊 系统状态查询中 (Agent 模式未就绪)'); return; } const senderId = sender.sender_id?.user_id || sender.sender_id?.open_id || ''; const senderName = sender.sender_id?.user_id || 'lark_user'; await enqueueIdeCommand(text, { chatId, messageId, senderId, senderName }); await replyLark(messageId, '📝 收到,已加入执行队列。(Agent 模式未就绪)'); } } } // ═══════════════════════════════════════════════════════ // 飞书连接管理端点 // ═══════════════════════════════════════════════════════ router.post('/lark/start', async (_req, res) => { res.json(await startLarkWS()); }); router.post('/lark/stop', async (_req, res) => { res.json(stopLarkWS()); }); router.get('/lark/status', async (_req, res) => { const config = getLarkConfig(); let queueInfo = {}; try { const repo = getRepo(); queueInfo = repo.getStatusCounts(); } catch { /* DB 未就绪 */ } res.json({ success: true, data: { connected: _wsConnected, hasCredentials: !!(config.appId && config.appSecret), appId: config.appId ? `${config.appId.slice(0, 8)}...` : '', activeChatId: _activeChatId ? `${_activeChatId.slice(0, 12)}...` : '', notificationReady: isLarkNotificationReady(), queue: queueInfo, projectRoot: _getProjectRoot(), }, }); }); // ═══════════════════════════════════════════════════════ // 飞书 Webhook 回调(备用) // ═══════════════════════════════════════════════════════ router.post('/lark/event', async (req, res) => { const body = req.body; if (body.type === 'url_verification') { return void res.json({ challenge: body.challenge }); } const header = body.header || {}; const event = body.event || {}; const larkConfig = getLarkConfig(); if (larkConfig.verificationToken && header.token !== larkConfig.verificationToken) { return void res.status(403).json({ success: false, message: 'Invalid token' }); } if (header.event_type === 'im.message.receive_v1') { await handleLarkMessage(event); } res.json({ success: true }); }); // ═══════════════════════════════════════════════════════ // VSCode 扩展 API // ═══════════════════════════════════════════════════════ router.get('/pending', async (_req, res) => { _lastPollAt = Date.now(); const repo = getRepo(); const row = repo.findFirstPending(); res.json({ success: true, data: row ? { id: row.id, command: row.command, source: row.source, userName: row.userName, messageId: row.messageId, createdAt: row.createdAt, } : null, }); }); router.post('/claim/:id', async (req, res) => { const id = req.params.id; const repo = getRepo(); const claimed = repo.claim(id); if (!claimed) { return void res.json({ success: false, message: 'Not found or already claimed' }); } // 通知飞书用户:IDE 已开始执行 const row = repo.findById(id); if (row?.messageId) { replyLark(row.messageId, `🚀 IDE 已开始执行...\n\n> ${(row.command || '').slice(0, 60)}`).catch(() => { }); } res.json({ success: true }); }); router.post('/result/:id', validate(RemoteResultBody), async (req, res) => { const id = req.params.id; const { result, status } = req.body; const repo = getRepo(); const row = repo.findById(id); if (!row) { return void res.json({ success: false, message: 'Not found' }); } repo.complete(id, result || '', status); // 回复飞书 if (row.messageId && result) { const truncated = result.length > 2000 ? `${result.slice(0, 2000)}\n\n... (截断)` : result; if (status === 'completed') { await replyLark(row.messageId, truncated); } else { const emoji = status === 'failed' ? '❌' : '⚠️'; const label = status === 'failed' ? '执行失败' : status; await replyLark(row.messageId, `${emoji} ${label}\n\n${truncated}`); } } res.json({ success: true }); }); router.get('/history', validateQuery(RemoteHistoryQuery), async (req, res) => { const repo = getRepo(); const limit = req.query.limit; const rows = repo.getHistory(limit); res.json({ success: true, data: rows }); }); // ═══════════════════════════════════════════════════════ // Long-Poll — 新消息到达时立即唤醒扩展端 // ═══════════════════════════════════════════════════════ /** 等待新消息的 resolve 回调队列 */ const _waiters = new Set(); /** IDE 扩展最后一次轮询/连接时间戳(用于 /check 诊断) */ let _lastPollAt = 0; /** * 唤醒所有等待中的 long-poll 客户端 * 在 handleLarkMessage 写入新指令后调用 */ function wakeWaiters() { for (const resolve of _waiters) { resolve({ hasNew: true }); } _waiters.clear(); } router.get('/wait', (req, res) => { _lastPollAt = Date.now(); const timeout = Math.min(parseInt(req.query.timeout) || 25000, 60000); let resolved = false; const resolve = (data) => { if (resolved) { return; } resolved = true; _waiters.delete(resolve); clearTimeout(timer); res.json(data); }; const timer = setTimeout(() => resolve({ hasNew: false }), timeout); _waiters.add(resolve); // 客户端断开时清理 req.on('close', () => { if (!resolved) { resolved = true; _waiters.delete(resolve); clearTimeout(timer); } }); }); // POST /flush — IDE 重连时清理所有积压的 pending 指令 router.post('/flush', async (req, res) => { const repo = getRepo(); const pending = repo.flushPending(); if (pending.length === 0) { return void res.json({ success: true, flushed: 0, commands: [] }); } const now = Math.floor(Date.now() / 1000); const summaries = pending.map((r) => ({ id: r.id, command: r.command?.slice(0, 60) || '', age: now - r.createdAt, })); logger.info(`[Remote] Flushed ${pending.length} stale pending commands on IDE reconnect`); // 飞书通知 const lines = summaries.map((s, i) => ` ${i + 1}. ${s.command}${s.command.length >= 60 ? '…' : ''} (${s.age}s ago)`); sendLarkNotification(`🗑 IDE 重连,已清理 ${pending.length} 条积压指令:\n${lines.join('\n')}`).catch(() => { }); res.json({ success: true, flushed: pending.length, commands: summaries }); }); router.post('/send', validate(RemoteSendBody), async (req, res) => { const { command } = req.body; const repo = getRepo(); const id = genId(); repo.enqueue({ id, source: 'manual', userName: 'developer', command, }); res.json({ success: true, data: { id, command } }); }); // POST /api/v1/remote/notify — 通用通知(扩展/外部模块主动推送飞书) router.post('/notify', validate(RemoteNotifyBody), async (req, res) => { const { text } = req.body; const sent = await sendLarkNotification(text); res.json({ success: sent, message: sent ? 'Sent' : 'Lark not connected or no active chat' }); }); // POST /api/v1/remote/screenshot — 截取 IDE 窗口并发送到飞书 router.post('/screenshot', async (req, res) => { const { caption } = req.body || {}; const result = await sendLarkScreenshot(caption || ''); res.json(result); }); // ═══════════════════════════════════════════════════════ // 飞书回复辅助 // ═══════════════════════════════════════════════════════ let _tenantToken = ''; let _tenantTokenExpiry = 0; async function getTenantToken() { if (_tenantToken && Date.now() < _tenantTokenExpiry) { return _tenantToken; } const config = getLarkConfig(); if (!config.appId || !config.appSecret) { return ''; } try { const resp = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ app_id: config.appId, app_secret: config.appSecret }), }); const data = (await resp.json()); if (data.code === 0 && data.tenant_access_token) { _tenantToken = data.tenant_access_token; _tenantTokenExpiry = Date.now() + ((data.expire ?? 7200) - 300) * 1000; return _tenantToken; } return ''; } catch { return ''; } } async function replyLark(messageId, text) { if (!messageId) { return; } // SDK Client 优先 if (_larkClient) { try { await _larkClient.im.message.reply({ path: { message_id: messageId }, data: { content: JSON.stringify({ text }), msg_type: 'text' }, }); return; } catch (err) { logger.warn(`[Remote/Lark] SDK reply failed: ${err.message}`); } } // REST 回退 const token = await getTenantToken(); if (!token) { return; } try { await fetch(`https://open.feishu.cn/open-apis/im/v1/messages/${messageId}/reply`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ content: JSON.stringify({ text }), msg_type: 'text' }), }); } catch { /* silent */ } } // ═══════════════════════════════════════════════════════ // IDE 窗口截图 + 飞书发送(ScreenCaptureKit,息屏可用) // ═══════════════════════════════════════════════════════ /** * 截取 IDE 窗口截图(通过 ScreenCaptureKit 原生 API,息屏时可用) * @param [opts.windowTitle] 窗口标题关键词(默认 "Code") */ async function captureIDEScreenshot(opts = {}) { try { const { screenshot } = await import('../../platform/ScreenCaptureService.js'); const windowTitle = opts.windowTitle || 'Code'; let result = await screenshot({ windowTitle, format: 'png' }); if (!result.success) { for (const alt of ['Visual Studio', 'Cursor', 'Xcode', 'IntelliJ', 'WebStorm']) { if (alt.toLowerCase() === windowTitle.toLowerCase()) { continue; } result = await screenshot({ windowTitle: alt, format: 'png' }); if (result.success) { break; } } } if (!result.success) { logger.info(`[Remote/Screenshot] IDE window not found, capturing largest available window`); result = await screenshot({ format: 'png' }); } if (result.success) { logger.info(`[Remote/Screenshot] Captured: ${result.path} (${result.width}x${result.height})`); return { path: result.path, error: null }; } return { path: null, error: result.error || 'Screenshot failed' }; } catch (err) { logger.warn(`[Remote/Screenshot] ScreenCaptureKit error: ${err.message}`); return { path: null, error: err.message }; } } /** * 上传图片到飞书 Image API * @param filePath 本地图片路径 * @returns >} */ async function _uploadImageToLark(filePath) { const token = await getTenantToken(); if (!token) { return { imageKey: null, error: '获取 tenant_access_token 失败' }; } try { const fileData = readFileSync(filePath); const blob = new Blob([fileData], { type: 'image/jpeg' }); const form = new FormData(); form.append('image_type', 'message'); form.append('image', blob, 'screenshot.jpg'); const resp = await fetch('https://open.feishu.cn/open-apis/im/v1/images', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form, }); const data = (await resp.json()); if (data.code === 0 && data.data?.image_key) { return { imageKey: data.data.image_key, error: null }; } const errMsg = `飞书图片上传失败 (code=${data.code}): ${data.msg || '未知错误'}`; logger.warn(`[Remote/Screenshot] Upload failed: code=${data.code} msg=${data.msg}`); return { imageKey: null, error: errMsg }; } catch (err) { logger.warn(`[Remote/Screenshot] Upload error: ${err.message}`); return { imageKey: null, error: `上传异常: ${err.message}` }; } } /** 向飞书发送图片消息 */ async function _sendLarkImageMsg(imageKey) { if (!_activeChatId || !_wsConnected) { return false; } // SDK Client 优先 if (_larkClient) { try { await _larkClient.im.message.create({ params: { receive_id_type: 'chat_id' }, data: { receive_id: _activeChatId, content: JSON.stringify({ image_key: imageKey }), msg_type: 'image', }, }); return true; } catch (err) { logger.warn(`[Remote/Screenshot] SDK image send failed: ${err.message}`); } } // REST 回退 const token = await getTenantToken(); if (!token) { return false; } try { const resp = await fetch('https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ receive_id: _activeChatId, content: JSON.stringify({ image_key: imageKey }), msg_type: 'image', }), }); const data = (await resp.json()); return data.code === 0; } catch { return false; } } /** * 截取 IDE 窗口 → 上传飞书 → 发送图片消息(完整流水线) * @param [caption] 可选文字说明(会先发一条文本) * @returns >} */ export async function sendLarkScreenshot(caption = '') { if (!_activeChatId || !_wsConnected) { return { success: false, message: 'Lark not connected or no active chat' }; } // 1. 截图(ScreenCaptureKit,息屏可用) const capture = await captureIDEScreenshot(); if (!capture.path) { return { success: false, message: capture.error || 'Screenshot capture failed' }; } const filePath = capture.path; try { // 2. 可选:先发文字说明 if (caption.trim()) { await sendLarkNotification(caption.trim()); } // 3. 上传 const upload = await _uploadImageToLark(filePath); if (!upload.imageKey) { return { success: false, message: upload.error || 'Image upload to Lark failed' }; } // 4. 发送图片消息 const sent = await _sendLarkImageMsg(upload.imageKey); return { success: sent, message: sent ? 'Screenshot sent' : 'Failed to send image message', }; } finally { // 清理临时文件 try { unlinkSync(filePath); } catch { /* ignore */ } } } // ═══════════════════════════════════════════════════════ // 主动通知能力(供 task.js 等外部模块调用) // ═══════════════════════════════════════════════════════ /** 最近活跃的飞书 chat_id(收到消息时更新) */ let _activeChatId = ''; /** 持久化 active chat_id 到数据库 */ function _persistActiveChatId(chatId) { try { const repo = getRepo(); repo.setState('active_chat_id', chatId); } catch { /* DB 未就绪 */ } } /** 从数据库恢复 active chat_id */ function _restoreActiveChatId() { try { const repo = getRepo(); // 优先从 remote_state 恢复 const value = repo.getState('active_chat_id'); if (value) { _activeChatId = value; logger.info(`[Remote/Lark] Restored active chat from state: ${_activeChatId.slice(0, 12)}...`); return; } // 回退:从 remote_commands 取最近有 chat_id 的记录 const chatId = repo.findRecentChatId(); if (chatId) { _activeChatId = chatId; repo.setState('active_chat_id', chatId); logger.info(`[Remote/Lark] Restored active chat from history: ${_activeChatId.slice(0, 12)}...`); } } catch { /* DB 未就绪 */ } } /** * 向飞书活跃会话发送主动通知(非回复) * 用于任务进度、Guard 结果等非指令触发的通知 * * @param text 纯文本通知内容 * @returns 发送是否成功 */ export async function sendLarkNotification(text) { if (!_activeChatId || !_wsConnected) { return false; } // SDK Client 优先 if (_larkClient) { try { await _larkClient.im.message.create({ params: { receive_id_type: 'chat_id' }, data: { receive_id: _activeChatId, content: JSON.stringify({ text }), msg_type: 'text', }, }); return true; } catch (err) { logger.warn(`[Remote/Lark] SDK send failed: ${err.message}`); } } // REST 回退 const token = await getTenantToken(); if (!token) { return false; } try { const resp = await fetch('https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ receive_id: _activeChatId, content: JSON.stringify({ text }), msg_type: 'text', }), }); const data = (await resp.json()); return data.code === 0; } catch { return false; } } /** 查询飞书通知是否可用 */ export function isLarkNotificationReady() { return !!(_activeChatId && _wsConnected); } export default router;