🚀 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.4.0
to
1.5.0
+25
dist/feishu/permission-handler.js
import { registerPermissionSender, resolvePermissionById } from '../hook/permission-server.js';
import { sendPermissionCard, updatePermissionCard } from './message-sender.js';
import { createLogger } from '../logger.js';
const log = createLogger('FeishuPermission');
/**
* 注册飞书平台的权限消息发送器
*/
export function registerFeishuPermissionSender() {
registerPermissionSender('feishu', {
sendPermissionCard,
updatePermissionCard: ({ messageId, toolName, decision }) => updatePermissionCard(messageId, toolName, decision),
});
}
/**
* 处理权限按钮点击(allow/deny)
*/
export function handlePermissionAction(requestId, decision) {
const resolvedId = resolvePermissionById(requestId, decision);
if (resolvedId) {
log.info(`Permission ${decision} via button for request ${requestId}`);
}
else {
log.warn(`No pending permission request found for requestId: ${requestId}`);
}
}
import { sendThinkingCard, streamContentUpdate, sendFinalCards, sendErrorCard, sendTextReply, uploadAndSendImage } from './message-sender.js';
import { buildCardV2 } from './card-builder.js';
import { destroySession, updateCardFull, disableStreaming } from './cardkit-manager.js';
import { runClaudeTask } from '../shared/claude-task.js';
import { CARDKIT_THROTTLE_MS } from '../constants.js';
import { createLogger } from '../logger.js';
const log = createLogger('FeishuTask');
export async function executeClaudeTask(deps, userId, chatId, prompt, workDir, convId, threadCtx, mentionedBot, isGroup) {
const { config, sessionManager, userCosts, runningTasks } = deps;
const sessionId = threadCtx && threadCtx.threadId
? sessionManager.getSessionIdForThread(userId, threadCtx.threadId)
: convId
? sessionManager.getSessionIdForConv(userId, convId)
: undefined;
log.info(`Running Claude for user ${userId}, ${threadCtx ? `thread=${threadCtx.threadId}` : `convId=${convId}`}, workDir=${workDir}, sessionId=${sessionId ?? 'new'}`);
let cardHandle;
try {
cardHandle = await sendThinkingCard(chatId, threadCtx);
}
catch (err) {
log.error('Failed to send thinking card:', err);
return;
}
const { messageId, cardId } = cardHandle;
if (!cardId) {
log.error('No card_id returned for thinking card');
return;
}
const taskKey = `${userId}:${cardId}`;
let waitingTimer = null;
await runClaudeTask({ config, sessionManager, userCosts }, {
userId,
chatId,
workDir,
sessionId,
convId,
threadId: threadCtx?.threadId,
threadRootMsgId: threadCtx?.rootMessageId,
platform: 'feishu',
taskKey,
}, prompt, {
throttleMs: CARDKIT_THROTTLE_MS,
streamUpdate: (content, toolNote) => {
streamContentUpdate(cardId, content, toolNote).catch((e) => log.warn('Stream update failed:', e?.message ?? e));
},
sendComplete: async (content, note, thinkingText) => {
try {
await sendFinalCards(chatId, messageId, cardId, content, note, threadCtx, thinkingText);
if (isGroup && mentionedBot) {
const replyText = `<at user_id="${userId}"></at> 任务已完成 ✅`;
await sendTextReply(chatId, replyText, threadCtx);
}
}
catch (err) {
log.error('Failed to send final cards:', err);
}
},
sendError: async (error) => {
try {
await sendErrorCard(cardId, error);
}
catch (err) {
log.error('Failed to send error card:', err);
}
},
onThinkingToText: (content, _thinkingText) => {
const resetCard = buildCardV2({ content: content || '...', status: 'streaming' }, cardId);
updateCardFull(cardId, resetCard)
.catch((e) => log.warn('Thinking→text transition update failed:', e?.message ?? e));
},
extraCleanup: () => {
if (waitingTimer) {
clearInterval(waitingTimer);
waitingTimer = null;
}
runningTasks.delete(taskKey);
},
onTaskReady: (state) => {
runningTasks.set(taskKey, { ...state, cardId, messageId });
const startTime = Date.now();
waitingTimer = setInterval(() => {
const taskInfo = runningTasks.get(taskKey);
if (!taskInfo) {
if (waitingTimer) {
clearInterval(waitingTimer);
waitingTimer = null;
}
return;
}
const elapsed = Math.floor((Date.now() - startTime) / 1000);
streamContentUpdate(cardId, `等待 Claude 响应... (${elapsed}s)`).catch(() => { });
}, 3000);
},
onFirstContent: () => {
if (waitingTimer) {
clearInterval(waitingTimer);
waitingTimer = null;
}
},
sendImage: (imagePath) => uploadAndSendImage(chatId, imagePath, threadCtx),
});
}
export function handleStopAction(runningTasks, userId, cardId) {
const taskKey = `${userId}:${cardId}`;
const taskInfo = runningTasks.get(taskKey);
if (taskInfo) {
log.info(`User ${userId} stopped task for card ${cardId}`);
const stoppedContent = taskInfo.latestContent || '(任务已停止,暂无输出)';
runningTasks.delete(taskKey);
taskInfo.settle();
taskInfo.handle.abort();
const stoppedCard = buildCardV2({ content: stoppedContent, status: 'done', note: '⏹️ 已停止' });
disableStreaming(cardId)
.then(() => updateCardFull(cardId, stoppedCard))
.catch((e) => log.warn('Stop card update failed:', e?.message ?? e))
.finally(() => destroySession(cardId));
}
else {
log.warn(`No running task found for key: ${taskKey}`);
log.info(`Current running tasks: ${Array.from(runningTasks.keys()).join(', ')}`);
}
}
+53
-6

