| import AiBot from '@wecom/aibot-node-sdk'; | ||
| import { createLogger } from '../logger.js'; | ||
| const log = createLogger('Wecom'); | ||
| let wsClient = null; | ||
| export function getWSClient() { | ||
| if (!wsClient) | ||
| throw new Error('Wecom WSClient not initialized'); | ||
| return wsClient; | ||
| } | ||
| export async function initWecom(config, setupHandlers) { | ||
| log.info('Initializing WeChat Work (WeCom) bot...'); | ||
| const client = new AiBot.WSClient({ | ||
| botId: config.wecomBotId, | ||
| secret: config.wecomBotSecret, | ||
| maxReconnectAttempts: -1, // 无限重连 | ||
| logger: { | ||
| debug: (msg, ...args) => log.debug(`[SDK] ${msg}`, ...args), | ||
| info: (msg, ...args) => log.info(`[SDK] ${msg}`, ...args), | ||
| warn: (msg, ...args) => log.warn(`[SDK] ${msg}`, ...args), | ||
| error: (msg, ...args) => log.error(`[SDK] ${msg}`, ...args), | ||
| }, | ||
| }); | ||
| // 注册生命周期事件 | ||
| client.on('disconnected', (reason) => { | ||
| log.warn(`WebSocket disconnected: ${reason}`); | ||
| }); | ||
| client.on('reconnecting', (attempt) => { | ||
| log.info(`Reconnecting (attempt ${attempt})...`); | ||
| }); | ||
| client.on('error', (error) => { | ||
| log.error('WebSocket error:', error); | ||
| }); | ||
| // 建立连接 | ||
| client.connect(); | ||
| // 等待认证成功 | ||
| await new Promise((resolve, reject) => { | ||
| const timeout = setTimeout(() => { | ||
| reject(new Error('WeChat Work authentication timed out (30s)')); | ||
| }, 30_000); | ||
| client.on('authenticated', () => { | ||
| clearTimeout(timeout); | ||
| log.info('Authenticated successfully'); | ||
| resolve(); | ||
| }); | ||
| }); | ||
| wsClient = client; | ||
| // 设置消息处理器 | ||
| const handle = setupHandlers(client); | ||
| return { wsClient: client, handle }; | ||
| } | ||
| export function stopWecom() { | ||
| if (wsClient) { | ||
| wsClient.disconnect(); | ||
| wsClient = null; | ||
| log.info('WeChat Work bot stopped'); | ||
| } | ||
| } |
| import { join } from 'node:path'; | ||
| import { mkdir, writeFile } from 'node:fs/promises'; | ||
| import { createWecomSender } from './message-sender.js'; | ||
| import { AccessControl } from '../access/access-control.js'; | ||
| import { RequestQueue } from '../queue/request-queue.js'; | ||
| import { CommandHandler } from '../commands/handler.js'; | ||
| import { registerPermissionSender, resolvePermissionById } from '../hook/permission-server.js'; | ||
| import { runClaudeTask } from '../shared/claude-task.js'; | ||
| import { startTaskCleanup } from '../shared/task-cleanup.js'; | ||
| import { MessageDedup } from '../shared/message-dedup.js'; | ||
| import { WECOM_THROTTLE_MS, IMAGE_DIR } from '../constants.js'; | ||
| import { setActiveChatId } from '../shared/active-chats.js'; | ||
| import { createLogger } from '../logger.js'; | ||
| const log = createLogger('WecomHandler'); | ||
| /** | ||
| * 从消息 body 中提取用户、聊天信息 | ||
| */ | ||
| function extractInfo(body) { | ||
| const userId = body.from?.userid ?? ''; | ||
| const isGroup = body.chattype === 'group'; | ||
| const chatId = isGroup ? (body.chatid ?? userId) : userId; | ||
| const text = body.text?.content ?? body.voice?.content ?? ''; | ||
| const msgId = body.msgid ?? ''; | ||
| return { userId, chatId, isGroup, text, msgId }; | ||
| } | ||
| /** | ||
| * 清理群聊消息文本 | ||
| * 企业微信智能机器人 SDK 在群聊中只会推送 @机器人的消息, | ||
| * 所以只要收到群聊消息,就一定是被 mention 的,无需额外检查。 | ||
| */ | ||
| function cleanGroupText(text) { | ||
| return text.trim(); | ||
| } | ||
| /** | ||
| * 下载企业微信图片到本地 | ||
| */ | ||
| async function downloadWecomImage(wsClient, url, aesKey) { | ||
| await mkdir(IMAGE_DIR, { recursive: true }); | ||
| const { buffer, filename } = await wsClient.downloadFile(url, aesKey); | ||
| const ext = filename?.split('.').pop() ?? 'jpg'; | ||
| const safeFilename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`; | ||
| const imagePath = join(IMAGE_DIR, safeFilename); | ||
| await writeFile(imagePath, buffer); | ||
| return imagePath; | ||
| } | ||
| export function setupWecomHandlers(wsClient, config, sessionManager) { | ||
| const accessControl = new AccessControl(config.allowedUserIds); | ||
| const requestQueue = new RequestQueue(); | ||
| const userCosts = new Map(); | ||
| const runningTasks = new Map(); | ||
| const stopTaskCleanup = startTaskCleanup(runningTasks); | ||
| const dedup = new MessageDedup(); | ||
| const sender = createWecomSender(wsClient); | ||
| // accepting flag: 标记是否接受新消息 | ||
| let accepting = true; | ||
| let taskCounter = 0; | ||
| const commandHandler = new CommandHandler({ | ||
| config, | ||
| sessionManager, | ||
| requestQueue, | ||
| sender: { sendTextReply: (chatId, text) => sender.sendTextReply(chatId, text) }, | ||
| userCosts, | ||
| getRunningTasksSize: () => runningTasks.size, | ||
| }); | ||
| // 注册权限发送器 | ||
| registerPermissionSender('wecom', { | ||
| sendPermissionCard: (chatId, requestId, toolName, toolInput) => sender.sendPermissionCard(chatId, requestId, toolName, toolInput), | ||
| updatePermissionCard: (params) => sender.updatePermissionCard(params), | ||
| }); | ||
| /** | ||
| * 核心请求处理器(frame 可选,有 frame 时使用流式回复,无 frame 时退化为 sendMessage) | ||
| */ | ||
| async function handleClaudeRequestCore(userId, chatId, prompt, workDir, convId, frame) { | ||
| const sessionId = convId ? sessionManager.getSessionIdForConv(userId, convId) : undefined; | ||
| log.info(`Running Claude for user ${userId}, convId=${convId}, workDir=${workDir}, sessionId=${sessionId ?? 'new'}`); | ||
| const taskKey = `${userId}:${++taskCounter}`; | ||
| let waitingTimer = null; | ||
| // 有 frame 时使用流式回复,无 frame 时 sender 内部会退化为 sendMessage | ||
| if (frame) { | ||
| sender.initStream(frame, taskKey); | ||
| } | ||
| // 追踪内容变化,用于检测工具执行期间的停滞 | ||
| let lastSeenContent = ''; | ||
| let firstContentReceived = false; | ||
| let isThinking = false; | ||
| await runClaudeTask({ config, sessionManager, userCosts }, { | ||
| userId, | ||
| chatId, | ||
| workDir, | ||
| sessionId, | ||
| convId, | ||
| platform: 'wecom', | ||
| taskKey, | ||
| }, prompt, { | ||
| throttleMs: WECOM_THROTTLE_MS, | ||
| streamUpdate: (content, toolNote) => { | ||
| lastSeenContent = content; | ||
| // 通过内容前缀判断是否在思考阶段(claude-task 中思考内容以 💭 开头) | ||
| isThinking = content.startsWith('💭'); | ||
| sender.sendStreamUpdate(content, toolNote).catch(() => { }); | ||
| }, | ||
| sendComplete: async (content, note) => { | ||
| try { | ||
| if (frame) { | ||
| await sender.sendStreamComplete(content, note); | ||
| } | ||
| else { | ||
| // 无 frame(命令触发),退化为 sendMessage | ||
| const text = note ? `${content}\n\n---\n> ${note}` : content; | ||
| await sender.sendTextReply(chatId, text); | ||
| } | ||
| } | ||
| catch (err) { | ||
| log.error('Failed to send complete:', err); | ||
| } | ||
| }, | ||
| sendError: async (error) => { | ||
| try { | ||
| if (frame) { | ||
| await sender.sendStreamError(error); | ||
| } | ||
| else { | ||
| await sender.sendTextReply(chatId, `❌ 错误\n\n${error}`); | ||
| } | ||
| } | ||
| catch (err) { | ||
| log.error('Failed to send error:', err); | ||
| } | ||
| }, | ||
| onThinkingToText: (content, thinkingText) => { | ||
| isThinking = false; | ||
| sender.resetStreamForTextSwitch(content, thinkingText).catch(() => { }); | ||
| }, | ||
| extraCleanup: () => { | ||
| if (waitingTimer) { | ||
| clearInterval(waitingTimer); | ||
| waitingTimer = null; | ||
| } | ||
| sender.cleanupStream(); | ||
| runningTasks.delete(taskKey); | ||
| }, | ||
| onTaskReady: (state) => { | ||
| runningTasks.set(taskKey, state); | ||
| // 有 frame 时才启动活动计时器(无 frame 场景没有流式通道) | ||
| if (frame) { | ||
| sender.sendStreamUpdate('⏳ 正在处理...').catch(() => { }); | ||
| const startTime = Date.now(); | ||
| let stallChecks = 0; | ||
| waitingTimer = setInterval(() => { | ||
| if (!runningTasks.has(taskKey)) { | ||
| if (waitingTimer) { | ||
| clearInterval(waitingTimer); | ||
| waitingTimer = null; | ||
| } | ||
| return; | ||
| } | ||
| const elapsed = Math.floor((Date.now() - startTime) / 1000); | ||
| if (!firstContentReceived) { | ||
| // 首次内容前:持续显示等待状态 | ||
| sender.sendStreamUpdate(`⏳ 等待 Claude 响应... (${elapsed}s)`).catch(() => { }); | ||
| } | ||
| else if (isThinking) { | ||
| // 思考阶段:不做 stall 检测,思考本身就可能暂停 | ||
| } | ||
| else if (state.latestContent === lastSeenContent) { | ||
| // 内容未变化(工具执行中):重发内容保持流活跃 | ||
| stallChecks++; | ||
| if (stallChecks >= 2) { | ||
| // 停滞 6s+ 才触发,避免正常节流间隔内的误判 | ||
| sender.sendStreamUpdate(state.latestContent, `⏳ 工具执行中... (${elapsed}s)`).catch(() => { }); | ||
| } | ||
| } | ||
| else { | ||
| // 内容有更新,重置计数 | ||
| stallChecks = 0; | ||
| lastSeenContent = state.latestContent; | ||
| } | ||
| }, 3000); | ||
| waitingTimer.unref(); | ||
| } | ||
| }, | ||
| onFirstContent: () => { | ||
| firstContentReceived = true; | ||
| }, | ||
| }); | ||
| } | ||
| // CommandHandler 使用的签名(无 frame) | ||
| async function handleClaudeRequest(userId, chatId, prompt, workDir, convId) { | ||
| await handleClaudeRequestCore(userId, chatId, prompt, workDir, convId); | ||
| } | ||
| /** | ||
| * 消息前置检查(去重、访问控制、活跃聊天设置) | ||
| * @returns true 表示通过检查,false 表示应跳过处理 | ||
| */ | ||
| async function preCheck(userId, chatId, msgId) { | ||
| if (!accepting) | ||
| return false; | ||
| if (dedup.isDuplicate(`${chatId}:${msgId}`)) { | ||
| log.debug(`Duplicate message ${msgId}, skipping`); | ||
| return false; | ||
| } | ||
| if (!accessControl.isAllowed(userId)) { | ||
| log.warn(`Access denied for user ${userId}. Add to ALLOWED_USER_IDS to grant access.`); | ||
| await sender.sendTextReply(chatId, '抱歉,您没有访问权限。\n\n请联系管理员将您的用户 ID 添加到白名单。\n您的 ID: ' + userId); | ||
| return false; | ||
| } | ||
| setActiveChatId('wecom', chatId); | ||
| return true; | ||
| } | ||
| /** | ||
| * 通用消息处理逻辑 | ||
| */ | ||
| async function handleMessage(frame, text, userId, chatId, msgId, isGroup) { | ||
| if (!await preCheck(userId, chatId, msgId)) | ||
| return; | ||
| let cleanText = text.trim(); | ||
| // 群聊文本清理(企业微信 SDK 只推送 @机器人的消息,无需检查 mention) | ||
| if (isGroup) { | ||
| cleanText = cleanGroupText(cleanText); | ||
| } | ||
| if (!cleanText) | ||
| return; | ||
| log.debug(`Processing message from user ${userId}: ${cleanText.slice(0, 100)}${cleanText.length > 100 ? '...' : ''}`); | ||
| // 处理 /stop 命令(企业微信特有,因为按钮可能不可用) | ||
| if (cleanText === '/stop') { | ||
| // 找到该用户最新的运行任务并停止(taskKey 格式为 userId:counter,用数值比较 counter) | ||
| const prefix = `${userId}:`; | ||
| let latestKey = null; | ||
| let latestCounter = -1; | ||
| for (const key of runningTasks.keys()) { | ||
| if (key.startsWith(prefix)) { | ||
| const counter = parseInt(key.slice(prefix.length), 10); | ||
| if (counter > latestCounter) { | ||
| latestCounter = counter; | ||
| latestKey = key; | ||
| } | ||
| } | ||
| } | ||
| if (latestKey) { | ||
| const task = runningTasks.get(latestKey); | ||
| runningTasks.delete(latestKey); | ||
| task.settle(); | ||
| task.handle.abort(); | ||
| await sender.sendTextReply(chatId, '⏹️ 已停止当前任务'); | ||
| } | ||
| else { | ||
| await sender.sendTextReply(chatId, '当前没有运行中的任务'); | ||
| } | ||
| return; | ||
| } | ||
| // 统一命令分发 | ||
| if (await commandHandler.dispatch(cleanText, chatId, userId, 'wecom', handleClaudeRequest)) { | ||
| return; | ||
| } | ||
| // 路由到 Claude | ||
| const workDirSnapshot = sessionManager.getWorkDir(userId); | ||
| const convIdSnapshot = sessionManager.getConvId(userId); | ||
| const enqueueResult = requestQueue.enqueue(userId, convIdSnapshot, cleanText, async (prompt) => { | ||
| await handleClaudeRequestCore(userId, chatId, prompt, workDirSnapshot, convIdSnapshot, frame); | ||
| }); | ||
| if (enqueueResult === 'rejected') { | ||
| log.warn(`Queue full for user: ${userId}`); | ||
| await sender.sendTextReply(chatId, '您的请求队列已满,请等待当前任务完成后再试。'); | ||
| } | ||
| else if (enqueueResult === 'queued') { | ||
| await sender.sendTextReply(chatId, '前面还有任务在处理中,您的请求已排队等待。'); | ||
| } | ||
| } | ||
| // --- 注册事件监听器 --- | ||
| // 文本消息 | ||
| wsClient.on('message.text', async (frame) => { | ||
| const body = frame.body; | ||
| if (!body) | ||
| return; | ||
| const { userId, chatId, text, msgId, isGroup } = extractInfo(body); | ||
| await handleMessage(frame, text, userId, chatId, msgId, isGroup); | ||
| }); | ||
| // 语音消息(已转文字) | ||
| wsClient.on('message.voice', async (frame) => { | ||
| const body = frame.body; | ||
| if (!body) | ||
| return; | ||
| const { userId, chatId, msgId, isGroup } = extractInfo(body); | ||
| const text = body.voice?.content ?? ''; | ||
| await handleMessage(frame, text, userId, chatId, msgId, isGroup); | ||
| }); | ||
| // 图片消息 | ||
| wsClient.on('message.image', async (frame) => { | ||
| const body = frame.body; | ||
| if (!body) | ||
| return; | ||
| const { userId, chatId, msgId, isGroup } = extractInfo(body); | ||
| if (!await preCheck(userId, chatId, msgId)) | ||
| return; | ||
| const imageUrl = body.image?.url; | ||
| const aesKey = body.image?.aeskey; | ||
| if (!imageUrl) { | ||
| log.warn('Image message without URL'); | ||
| return; | ||
| } | ||
| let imagePath; | ||
| try { | ||
| imagePath = await downloadWecomImage(wsClient, imageUrl, aesKey); | ||
| } | ||
| catch (err) { | ||
| log.error('Failed to download image:', err); | ||
| await sender.sendTextReply(chatId, '图片下载失败,请重试。'); | ||
| return; | ||
| } | ||
| const prompt = `用户发送了一张图片,已保存到 ${imagePath}。请用 Read 工具查看并分析图片内容。`; | ||
| log.info(`User ${userId} [image]: ${prompt.slice(0, 100)}...`); | ||
| const workDirSnapshot = sessionManager.getWorkDir(userId); | ||
| const convIdSnapshot = sessionManager.getConvId(userId); | ||
| const enqueueResult = requestQueue.enqueue(userId, convIdSnapshot, prompt, async (p) => { | ||
| await handleClaudeRequestCore(userId, chatId, p, workDirSnapshot, convIdSnapshot, frame); | ||
| }); | ||
| if (enqueueResult === 'rejected') { | ||
| await sender.sendTextReply(chatId, '您的请求队列已满,请等待当前任务完成后再试。'); | ||
| } | ||
| else if (enqueueResult === 'queued') { | ||
| await sender.sendTextReply(chatId, '前面还有任务在处理中,您的请求已排队等待。'); | ||
| } | ||
| }); | ||
| // 图文混排消息 | ||
| wsClient.on('message.mixed', async (frame) => { | ||
| const body = frame.body; | ||
| if (!body) | ||
| return; | ||
| const { userId, chatId, msgId, isGroup } = extractInfo(body); | ||
| if (!await preCheck(userId, chatId, msgId)) | ||
| return; | ||
| const msgItems = body.mixed?.msg_item ?? []; | ||
| const textParts = []; | ||
| const imagePaths = []; | ||
| for (const item of msgItems) { | ||
| if (item.msgtype === 'text' && item.text?.content) { | ||
| textParts.push(item.text.content); | ||
| } | ||
| else if (item.msgtype === 'image' && item.image?.url) { | ||
| try { | ||
| const path = await downloadWecomImage(wsClient, item.image.url, item.image.aeskey); | ||
| imagePaths.push(path); | ||
| } | ||
| catch (err) { | ||
| log.error('Failed to download mixed image:', err); | ||
| } | ||
| } | ||
| } | ||
| // 群聊文本清理(企业微信 SDK 只推送 @机器人的消息,无需检查 mention) | ||
| const textContent = isGroup | ||
| ? cleanGroupText(textParts.join(' ')) | ||
| : textParts.join(' ').trim(); | ||
| let prompt; | ||
| if (imagePaths.length > 0) { | ||
| const imageDesc = imagePaths.map(p => `已保存到 ${p}`).join(','); | ||
| const captionPart = textContent ? `(附言:${textContent})` : ''; | ||
| prompt = `用户发送了 ${imagePaths.length} 张图片${captionPart},${imageDesc}。请用 Read 工具查看并分析图片内容。`; | ||
| } | ||
| else { | ||
| prompt = textContent; | ||
| } | ||
| if (!prompt) | ||
| return; | ||
| log.info(`User ${userId} [mixed]: ${prompt.slice(0, 100)}...`); | ||
| const workDirSnapshot = sessionManager.getWorkDir(userId); | ||
| const convIdSnapshot = sessionManager.getConvId(userId); | ||
| const enqueueResult = requestQueue.enqueue(userId, convIdSnapshot, prompt, async (p) => { | ||
| await handleClaudeRequestCore(userId, chatId, p, workDirSnapshot, convIdSnapshot, frame); | ||
| }); | ||
| if (enqueueResult === 'rejected') { | ||
| await sender.sendTextReply(chatId, '您的请求队列已满,请等待当前任务完成后再试。'); | ||
| } | ||
| else if (enqueueResult === 'queued') { | ||
| await sender.sendTextReply(chatId, '前面还有任务在处理中,您的请求已排队等待。'); | ||
| } | ||
| }); | ||
| // 模板卡片事件(停止按钮、权限按钮) | ||
| wsClient.on('event.template_card_event', async (frame) => { | ||
| const body = frame.body; | ||
| if (!body) | ||
| return; | ||
| const eventKey = body.event?.event_key ?? ''; | ||
| const userId = body.from?.userid ?? ''; | ||
| log.info(`Template card event from ${userId}: key=${eventKey}`); | ||
| if (eventKey.startsWith('stop_')) { | ||
| const taskKey = eventKey.replace('stop_', ''); | ||
| const taskInfo = runningTasks.get(taskKey); | ||
| if (taskInfo) { | ||
| runningTasks.delete(taskKey); | ||
| taskInfo.settle(); | ||
| taskInfo.handle.abort(); | ||
| // 更新卡片为已停止状态 | ||
| try { | ||
| await wsClient.updateTemplateCard(frame, { | ||
| card_type: 'text_notice', | ||
| main_title: { title: 'Claude Code' }, | ||
| sub_title_text: '⏹️ 已停止', | ||
| }); | ||
| } | ||
| catch (err) { | ||
| log.warn('Failed to update stop card:', err); | ||
| } | ||
| } | ||
| else { | ||
| try { | ||
| await wsClient.updateTemplateCard(frame, { | ||
| card_type: 'text_notice', | ||
| main_title: { title: 'Claude Code' }, | ||
| sub_title_text: '任务已完成或不存在', | ||
| }); | ||
| } | ||
| catch (err) { | ||
| log.warn('Failed to update card:', err); | ||
| } | ||
| } | ||
| } | ||
| else if (eventKey.startsWith('perm_allow_') || eventKey.startsWith('perm_deny_')) { | ||
| const isAllow = eventKey.startsWith('perm_allow_'); | ||
| const requestId = eventKey.replace(/^perm_(allow|deny)_/, ''); | ||
| const decision = isAllow ? 'allow' : 'deny'; | ||
| const resolved = resolvePermissionById(requestId, decision); | ||
| try { | ||
| await wsClient.updateTemplateCard(frame, { | ||
| card_type: 'text_notice', | ||
| main_title: { title: resolved ? (isAllow ? '✅ 已允许' : '❌ 已拒绝') : '请求已过期' }, | ||
| sub_title_text: resolved ? `权限请求已${isAllow ? '允许' : '拒绝'}` : '请求已过期或不存在', | ||
| }); | ||
| } | ||
| catch (err) { | ||
| log.warn('Failed to update permission card:', err); | ||
| } | ||
| } | ||
| }); | ||
| return { | ||
| stop: () => { | ||
| accepting = false; | ||
| stopTaskCleanup(); | ||
| }, | ||
| getRunningTaskCount: () => runningTasks.size, | ||
| }; | ||
| } |
| import { getWSClient } from './client.js'; | ||
| import { generateReqId } from '@wecom/aibot-node-sdk'; | ||
| import { createLogger } from '../logger.js'; | ||
| import { splitLongContent, buildInputSummary } from '../shared/utils.js'; | ||
| import { MAX_WECOM_MESSAGE_LENGTH, WECOM_STREAM_TIMEOUT_MS } from '../constants.js'; | ||
| const log = createLogger('WecomSender'); | ||
| /** | ||
| * 创建企业微信消息发送器 | ||
| */ | ||
| export function createWecomSender(wsClient) { | ||
| let session = null; | ||
| // 忙碌锁:防止并发 replyStream 调用导致 SDK 排队/丢弃/阻塞 | ||
| let streamBusy = false; | ||
| let pendingStreamUpdate = null; | ||
| /** | ||
| * 如果流式消息接近超时(330s),结束当前流并开始新流。 | ||
| * 企业微信 SDK 的 replyStream(finish=true) 会终结当前流内容并展示最终文本, | ||
| * 新流会以独立消息出现,因此重发完整内容不会导致用户侧重复显示。 | ||
| */ | ||
| async function renewStreamIfNeeded(content) { | ||
| if (!session) | ||
| return; | ||
| const elapsed = Date.now() - session.streamStartedAt; | ||
| if (elapsed > WECOM_STREAM_TIMEOUT_MS) { | ||
| log.info(`Stream ${session.streamId} elapsed ${Math.round(elapsed / 1000)}s, renewing`); | ||
| try { | ||
| await wsClient.replyStream(session.frame, session.streamId, content, true); | ||
| } | ||
| catch (err) { | ||
| log.warn('Failed to finish stream during renewal:', err); | ||
| } | ||
| session.streamId = generateReqId('stream'); | ||
| session.streamStartedAt = Date.now(); | ||
| session.isFirstUpdate = true; | ||
| } | ||
| } | ||
| /** 构建停止按钮模板卡片 */ | ||
| function buildStopCard(taskKey) { | ||
| return { | ||
| templateCard: { | ||
| card_type: 'button_interaction', | ||
| main_title: { title: 'Claude Code' }, | ||
| task_id: `stop_${taskKey}`, | ||
| button_list: [ | ||
| { text: '⏹️ 停止', style: 3, key: `stop_${taskKey}` }, | ||
| ], | ||
| }, | ||
| }; | ||
| } | ||
| /** 等待 streamBusy 释放,带 5 秒超时保护 */ | ||
| async function waitForStreamIdle() { | ||
| const waitStart = Date.now(); | ||
| while (streamBusy) { | ||
| if (Date.now() - waitStart > 5000) { | ||
| log.warn('Timed out waiting for streamBusy lock (5s), proceeding anyway'); | ||
| break; | ||
| } | ||
| await new Promise((r) => setTimeout(r, 50)); | ||
| } | ||
| } | ||
| /** | ||
| * 内部串行化的流式更新 | ||
| * 保证同一时刻最多一个 replyStream 在飞行中 | ||
| */ | ||
| async function doStreamUpdate(content, toolNote) { | ||
| if (!session) | ||
| return; | ||
| await renewStreamIfNeeded(content); | ||
| const text = toolNote ? `${content}\n\n---\n> ${toolNote}` : content; | ||
| try { | ||
| if (session.isFirstUpdate && session.taskKey) { | ||
| await wsClient.replyStreamWithCard(session.frame, session.streamId, text, false, buildStopCard(session.taskKey)); | ||
| } | ||
| else { | ||
| await wsClient.replyStream(session.frame, session.streamId, text, false); | ||
| } | ||
| session.isFirstUpdate = false; | ||
| } | ||
| catch (err) { | ||
| log.warn('Failed to send stream update:', err); | ||
| } | ||
| } | ||
| return { | ||
| async sendTextReply(chatId, text) { | ||
| try { | ||
| await wsClient.sendMessage(chatId, { | ||
| msgtype: 'markdown', | ||
| markdown: { content: text }, | ||
| }); | ||
| } | ||
| catch (err) { | ||
| log.error('Failed to send text reply:', err); | ||
| } | ||
| }, | ||
| initStream(frame, taskKey) { | ||
| const body = frame.body; | ||
| const chatId = body?.chatid ?? body?.from?.userid ?? null; | ||
| streamBusy = false; | ||
| pendingStreamUpdate = null; | ||
| session = { | ||
| frame, | ||
| chatId, | ||
| streamId: generateReqId('stream'), | ||
| streamStartedAt: Date.now(), | ||
| isFirstUpdate: true, | ||
| taskKey: taskKey ?? '', | ||
| }; | ||
| }, | ||
| async sendStreamUpdate(content, toolNote) { | ||
| if (!session) | ||
| return; | ||
| // 如果前一个 replyStream 还在飞行中,保存最新内容,等它完成后再发 | ||
| if (streamBusy) { | ||
| pendingStreamUpdate = { content, toolNote }; | ||
| return; | ||
| } | ||
| streamBusy = true; | ||
| try { | ||
| await doStreamUpdate(content, toolNote); | ||
| // 发送期间如果有新的 pending 内容,循环处理(避免递归) | ||
| while (pendingStreamUpdate) { | ||
| const pending = pendingStreamUpdate; | ||
| pendingStreamUpdate = null; | ||
| await doStreamUpdate(pending.content, pending.toolNote); | ||
| } | ||
| } | ||
| finally { | ||
| streamBusy = false; | ||
| } | ||
| }, | ||
| async resetStreamForTextSwitch(content, thinkingText) { | ||
| if (!session) | ||
| return; | ||
| // 等待正在飞行的 replyStream 完成,避免并发调用 | ||
| await waitForStreamIdle(); | ||
| streamBusy = true; | ||
| // 丢弃切换前的 pending 更新(思考阶段的内容已过时) | ||
| pendingStreamUpdate = null; | ||
| try { | ||
| // 结束当前流,保留完整思考内容作为独立消息 | ||
| const thinkingContent = `💭 **思考过程**\n\n${thinkingText}`; | ||
| try { | ||
| await wsClient.replyStream(session.frame, session.streamId, thinkingContent, true); | ||
| } | ||
| catch (err) { | ||
| log.warn('Failed to finish thinking stream:', err); | ||
| } | ||
| // 开启新流用于实际回答 | ||
| session.streamId = generateReqId('stream'); | ||
| session.streamStartedAt = Date.now(); | ||
| session.isFirstUpdate = true; | ||
| // 立即发送首条文本内容到新流(带停止按钮) | ||
| try { | ||
| if (session.taskKey) { | ||
| await wsClient.replyStreamWithCard(session.frame, session.streamId, content || '...', false, buildStopCard(session.taskKey)); | ||
| } | ||
| else { | ||
| await wsClient.replyStream(session.frame, session.streamId, content || '...', false); | ||
| } | ||
| session.isFirstUpdate = false; | ||
| } | ||
| catch (err) { | ||
| log.warn('Failed to start text stream:', err); | ||
| } | ||
| } | ||
| finally { | ||
| streamBusy = false; | ||
| } | ||
| }, | ||
| async sendStreamComplete(content, note) { | ||
| if (!session) | ||
| return; | ||
| // 等待飞行中的 replyStream 完成,避免并发调用 | ||
| await waitForStreamIdle(); | ||
| streamBusy = true; | ||
| const parts = splitLongContent(content, MAX_WECOM_MESSAGE_LENGTH); | ||
| const firstPart = note ? `${parts[0]}\n\n---\n> ${note}` : parts[0]; | ||
| try { | ||
| try { | ||
| await wsClient.replyStream(session.frame, session.streamId, firstPart, true); | ||
| } | ||
| catch (err) { | ||
| log.warn('Failed to finish stream, falling back to sendMessage:', err); | ||
| if (session.chatId) { | ||
| try { | ||
| await wsClient.sendMessage(session.chatId, { | ||
| msgtype: 'markdown', | ||
| markdown: { content: firstPart }, | ||
| }); | ||
| } | ||
| catch (fallbackErr) { | ||
| log.error('Fallback sendMessage also failed:', fallbackErr); | ||
| } | ||
| } | ||
| } | ||
| // 发送后续分片 | ||
| if (parts.length > 1 && session.chatId) { | ||
| for (let i = 1; i < parts.length; i++) { | ||
| try { | ||
| const partText = `${parts[i]}\n\n---\n> (续 ${i + 1}/${parts.length}) ${note}`; | ||
| await wsClient.sendMessage(session.chatId, { | ||
| msgtype: 'markdown', | ||
| markdown: { content: partText }, | ||
| }); | ||
| } | ||
| catch (err) { | ||
| log.error(`Failed to send continuation part ${i + 1}/${parts.length}:`, err); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| finally { | ||
| streamBusy = false; | ||
| } | ||
| }, | ||
| async sendStreamError(error) { | ||
| if (!session) | ||
| return; | ||
| // 等待飞行中的 replyStream 完成,避免并发调用 | ||
| await waitForStreamIdle(); | ||
| streamBusy = true; | ||
| const text = `❌ 错误\n\n${error}`; | ||
| try { | ||
| await wsClient.replyStream(session.frame, session.streamId, text, true); | ||
| } | ||
| catch (err) { | ||
| log.warn('Failed to send stream error, falling back to sendMessage:', err); | ||
| if (session.chatId) { | ||
| try { | ||
| await wsClient.sendMessage(session.chatId, { | ||
| msgtype: 'markdown', | ||
| markdown: { content: text }, | ||
| }); | ||
| } | ||
| catch (fallbackErr) { | ||
| log.error('Fallback sendMessage also failed:', fallbackErr); | ||
| } | ||
| } | ||
| } | ||
| finally { | ||
| streamBusy = false; | ||
| } | ||
| }, | ||
| cleanupStream() { | ||
| session = null; | ||
| streamBusy = false; | ||
| pendingStreamUpdate = null; | ||
| }, | ||
| async sendPermissionCard(chatId, requestId, toolName, toolInput) { | ||
| const inputSummary = buildInputSummary(toolName, toolInput); | ||
| try { | ||
| await wsClient.sendMessage(chatId, { | ||
| msgtype: 'template_card', | ||
| template_card: { | ||
| card_type: 'button_interaction', | ||
| main_title: { title: `🔐 权限确认 - ${toolName}` }, | ||
| sub_title_text: inputSummary.length > 200 ? inputSummary.slice(0, 200) + '...' : inputSummary, | ||
| task_id: `perm_${requestId}`, | ||
| button_list: [ | ||
| { text: '✅ 允许', style: 1, key: `perm_allow_${requestId}` }, | ||
| { text: '❌ 拒绝', style: 3, key: `perm_deny_${requestId}` }, | ||
| ], | ||
| }, | ||
| }); | ||
| } | ||
| catch (err) { | ||
| log.error('Failed to send permission card:', err); | ||
| } | ||
| // 企业微信通过 sendMessage 发送的模板卡片没有可追踪的 messageId。 | ||
| // 权限卡片的更新不依赖 messageId,而是通过 template_card_event 事件的回调帧完成(见 event-handler.ts)。 | ||
| return ''; | ||
| }, | ||
| async updatePermissionCard(params) { | ||
| // 企业微信的模板卡片更新通过 template_card_event 事件的回调帧完成 | ||
| // 在 event-handler 中处理,此处仅记录日志 | ||
| log.info(`Permission card update: ${params.toolName} ${params.decision} (handled via template_card_event)`); | ||
| }, | ||
| async sendImage(chatId, imagePath) { | ||
| // 企业微信智能机器人不支持独立发送图片消息 | ||
| log.info(`Image sending not supported in WeCom (path: ${imagePath})`); | ||
| }, | ||
| }; | ||
| } | ||
| /** | ||
| * 独立的 sendTextReply 函数,使用全局 WSClient | ||
| * 供 index.ts 等模块发送生命周期通知使用 | ||
| */ | ||
| export async function sendTextReply(chatId, text) { | ||
| try { | ||
| const client = getWSClient(); | ||
| await client.sendMessage(chatId, { | ||
| msgtype: 'markdown', | ||
| markdown: { content: text }, | ||
| }); | ||
| } | ||
| catch (err) { | ||
| log.error('Failed to send text reply (standalone):', err); | ||
| } | ||
| } |
@@ -40,2 +40,6 @@ import { spawn } from 'node:child_process'; | ||
| } | ||
| if (options?.proxyUrl) { | ||
| env.HTTPS_PROXY = options.proxyUrl; | ||
| env.HTTP_PROXY = options.proxyUrl; | ||
| } | ||
| const child = spawn(cliPath, args, { | ||
@@ -90,2 +94,6 @@ cwd: workDir, | ||
| } | ||
| if (isContentBlockStart(event) && event.event.content_block.type === 'thinking') { | ||
| accumulatedThinking = ''; | ||
| return; | ||
| } | ||
| if (isContentBlockStart(event) && event.event.content_block.type === 'tool_use') { | ||
@@ -92,0 +100,0 @@ const { name } = event.event.content_block; |
@@ -76,2 +76,3 @@ import { resolveLatestPermission, getPendingCount } from '../hook/permission-server.js'; | ||
| const threadsCmd = platform === 'feishu' ? '/threads - 列出所有话题会话\n' : ''; | ||
| const stopCmd = platform === 'wecom' ? '/stop - 停止当前运行的任务\n' : ''; | ||
| const helpText = [ | ||
@@ -93,2 +94,3 @@ '📋 可用命令:', | ||
| threadsCmd, | ||
| stopCmd, | ||
| '/allow (/y) - 允许权限请求(按钮不可用时的备选)', | ||
@@ -95,0 +97,0 @@ '/deny (/n) - 拒绝权限请求(按钮不可用时的备选)', |
+15
-1
@@ -48,2 +48,8 @@ try { | ||
| } | ||
| // 检测企业微信 | ||
| const wecomBotId = process.env.WECOM_BOT_ID ?? file.wecomBotId; | ||
| const wecomBotSecret = process.env.WECOM_BOT_SECRET ?? file.wecomBotSecret; | ||
| if (wecomBotId && wecomBotSecret) { | ||
| platforms.push('wecom'); | ||
| } | ||
| // 如果都没配置,抛出错误 | ||
@@ -53,3 +59,4 @@ if (platforms.length === 0) { | ||
| ' Telegram: 设置 TELEGRAM_BOT_TOKEN\n' + | ||
| ' 飞书: 设置 FEISHU_APP_ID 和 FEISHU_APP_SECRET'); | ||
| ' 飞书: 设置 FEISHU_APP_ID 和 FEISHU_APP_SECRET\n' + | ||
| ' 企业微信: 设置 WECOM_BOT_ID 和 WECOM_BOT_SECRET'); | ||
| } | ||
@@ -66,2 +73,5 @@ return platforms; | ||
| const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN ?? file.telegramBotToken ?? ''; | ||
| // 企业微信配置 | ||
| const wecomBotId = process.env.WECOM_BOT_ID ?? file.wecomBotId ?? ''; | ||
| const wecomBotSecret = process.env.WECOM_BOT_SECRET ?? file.wecomBotSecret ?? ''; | ||
| const allowedUserIds = process.env.ALLOWED_USER_IDS !== undefined | ||
@@ -114,2 +124,3 @@ ? parseCommaSeparated(process.env.ALLOWED_USER_IDS) | ||
| : file.hookPort ?? 18900; | ||
| const proxyUrl = process.env.PROXY_URL ?? file.proxyUrl; | ||
| const logDir = process.env.LOG_DIR ?? file.logDir ?? join(APP_HOME, 'logs'); | ||
@@ -122,2 +133,4 @@ const logLevel = (process.env.LOG_LEVEL?.toUpperCase() ?? file.logLevel ?? 'DEBUG'); | ||
| telegramBotToken, | ||
| wecomBotId, | ||
| wecomBotSecret, | ||
| allowedUserIds, | ||
@@ -130,2 +143,3 @@ claudeCliPath, | ||
| claudeModel: process.env.CLAUDE_MODEL ?? file.claudeModel, | ||
| proxyUrl, | ||
| hookPort, | ||
@@ -132,0 +146,0 @@ logDir, |
+15
-0
@@ -95,1 +95,16 @@ import { join } from 'node:path'; | ||
| }; | ||
| /** | ||
| * 企业微信流式更新节流时间(毫秒) | ||
| */ | ||
| export const WECOM_THROTTLE_MS = 200; | ||
| /** | ||
| * 企业微信流式消息续接阈值(毫秒) | ||
| * 企业微信流式消息有 6 分钟硬超时,设 5 分 30 秒触发续接 | ||
| */ | ||
| export const WECOM_STREAM_TIMEOUT_MS = 330_000; | ||
| /** | ||
| * 企业微信消息最大长度 | ||
| * replyStream 的 content 最长不超过 20480 字节(utf-8) | ||
| * 为安全起见,以字符计限制在 4000 | ||
| */ | ||
| export const MAX_WECOM_MESSAGE_LENGTH = 4000; |
@@ -140,3 +140,3 @@ import { join } from 'node:path'; | ||
| }, | ||
| onThinkingToText: (content) => { | ||
| onThinkingToText: (content, _thinkingText) => { | ||
| const resetCard = buildCardV2({ content: content || '...', status: 'streaming' }, cardId); | ||
@@ -143,0 +143,0 @@ updateCardFull(cardId, resetCard) |
+31
-2
@@ -9,2 +9,5 @@ import { loadConfig } from './config.js'; | ||
| import { sendTextReply as telegramSendText } from './telegram/message-sender.js'; | ||
| import { initWecom, stopWecom } from './wecom/client.js'; | ||
| import { setupWecomHandlers } from './wecom/event-handler.js'; | ||
| import { sendTextReply as wecomSendText } from './wecom/message-sender.js'; | ||
| import { startPermissionServer } from './hook/permission-server.js'; | ||
@@ -39,3 +42,3 @@ import { ensureHookConfigured } from './hook/ensure-hook.js'; | ||
| } | ||
| const sender = platform === 'feishu' ? feishuSendText : telegramSendText; | ||
| const sender = platform === 'feishu' ? feishuSendText : platform === 'wecom' ? wecomSendText : telegramSendText; | ||
| tasks.push(sender(chatId, message).catch((err) => { | ||
@@ -58,2 +61,5 @@ log.debug(`Failed to send ${bot} lifecycle notification:`, err); | ||
| log.info(`Timeout: ${config.claudeTimeoutMs}ms`); | ||
| if (config.proxyUrl) { | ||
| log.info(`Proxy: ${config.proxyUrl}`); | ||
| } | ||
| log.info(`Allowed base dirs: ${config.allowedBaseDirs.length} dirs configured`); | ||
@@ -73,2 +79,3 @@ let permissionServer = null; | ||
| let telegramHandle = null; | ||
| let wecomHandle = null; | ||
| if (config.enabledPlatforms.includes('telegram')) { | ||
@@ -109,2 +116,18 @@ log.debug('Initializing Telegram platform...'); | ||
| } | ||
| if (config.enabledPlatforms.includes('wecom')) { | ||
| log.debug('Initializing WeChat Work (WeCom) platform...'); | ||
| initTasks.push(initWecom(config, (wsClient) => { | ||
| wecomHandle = setupWecomHandlers(wsClient, config, sessionManager); | ||
| return wecomHandle; | ||
| }) | ||
| .then(() => { | ||
| log.info('WeCom bot initialized'); | ||
| return { platform: 'WeCom', success: true }; | ||
| }) | ||
| .catch((err) => { | ||
| log.error('Failed to initialize WeCom bot:', err); | ||
| log.warn('Continuing without WeCom support'); | ||
| return { platform: 'WeCom', success: false }; | ||
| })); | ||
| } | ||
| const results = await Promise.all(initTasks); | ||
@@ -164,2 +187,6 @@ for (const result of results) { | ||
| } | ||
| wecomHandle?.stop(); | ||
| if (config.enabledPlatforms.includes('wecom')) { | ||
| stopWecom(); | ||
| } | ||
| permissionServer?.close(); | ||
@@ -175,3 +202,5 @@ // 持久化会话和活跃聊天数据 | ||
| const start = Date.now(); | ||
| const getTotalTasks = () => (feishuHandle?.getRunningTaskCount() ?? 0) + (telegramHandle?.getRunningTaskCount() ?? 0); | ||
| const getTotalTasks = () => (feishuHandle?.getRunningTaskCount() ?? 0) + | ||
| (telegramHandle?.getRunningTaskCount() ?? 0) + | ||
| (wecomHandle?.getRunningTaskCount() ?? 0); | ||
| let remaining = getTotalTasks(); | ||
@@ -178,0 +207,0 @@ if (remaining > 0) { |
@@ -151,3 +151,3 @@ /** | ||
| taskState.latestContent = accumulated; | ||
| adapter.onThinkingToText(accumulated); | ||
| adapter.onThinkingToText(accumulated, thinkingText); | ||
| return; | ||
@@ -238,2 +238,3 @@ } | ||
| platform: ctx.platform, | ||
| proxyUrl: config.proxyUrl, | ||
| }); | ||
@@ -240,0 +241,0 @@ taskState = { handle, latestContent: '', settle, startedAt: Date.now() }; |
+12
-0
@@ -26,2 +26,4 @@ /** | ||
| NotebookEdit: '📓', | ||
| Agent: '🤖', | ||
| Skill: '⚡', | ||
| }; | ||
@@ -171,2 +173,12 @@ function getToolEmoji(toolName) { | ||
| break; | ||
| case 'Agent': | ||
| if (toolInput.prompt) | ||
| detail = ` → ${truncate(String(toolInput.prompt), 60)}`; | ||
| else if (toolInput.description) | ||
| detail = ` → ${truncate(String(toolInput.description), 60)}`; | ||
| break; | ||
| case 'Skill': | ||
| if (toolInput.skill) | ||
| detail = ` → ${truncate(String(toolInput.skill), 40)}`; | ||
| break; | ||
| default: | ||
@@ -173,0 +185,0 @@ break; |
+2
-1
| { | ||
| "name": "cc-im", | ||
| "version": "1.3.0", | ||
| "version": "1.4.0", | ||
| "description": "Multi-platform bot bridge (Feishu & Telegram) for Claude Code CLI", | ||
@@ -39,2 +39,3 @@ "repository": { | ||
| "@larksuiteoapi/node-sdk": "^1.59.0", | ||
| "@wecom/aibot-node-sdk": "^1.0.1", | ||
| "telegraf": "^4.16.3" | ||
@@ -41,0 +42,0 @@ }, |
+34
-5
| # cc-im | ||
| 多平台(飞书 & Telegram)机器人 ↔ Claude Code CLI 桥接服务。 | ||
| 多平台(飞书 & Telegram & 企业微信)机器人 ↔ Claude Code CLI 桥接服务。 | ||
| 用户在飞书或 Telegram 中发消息,服务器接收后调用 Claude Code 执行,并将输出实时流式推送回聊天窗口。 | ||
| 用户在飞书、Telegram 或企业微信中发消息,服务器接收后调用 Claude Code 执行,并将输出实时流式推送回聊天窗口。 | ||
| ## 功能 | ||
| - **多平台支持**:飞书和 Telegram,可同时运行或单独使用 | ||
| - **流式输出**:飞书端使用 CardKit 打字机效果,Telegram 端通过 editMessage 实时更新 | ||
| - **多平台支持**:飞书、Telegram 和企业微信,可同时运行或单独使用 | ||
| - **流式输出**:飞书端使用 CardKit 打字机效果,Telegram 端通过 editMessage 实时更新,企业微信端使用 replyStream 原生流式回复 | ||
| - **思考过程展示**:实时显示 Claude 的思考过程(折叠面板) | ||
@@ -36,3 +36,3 @@ - **工具调用通知**:流式显示当前正在使用的工具及参数摘要 | ||
| 可以同时启用飞书和 Telegram,只需配置两个平台的凭证即可: | ||
| 可以同时启用多个平台,只需配置对应平台的凭证即可: | ||
@@ -43,2 +43,4 @@ ```bash | ||
| export TELEGRAM_BOT_TOKEN=your_bot_token | ||
| export WECOM_BOT_ID=your_bot_id | ||
| export WECOM_BOT_SECRET=your_bot_secret | ||
| npx cc-im@latest | ||
@@ -87,2 +89,17 @@ ``` | ||
| ### 企业微信平台 | ||
| 1. 在[企业微信管理后台](https://work.weixin.qq.com)创建智能机器人应用 | ||
| 2. 获取机器人的 Bot ID 和 Secret | ||
| 3. 配置并启动: | ||
| ```bash | ||
| export WECOM_BOT_ID=your_bot_id | ||
| export WECOM_BOT_SECRET=your_bot_secret | ||
| npx cc-im@latest | ||
| ``` | ||
| 4. 在企业微信中找到你的机器人,发送消息开始使用 | ||
| 5. 群聊中需要 @机器人 才会响应 | ||
| ### 从源码构建 | ||
@@ -131,2 +148,3 @@ | ||
| | `/threads` | 列出所有话题会话(飞书) | | ||
| | `/stop` | 停止当前运行的任务(企业微信) | | ||
| | `/allow` 或 `/y` | 允许权限请求(按钮不可用时的备选) | | ||
@@ -142,2 +160,4 @@ | `/deny` 或 `/n` | 拒绝权限请求(按钮不可用时的备选) | | ||
| | `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Telegram 平台必填 | | ||
| | `WECOM_BOT_ID` | 企业微信机器人 Bot ID | 企业微信平台必填 | | ||
| | `WECOM_BOT_SECRET` | 企业微信机器人 Secret | 企业微信平台必填 | | ||
| | `ALLOWED_USER_IDS` | 白名单用户 ID,逗号分隔,留空不限制 | 空(不限制) | | ||
@@ -150,2 +170,3 @@ | `CLAUDE_CLI_PATH` | Claude CLI 可执行文件路径 | `claude` | | ||
| | `CLAUDE_MODEL` | 默认模型(如 `sonnet`、`opus`、`haiku`) | 空(由 Claude Code 决定) | | ||
| | `PROXY_URL` | 代理地址,传递给 Claude CLI(如 `http://127.0.0.1:7890`) | 空 | | ||
| | `HOOK_SERVER_PORT` | 权限确认 Hook 服务端口 | `18900` | | ||
@@ -159,2 +180,3 @@ | `LOG_DIR` | 日志文件存储目录 | `~/.cc-im/logs` | | ||
| - **Telegram**:用户数字 ID,如 `123456789`(可通过 [@userinfobot](https://t.me/userinfobot) 获取) | ||
| - **企业微信**:企业微信 userid,如 `zhangsan` | ||
@@ -170,2 +192,4 @@ ## 配置文件 | ||
| "telegramBotToken": "your_bot_token", | ||
| "wecomBotId": "", | ||
| "wecomBotSecret": "", | ||
| "allowedUserIds": ["123456789"], | ||
@@ -178,2 +202,3 @@ "claudeCliPath": "/usr/local/bin/claude", | ||
| "claudeModel": "sonnet", | ||
| "proxyUrl": "http://127.0.0.1:7890", | ||
| "hookPort": 18900, | ||
@@ -273,2 +298,6 @@ "logDir": "/var/log/cc-im", | ||
| │ └── message-sender.ts # Telegram 消息发送 | ||
| ├── wecom/ | ||
| │ ├── client.ts # 企业微信 WSClient 初始化 | ||
| │ ├── event-handler.ts # 企业微信事件处理 | ||
| │ └── message-sender.ts # 企业微信消息发送(流式回复、权限卡片) | ||
| ├── hook/ | ||
@@ -275,0 +304,0 @@ │ ├── permission-server.ts # 权限确认 HTTP 服务 |
Sorry, the diff of this file is not supported yet
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 3 instances
243796
19.09%39
8.33%5748
18.03%315
10.14%3
50%35
16.67%+ Added
+ Added
+ Added