🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

cc-im

Package Overview
Dependencies
Maintainers
1
Versions
9
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

cc-im - npm Package Compare versions

Comparing version
1.3.0
to
1.4.0
+57
dist/wecom/client.js
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);
}
}
+8
-0

@@ -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;

+2
-0

@@ -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) - 拒绝权限请求(按钮不可用时的备选)',

@@ -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,

@@ -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;
+1
-1

@@ -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)

@@ -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() };

@@ -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;

{
"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 @@ },

# 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