@@ -8,3 +8,3 @@ import { resolveLatestPermission, getPendingCount } from '../hook/permission-server.js';

import { homedir } from 'node:os';
import { getHistory, formatHistoryPage } from '../shared/history.js';
import { getHistory, formatHistoryPage, getSessionList, formatSessionList } from '../shared/history.js';
/**

@@ -63,2 +63,5 @@ * 共享的命令处理器

}
if (trimmed === '/resume' || trimmed.startsWith('/resume ')) {
return this.handleResume(chatId, userId, trimmed.slice(7).trim(), threadCtx);
}
// 仅终端可用的命令

@@ -93,3 +96,4 @@ const cmdName = trimmed.split(/\s+/)[0];

'/list - 列出所有工作区',
'/history [页码] - 浏览会话历史记录',
'/history [页码] - 查看当前会话聊天记录',
'/resume [序号] - 浏览/恢复历史会话',
threadsCmd,

@@ -131,3 +135,3 @@ stopCmd,

async handleCd(chatId, userId, args, threadCtx) {
const dir = args.trim();
let dir = args.trim();
if (!dir) {

@@ -146,2 +150,12 @@ const workDir = threadCtx

}
// 支持通过序号切换(对应 /list 的编号)
if (/^\d+$/.test(dir)) {
const index = parseInt(dir, 10) - 1;
const dirs = this.listClaudeProjects();
if (index < 0 || index >= dirs.length) {
await this.deps.sender.sendTextReply(chatId, `无效的序号 ${dir},请使用 /list 查看可用工作区。`, threadCtx);
return true;
}
dir = dirs[index];
}
try {

@@ -181,4 +195,4 @@ const resolved = threadCtx

: this.deps.sessionManager.getWorkDir(userId);
const lines = dirs.map((d) => (d === current ? `▶ ${d}` : ` ${d}`));
await this.deps.sender.sendTextReply(chatId, `Claude Code 工作区列表:\n${lines.join('\n')}\n\n使用 /cd <路径> 切换`, threadCtx);
const lines = dirs.map((d, i) => (d === current ? `▶ ${i + 1}. ${d}` : ` ${i + 1}. ${d}`));
await this.deps.sender.sendTextReply(chatId, `Claude Code 工作区列表:\n${lines.join('\n')}\n\n使用 /cd <序号> 或 /cd <路径> 切换`, threadCtx);
}

@@ -351,3 +365,3 @@ return true;

async handleHistory(chatId, userId, args, threadCtx) {
const page = parseInt(args, 10) || 1;
const page = args ? (parseInt(args, 10) || 1) : 0;
const workDir = threadCtx

@@ -369,2 +383,35 @@ ? this.deps.sessionManager.getWorkDirForThread(userId, threadCtx.threadId)

/**
* 处理 /resume 命令 - 浏览/恢复历史会话
*/
async handleResume(chatId, userId, args, threadCtx) {
const workDir = threadCtx
? this.deps.sessionManager.getWorkDirForThread(userId, threadCtx.threadId)
: this.deps.sessionManager.getWorkDir(userId);
const currentSessionId = threadCtx
? this.deps.sessionManager.getSessionIdForThread(userId, threadCtx.threadId)
: this.deps.sessionManager.getSessionIdForConv(userId, this.deps.sessionManager.getConvId(userId));
const listResult = await getSessionList(workDir, currentSessionId);
if (!listResult.ok) {
await this.deps.sender.sendTextReply(chatId, listResult.error, threadCtx);
return true;
}
if (!args) {
await this.deps.sender.sendTextReply(chatId, formatSessionList(listResult.data), threadCtx);
return true;
}
const index = parseInt(args, 10) - 1;
if (isNaN(index) || index < 0 || index >= listResult.data.length) {
await this.deps.sender.sendTextReply(chatId, `无效的序号 ${args},共 ${listResult.data.length} 个会话。`, threadCtx);
return true;
}
const target = listResult.data[index];
if (target.isCurrent) {
await this.deps.sender.sendTextReply(chatId, '该会话已是当前会话。', threadCtx);
return true;
}
this.deps.sessionManager.resumeSession(userId, target.sessionId);
await this.deps.sender.sendTextReply(chatId, `已恢复会话: ${target.preview}\n后续消息将延续该会话上下文。`, threadCtx);
return true;
}
/**
* 处理 /threads 命令 - 列出所有话题会话

@@ -371,0 +418,0 @@ */

+0
-1

@@ -32,3 +32,2 @@ import { join } from 'node:path';

'/rewind',
'/resume',
'/copy',

@@ -35,0 +34,0 @@ '/export',

@@ -6,13 +6,11 @@ import { join } from 'node:path';

import { RequestQueue } from '../queue/request-queue.js';
import { sendThinkingCard, streamContentUpdate, sendFinalCards, sendErrorCard, sendTextReply, sendPermissionCard, updatePermissionCard, fetchThreadDescription, uploadAndSendImage } from './message-sender.js';
import { sendTextReply, fetchThreadDescription } from './message-sender.js';
import { getClient, getBotOpenId } from './client.js';
import { buildCardV2 } from './card-builder.js';
import { destroySession, updateCardFull, disableStreaming } from './cardkit-manager.js';
import { registerPermissionSender, resolvePermissionById } from '../hook/permission-server.js';
import { registerFeishuPermissionSender, handlePermissionAction } from './permission-handler.js';
import { CommandHandler } from '../commands/handler.js';
import { safeStringify } from '../shared/utils.js';
import { runClaudeTask } from '../shared/claude-task.js';
import { startTaskCleanup } from '../shared/task-cleanup.js';
import { MessageDedup } from '../shared/message-dedup.js';
import { CARDKIT_THROTTLE_MS, IMAGE_DIR } from '../constants.js';
import { IMAGE_DIR } from '../constants.js';
import { executeClaudeTask, handleStopAction } from './task-executor.js';
import { setActiveChatId } from '../shared/active-chats.js';

@@ -77,105 +75,4 @@ import { createLogger } from '../logger.js';

// Register feishu permission sender
registerPermissionSender('feishu', {
sendPermissionCard,
updatePermissionCard: ({ messageId, toolName, decision }) => updatePermissionCard(messageId, toolName, decision),
});
registerFeishuPermissionSender();
// ─── 内部函数(闭包访问 runningTasks 等) ───
async function handleClaudeRequest(userId, chatId, prompt, workDir, convId, threadCtx, mentionedBot, isGroup) {
const sessionId = threadCtx && threadCtx.threadId
? sessionManager.getSessionIdForThread(userId, threadCtx.threadId)
: convId
? sessionManager.getSessionIdForConv(userId, convId)
: undefined;
log.info(`Running Claude for user ${userId}, ${threadCtx ? `thread=${threadCtx.threadId}` : `convId=${convId}`}, workDir=${workDir}, sessionId=${sessionId ?? 'new'}`);
let cardHandle;
try {
cardHandle = await sendThinkingCard(chatId, threadCtx);
}
catch (err) {
log.error('Failed to send thinking card:', err);
return;
}
const { messageId, cardId } = cardHandle;
if (!cardId) {
log.error('No card_id returned for thinking card');
return;
}
const finalThreadCtx = threadCtx;
const taskKey = `${userId}:${cardId}`;
let waitingTimer = null;
await runClaudeTask({ config, sessionManager, userCosts }, {
userId,
chatId,
workDir,
sessionId,
convId,
threadId: finalThreadCtx?.threadId,
threadRootMsgId: finalThreadCtx?.rootMessageId,
platform: 'feishu',
taskKey,
}, prompt, {
throttleMs: CARDKIT_THROTTLE_MS,
streamUpdate: (content, toolNote) => {
streamContentUpdate(cardId, content, toolNote).catch((e) => log.warn('Stream update failed:', e?.message ?? e));
},
sendComplete: async (content, note, thinkingText) => {
try {
await sendFinalCards(chatId, messageId, cardId, content, note, finalThreadCtx, thinkingText);
// 如果是群聊且被@了,任务完成后@回发送者
if (isGroup && mentionedBot) {
const replyText = `<at user_id="${userId}"></at> 任务已完成 ✅`;
await sendTextReply(chatId, replyText, finalThreadCtx);
}
}
catch (err) {
log.error('Failed to send final cards:', err);
}
},
sendError: async (error) => {
try {
await sendErrorCard(cardId, error);
}
catch (err) {
log.error('Failed to send error card:', err);
}
},
onThinkingToText: (content, _thinkingText) => {
const resetCard = buildCardV2({ content: content || '...', status: 'streaming' }, cardId);
updateCardFull(cardId, resetCard)
.catch((e) => log.warn('Thinking→text transition update failed:', e?.message ?? e));
},
extraCleanup: () => {
if (waitingTimer) {
clearInterval(waitingTimer);
waitingTimer = null;
}
runningTasks.delete(taskKey);
},
onTaskReady: (state) => {
runningTasks.set(taskKey, { ...state, cardId, messageId });
// 等待首次内容期间,每3秒更新卡片显示已等待时间
const startTime = Date.now();
waitingTimer = setInterval(() => {
const taskInfo = runningTasks.get(taskKey);
if (!taskInfo) {
if (waitingTimer) {
clearInterval(waitingTimer);
waitingTimer = null;
}
return;
}
const elapsed = Math.floor((Date.now() - startTime) / 1000);
streamContentUpdate(cardId, `等待 Claude 响应... (${elapsed}s)`).catch(() => { });
}, 3000);
},
onFirstContent: () => {
// 首次内容到达,清除等待计时器
if (waitingTimer) {
clearInterval(waitingTimer);
waitingTimer = null;
}
},
sendImage: (imagePath) => uploadAndSendImage(chatId, imagePath, finalThreadCtx),
});
}
async function routeToThread(userId, chatId, threadId, rootMessageId, text, mentionedBot) {

@@ -206,3 +103,3 @@ let threadSession = sessionManager.getThreadSession(userId, threadId);

const enqueueResult = requestQueue.enqueue(userId, threadId, text, async (prompt) => {
await handleClaudeRequest(userId, chatId, prompt, workDir, undefined, threadCtx, mentionedBot, true);
await executeClaudeTask({ config, sessionManager, userCosts, runningTasks }, userId, chatId, prompt, workDir, undefined, threadCtx, mentionedBot, true);
});

@@ -221,3 +118,3 @@ if (enqueueResult === 'rejected') {

const enqueueResult = requestQueue.enqueue(userId, convIdSnapshot, text, async (prompt) => {
await handleClaudeRequest(userId, chatId, prompt, workDirSnapshot, convIdSnapshot, undefined, mentionedBot, isGroup);
await executeClaudeTask({ config, sessionManager, userCosts, runningTasks }, userId, chatId, prompt, workDirSnapshot, convIdSnapshot, undefined, mentionedBot, isGroup);
});

@@ -279,26 +176,6 @@ if (enqueueResult === 'rejected') {

}
const taskKey = `${userId}:${cardId}`;
const taskInfo = runningTasks.get(taskKey);
log.debug(`Stop button clicked - taskKey: ${taskKey}, handle exists: ${!!taskInfo}`);
if (taskInfo) {
log.info(`User ${userId} stopped task for card ${cardId}`);
const stoppedContent = taskInfo.latestContent || '(任务已停止,暂无输出)';
runningTasks.delete(taskKey);
taskInfo.settle();
taskInfo.handle.abort();
const stoppedCard = buildCardV2({ content: stoppedContent, status: 'done', note: '⏹️ 已停止' });
disableStreaming(cardId)
.then(() => updateCardFull(cardId, stoppedCard))
.catch((e) => log.warn('Stop card update failed:', e?.message ?? e))
.finally(() => destroySession(cardId));
}
else {
log.warn(`No running task found for key: ${taskKey}`);
log.info(`Current running tasks: ${Array.from(runningTasks.keys()).join(', ')}`);
}
handleStopAction(runningTasks, userId, cardId);
}
else if (actionData.action === 'allow' || actionData.action === 'deny') {
// 处理权限按钮点击
const requestId = actionData.requestId;
const decision = actionData.action;
if (!requestId) {

@@ -308,9 +185,3 @@ log.warn('No requestId in permission action');

}
const resolvedId = resolvePermissionById(requestId, decision);
if (resolvedId) {
log.info(`Permission ${decision} via button for request ${requestId}`);
}
else {
log.warn(`No pending permission request found for requestId: ${requestId}`);
}
handlePermissionAction(requestId, actionData.action);
}

@@ -454,3 +325,3 @@ },

log.info(`User ${senderId}${isImageMessage ? ' [image]' : ''}: ${text.slice(0, 100)}${text.length > 100 ? '...' : ''}`);
if (!isImageMessage && await commandHandler.dispatch(text, chatId, senderId, 'feishu', handleClaudeRequest, threadCtx)) {
if (!isImageMessage && await commandHandler.dispatch(text, chatId, senderId, 'feishu', (userId, chatId, prompt, workDir, convId, threadCtx) => executeClaudeTask({ config, sessionManager, userCosts, runningTasks }, userId, chatId, prompt, workDir, convId, threadCtx), threadCtx)) {
return;

@@ -457,0 +328,0 @@ }

@@ -124,2 +124,6 @@ #!/usr/bin/env node

}
main();
/* c8 ignore next 3 */
const isDirectRun = process.argv[1]?.endsWith('hook-script.js');
if (isDirectRun)
main();
export { main, readStdin, httpPost };

@@ -143,2 +143,18 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';

}
resumeSession(userId, sessionId) {
const session = this.sessions.get(userId);
if (!session)
return false;
// 转存旧 convId 的 sessionId,供仍在运行的旧任务使用
if (session.activeConvId && session.sessionId) {
this.convSessionMap.set(`${userId}:${session.activeConvId}`, session.sessionId);
this.pruneConvSessionMap();
}
session.sessionId = sessionId;
session.activeConvId = this.generateConvId();
session.totalTurns = 0;
this.flushSync();
log.info(`Resumed session for user ${userId}: ${sessionId}`);
return true;
}
addTurns(userId, turns) {

@@ -145,0 +161,0 @@ const session = this.sessions.get(userId);

/**
* 共享的 Claude 任务执行逻辑
* 封装两个平台重复的节流更新、完成统计、竞态保护等代码
* 封装各平台重复的节流更新、完成统计、竞态保护等代码
*/

@@ -5,0 +5,0 @@ import { access } from 'node:fs/promises';

@@ -68,3 +68,3 @@ import { readFile, readdir, stat } from 'node:fs/promises';

const totalPages = Math.ceil(entries.length / PAGE_SIZE);
const p = Math.max(1, Math.min(page, totalPages));
const p = page <= 0 ? totalPages : Math.max(1, Math.min(page, totalPages));
const start = (p - 1) * PAGE_SIZE;

@@ -74,2 +74,97 @@ const pageEntries = entries.slice(start, start + PAGE_SIZE);

}
function formatTimestamp(ts) {
if (!ts)
return '';
try {
const d = new Date(ts);
const now = new Date();
const pad = (n) => String(n).padStart(2, '0');
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()) {
return `[${time}]`;
}
return `[${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}]`;
}
catch {
return '';
}
}
const MAX_SESSION_LIST = 10;
const PREVIEW_LENGTH = 100;
function parseSessionFile(raw) {
let messageCount = 0;
let preview = '';
for (const line of raw.split('\n')) {
if (!line)
continue;
try {
const obj = JSON.parse(line);
if (obj.type !== 'user' && obj.type !== 'assistant')
continue;
const msg = obj.message;
if (!msg)
continue;
const text = extractText(msg.content).trim();
if (!text)
continue;
if (text.startsWith('<local-command') || text.startsWith('<command-name>'))
continue;
messageCount++;
if (!preview && msg.role === 'user') {
preview = text.length > PREVIEW_LENGTH ? text.slice(0, PREVIEW_LENGTH) + '...' : text;
}
}
catch { /* skip */ }
}
return { messageCount, preview };
}
export async function getSessionList(workDir, currentSessionId) {
const projectDir = join(homedir(), '.claude', 'projects', encodeWorkDir(workDir));
let files;
try {
files = (await readdir(projectDir)).filter(f => f.endsWith('.jsonl'));
}
catch {
return { ok: false, error: '当前工作区未找到会话记录。' };
}
if (files.length === 0)
return { ok: false, error: '当前工作区未找到会话记录。' };
const withMtime = await Promise.all(files.map(async (f) => ({
f,
mtime: (await stat(join(projectDir, f)).catch(() => ({ mtimeMs: 0 }))).mtimeMs,
})));
withMtime.sort((a, b) => b.mtime - a.mtime);
const recent = withMtime.slice(0, MAX_SESSION_LIST);
const items = await Promise.all(recent.map(async ({ f, mtime }) => {
const sessionId = f.replace('.jsonl', '');
let messageCount = 0;
let preview = '';
try {
const raw = await readFile(join(projectDir, f), 'utf-8');
({ messageCount, preview } = parseSessionFile(raw));
}
catch { /* skip */ }
return {
sessionId,
mtime,
messageCount,
preview: preview || '(空会话)',
isCurrent: sessionId === currentSessionId,
};
}));
return { ok: true, data: items };
}
export function formatSessionList(items) {
const lines = ['📋 会话列表', ''];
for (let i = 0; i < items.length; i++) {
const s = items[i];
const d = new Date(s.mtime);
const pad = (n) => String(n).padStart(2, '0');
const date = `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
const current = s.isCurrent ? '\n ▶ 当前会话' : '';
lines.push(`${i + 1}. ${date} | ${s.messageCount}条 | ${s.preview}${current}`);
}
lines.push('', '使用 /resume <序号> 恢复会话');
return lines.join('\n');
}
export function formatHistoryPage(result) {

@@ -79,9 +174,13 @@ const lines = [`📜 会话历史 (${result.page}/${result.totalPages}) — ${result.sessionId.slice(-8)}`, ''];

const prefix = e.role === 'user' ? '👤' : '🤖';
const preview = e.text.length > 300 ? e.text.slice(0, 297) + '...' : e.text;
lines.push(`${prefix} ${preview}`);
const ts = formatTimestamp(e.timestamp);
lines.push(ts ? `${ts} ${prefix} ${e.text}` : `${prefix} ${e.text}`);
}
if (result.page < result.totalPages) {
lines.push('', `使用 /history ${result.page + 1} 查看下一页`);
}
const nav = [];
if (result.page > 1)
nav.push(`/history ${result.page - 1} 上一页`);
if (result.page < result.totalPages)
nav.push(`/history ${result.page + 1} 下一页`);
if (nav.length > 0)
lines.push('', nav.join(' | '));
return lines.join('\n');
}

@@ -19,4 +19,8 @@ import { createLogger } from '../logger.js';

catch (err) {
if (err instanceof NonRetryableError || attempt >= maxRetries)
if (err instanceof NonRetryableError)
throw err;
if (opts?.shouldRetry && !opts.shouldRetry(err))
throw err;
if (attempt >= maxRetries)
throw err;
const delay = Math.min(baseDelay * 2 ** attempt + Math.random() * 200, maxDelay);

@@ -23,0 +27,0 @@ log.warn(`Retry ${attempt + 1}/${maxRetries} after ${Math.round(delay)}ms: ${err?.message ?? err}`);

@@ -6,2 +6,3 @@ import { getBot } from './client.js';

import { MAX_TELEGRAM_MESSAGE_LENGTH } from '../constants.js';
import { withRetry } from '../shared/retry.js';
const log = createLogger('TgSender');

@@ -62,20 +63,16 @@ const MAX_RETRIES = 3;

}
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const result = await fn();
return result;
}
catch (err) {
return withRetry(fn, {
maxRetries: MAX_RETRIES - 1, // withRetry counts retries after first attempt
baseDelayMs: 1000,
maxDelayMs: RATE_LIMIT_MAX_WAIT_SEC * 1000,
shouldRetry: (err) => {
const retryAfter = parseRetryAfter(err);
if (retryAfter !== null && attempt < MAX_RETRIES) {
const waitSec = Math.min(retryAfter, RATE_LIMIT_MAX_WAIT_SEC);
if (retryAfter !== null) {
setCooldown(chatId, retryAfter);
log.warn(`${label}: rate limited, waiting ${waitSec}s before retry (attempt ${attempt}/${MAX_RETRIES})`);
await new Promise((r) => setTimeout(r, waitSec * 1000));
continue;
log.warn(`${label}: rate limited, retry after ${retryAfter}s`);
return true;
}
throw err;
}
}
throw new Error('unreachable');
return false; // 非 429 错误不重试
},
});
}

@@ -82,0 +79,0 @@ const STATUS_ICONS = {

@@ -169,3 +169,3 @@ import { join } from 'node:path';

if (stallChecks >= 2) {
// 停滞 6s+ 才触发,避免正常节流间隔内的误判
// 停滞 6~9s 才触发,避免正常节流间隔内的误判
sender.sendStreamUpdate(state.latestContent, `⏳ 工具执行中... (${elapsed}s)`).catch(() => { });

@@ -172,0 +172,0 @@ }

@@ -17,4 +17,4 @@ import { getWSClient } from './client.js';

* 如果流式消息接近超时(330s),结束当前流并开始新流。
* 企业微信 SDK 的 replyStream(finish=true) 会终结当前流内容并展示最终文本,
* 新流会以独立消息出现,因此重发完整内容不会导致用户侧重复显示。
* 旧流结束时保留到截断点的内容,新流只发截断点之后的增量内容,
* 避免每次续接都重复显示之前的全部文本。
*/

@@ -27,4 +27,6 @@ async function renewStreamIfNeeded(content) {

log.info(`Stream ${session.streamId} elapsed ${Math.round(elapsed / 1000)}s, renewing`);
// 旧流结束,显示当前增量内容 + 续接标记
const oldContent = content.slice(session.contentOffset);
try {
await wsClient.replyStream(session.frame, session.streamId, content, true);
await wsClient.replyStream(session.frame, session.streamId, oldContent + '\n\n---\n> _(续)_', true);
}

@@ -34,2 +36,4 @@ catch (err) {

}
// 记录截断点,新流从此偏移开始
session.contentOffset = content.length;
session.streamId = generateReqId('stream');

@@ -43,10 +47,8 @@ session.streamStartedAt = Date.now();

return {
templateCard: {
card_type: 'button_interaction',
main_title: { title: 'Claude Code' },
task_id: `stop_${taskKey}`,
button_list: [
{ text: '⏹️ 停止', style: 3, key: `stop_${taskKey}` },
],
},
card_type: 'button_interaction',
main_title: { title: 'Claude Code' },
task_id: `stop_${taskKey}`,
button_list: [
{ text: '⏹️ 停止', style: 3, key: `stop_${taskKey}` },
],
};

@@ -66,2 +68,20 @@ }

/**
* 首次流式更新后,单独发一条停止按钮卡片消息。
* replyStreamWithCard 在手机端可能不渲染卡片,改为 sendMessage 独立发送。
*/
async function sendStopCardIfNeeded() {
if (!session || !session.chatId || !session.taskKey || session.stopCardSent)
return;
session.stopCardSent = true;
try {
await wsClient.sendMessage(session.chatId, {
msgtype: 'template_card',
template_card: buildStopCard(session.taskKey),
});
}
catch (err) {
log.warn('Failed to send stop card:', err);
}
}
/**
* 内部串行化的流式更新

@@ -74,10 +94,7 @@ * 保证同一时刻最多一个 replyStream 在飞行中

await renewStreamIfNeeded(content);
const text = toolNote ? `${content}\n\n---\n> ${toolNote}` : content;
// 只发送截断点之后的增量内容
const sliced = content.slice(session.contentOffset);
const text = toolNote ? `${sliced}\n\n---\n> ${toolNote}` : sliced;
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);
}
await wsClient.replyStream(session.frame, session.streamId, text, false);
session.isFirstUpdate = false;

@@ -113,2 +130,4 @@ }

taskKey: taskKey ?? '',
stopCardSent: false,
contentOffset: 0,
};

@@ -127,2 +146,4 @@ },

await doStreamUpdate(content, toolNote);
// 首次流式更新成功后,单独发送停止按钮卡片
await sendStopCardIfNeeded();
// 发送期间如果有新的 pending 内容,循环处理(避免递归)

@@ -156,14 +177,10 @@ while (pendingStreamUpdate) {

}
// 开启新流用于实际回答
// 开启新流用于实际回答,重置偏移量(文本从头开始)
session.contentOffset = 0;
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);
}
await wsClient.replyStream(session.frame, session.streamId, content || '...', false);
session.isFirstUpdate = false;

@@ -185,3 +202,5 @@ }

streamBusy = true;
const parts = splitLongContent(content, MAX_WECOM_MESSAGE_LENGTH);
// 只发送截断点之后的增量内容
const sliced = content.slice(session.contentOffset);
const parts = splitLongContent(sliced, MAX_WECOM_MESSAGE_LENGTH);
const firstPart = note ? `${parts[0]}\n\n---\n> ${note}` : parts[0];

@@ -188,0 +207,0 @@ try {

{
"name": "cc-im",
"version": "1.4.0",
"version": "1.5.0",
"description": "Multi-platform bot bridge (Feishu & Telegram) for Claude Code CLI",

@@ -5,0 +5,0 @@ "repository": {

Sorry, the diff of this file is not supported yet