backend-ts/src/app-feishu-handler.ts
Rama main · réplica de lectura del repositorio original.
/* eslint-disable @typescript-eslint/no-explicit-any */
import { mkdirSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import type { Db } from './db/db.js';
import type { AppConfig } from './config/app-config.js';
import type { AppContext } from './create-app.js';
import type { MysqlFeishuMessageRepository } from './feishu/message.repository.js';
import type { FeishuMessageService } from './feishu/message.service.js';
import type { FeishuClientHelper } from './feishu-client.js';
import type { HarnessService } from './harness/core/harness-service.js';
import type { AgentLoop } from './harness/core/agent-loop.js';
import type { StreamingWsHandler } from './session/ws/streaming-ws-handler.js';
import { createFeishuSessionHelper } from './feishu-session.js';
import { FEISHU_ECP_IDENTITY_MISMATCH_TEXT } from './feishu/ecp-inbound-gate.js';
import { createFeishuQueueCardHelper } from './feishu-cards.js';
import { createFeishuSessionUrlHelper } from './feishu-session.js';
import { MysqlFeishuCardSnapshotRepository } from './feishu/card-snapshot.repository.js';
import { MysqlFeishuProgressCardRepository } from './feishu/progress-card.repository.js';
import { createPatchedProgressHelper } from './feishu-cards.js';
import { createFeishuProgressCardHelper } from './feishu-cards.js';
import { createFeishuRoundOffsetHelper } from './feishu-client.js';
import { createFeishuRecoveryProgressHelper } from './feishu-cards.js';
import { createFeishuUserIdHelper } from './feishu-session.js';
import { FeishuInboundQueueRepository } from './feishu/inbound-queue.repository.js';
import { FeishuTaskQueueService } from './feishu/inbound-queue.service.js';
import { createQueuePositionHelper } from './app-utils.js';
import { createPersistCancelledHelper } from './app-utils.js';
import { createPlatformQueueRelay } from './app-utils.js';
import { AgentFeishuInboundHandler } from './feishu/agent-inbound-handler.js';
import { channelAssistantReply } from './session/util/citation-markers.js';
import { wsEvent } from './session/ws/ws-event.js';
import { WsStreamingEventListener } from './session/ws/ws-streaming-event-listener.js';
import { buildFeishuProgressCard } from './feishu/progress-card.js';
import { feishuSendTargetOf } from './feishu/media-sender.js';
import { createFeishuMediaHelper, IMAGE_EXT_BY_CONTENT_TYPE } from './feishu-media.js';
import { sanitizeFeishuFileName, downloadFeishuMediaBuffer, dataUriOf } from './feishu-media.js';
import { createThreadSessionHelper } from './feishu-session.js';
import { FeishuInboundProcessor } from './feishu/inbound-processor.js';
import { createResolveQuotedMessageHelper } from './feishu-session.js';
import { createResolveMessageTextHelper } from './feishu-media.js';
import { createResolveUserIdHelper } from './feishu-session.js';
import { createAuthorizeSenderHelper } from './feishu-session.js';
import { createRenewAuthLinkHelper, createSendUnauthorizedCardHelper } from './feishu-cards.js';
import { createUnauthorizedTextHelper } from './feishu-cards.js';
import { FeishuCardActionService } from './feishu/card-action.service.js';
import { persistFeishuCancelIfIdle } from './feishu/cancel-running.js';
import { FEISHU_DISLIKE_SOURCE } from './feedback/feedback.repository.js';
import { inboundFileItems, inboundImageKeys } from './feishu/event-normalizer.js';
import { chatFilesDirOf, resolveChatFileTarget } from './feishu/chat-files.js';
import { parseSqlTimeMs } from './app-utils.js';
import { createDingtalkRuntime } from './dingtalk/runtime.js';
import { FeishuMonitorService } from './feishu/monitor.service.js';
import { AnalyticsService, AnalyticsDbStore } from './analytics/analytics.service.js';
import { StatisticsService, StatisticsDbStore } from './statistics/statistics.service.js';
import { AdminAnalyticsService, AdminAnalyticsDbStore } from './admin/admin-analytics.service.js';
import { McpServerValidatorImpl } from './agent/mcp-validator.js';
import { fetchFeishuMessageDetail } from './feishu/message-detail.js';
import { sendFeishuFile, sendFeishuImage } from './feishu/media-sender.js';
export interface FeishuHandlerDeps {
db: Db;
cfg: AppConfig;
ctx: AppContext;
/** 飞书消息层(app-feishu-messaging)先于 harness 装配,这里只做消费。 */
feishuMessageRepository: MysqlFeishuMessageRepository;
feishuMessageService: FeishuMessageService;
getFeishuClient: FeishuClientHelper['getFeishuClient'];
getFeishuChatTitle: FeishuClientHelper['getFeishuChatTitle'];
sendFeishuText: FeishuClientHelper['sendFeishuText'];
/** harness/agentLoop 已于本模块之前装配完成(钉钉运行时需要真实实例)。 */
harness: HarnessService;
agentLoop: AgentLoop;
/** WS handler(0.0.434 跨通道队列接力/忙检查):装配在 create-app 传入,漏传编译期报错。 */
wsHandler: Pick<StreamingWsHandler, 'hasExecutionClaim' | 'hasCrossChannelBusy' | 'autoConsumeQueue'>;
// 其余依赖仍以 any 透传,避免逐个下沉类型
[key: string]: any;
}
export interface FeishuHandlerResult {
feishuInboundHandler: AgentFeishuInboundHandler;
feishuInboundProcessor: FeishuInboundProcessor;
feishuCardActionService: FeishuCardActionService;
feishuMonitor: FeishuMonitorService;
dingtalk: any;
feishuTaskQueue: FeishuTaskQueueService;
feishuProgressCardRepo: MysqlFeishuProgressCardRepository;
createFeishuRecoveryProgress: any;
analyticsService: AnalyticsService;
statisticsService: StatisticsService;
adminAnalytics: AdminAnalyticsService;
httpToolStatsStore: AdminAnalyticsDbStore;
mcpValidator: McpServerValidatorImpl;
}
export function assembleFeishuHandler(deps: FeishuHandlerDeps): FeishuHandlerResult {
const d = deps as any;
const { db, cfg, ctx } = d;
const {
feishuMessageRepository, feishuMessageService, getFeishuClient, getFeishuChatTitle, sendFeishuText,
} = d;
const feishuSenderNames = new Map<string, Promise<string | null>>();
const { applyFeishuBotConfig, ensureFeishuSessionTitle } = createFeishuSessionHelper({
feishuBots: d.feishuBots, sessionService: d.sessionService, sessionRepo: d.sessionRepo, agentService: d.agentService, modelService: d.modelService, getFeishuChatTitle,
});
ctx.notifyFeishuPendingMismatch = async (pending: any) => {
await sendFeishuText(pending.appId, pending.event, FEISHU_ECP_IDENTITY_MISMATCH_TEXT);
};
const feishuQueueCard = createFeishuQueueCardHelper({ getFeishuClient, feishuMessageService });
const resolveFeishuSessionDetailUrl = createFeishuSessionUrlHelper({ settingService: d.settingService });
const feishuCardSnapshotRepo = new MysqlFeishuCardSnapshotRepository(db);
const feishuProgressCardRepo = new MysqlFeishuProgressCardRepository(db);
const createPatchedProgress = createPatchedProgressHelper({
feishuActiveProgress: d.feishuActiveProgress, feishuAskFormStore: d.feishuAskFormStore, feishuCardSnapshotRepo, backgroundSubagentManager: d.backgroundSubagentManager, feedbackMessageLookup: d.feedbackMessageLookup,
});
const createFeishuProgressCard = createFeishuProgressCardHelper({
getFeishuClient, feishuMessageService, feishuProgressCardRepo, resolveFeishuSessionDetailUrl, createPatchedProgress,
});
const resolveFeishuRoundOffset = createFeishuRoundOffsetHelper({ sessionService: d.sessionService });
const createFeishuRecoveryProgress = createFeishuRecoveryProgressHelper({
getFeishuClient, feishuProgressCardRepo, sessionService: d.sessionService, resolveFeishuSessionDetailUrl, resolveFeishuRoundOffset, createPatchedProgress,
});
const resolveFeishuUserId = createFeishuUserIdHelper({ feishuBinding: d.feishuBinding });
const feishuInboundQueueRepo = new FeishuInboundQueueRepository(db);
const feishuTaskQueue = new FeishuTaskQueueService(feishuInboundQueueRepo);
const queuePositionOf = createQueuePositionHelper({ feishuInboundQueueRepo });
const persistCancelledIfActive = createPersistCancelledHelper({ sessionService: d.sessionService, taskTerminal: d.taskTerminal, feishuProgressCardRepo });
const feishuInboundHandler = new AgentFeishuInboundHandler({
registry: d.wsRegistry,
cardToolTickMs: cfg.feishu.bot.cardToolTickMs,
// 跨通道队列接力(0.0.434):WS 侧执行占用时让行;飞书队列排空后消费平台排队消息。
isSessionExecuting: (sessionId: number) => d.wsHandler?.hasExecutionClaim(sessionId) === true,
onQueuesDrained: createPlatformQueueRelay({ sessionService: d.sessionService, wsHandler: d.wsHandler }),
resolveStreamUserId: async (sessionId: number) => {
const session = await d.sessionService.getSession(sessionId).catch(() => null);
return session?.userId ?? null;
},
sessionService: {
getOrCreateSession: async (accountId: any, context: any) => {
const triggerUserId = await resolveFeishuUserId(accountId, context);
const conversation = context.chatType === 'group'
? await feishuMessageService.getOrCreateGroup(accountId, context)
: await feishuMessageService.getOrCreateP2p(accountId, context, triggerUserId);
await applyFeishuBotConfig(conversation.sessionId, Number(accountId));
void ensureFeishuSessionTitle(conversation.sessionId, Number(accountId), context);
return { id: conversation.sessionId, workspace: conversation.workspace ?? null, executionUserId: triggerUserId ?? null };
},
saveUserMessage: async (sessionId: number, content: any, metadata: any) => { await d.sessionService.saveMessage(sessionId, 'USER', content, null, null, null, 0, null, metadata ?? null); },
updatePhase: async (sessionId: number, phase: any) => { await d.sessionService.updatePhase(sessionId, phase); },
cleanupIncompleteTail: async (sessionId: number) => d.sessionService.cleanupIncompleteTail(sessionId),
getPhase: async (sessionId: number) => (await d.sessionService.getSession(sessionId))?.phase ?? null,
// 最近一次执行的运行身份(技术方案 2026-11-04 §5.2):失败卡重试透传。
// 不在此吞错:读取异常交给 runRetry 的 catch 记录并回落 owner,避免「身份漂移」静默复现。
getLastRuntimeUserId: async (sessionId: number) =>
(await d.sessionService.getSession(sessionId))?.lastRuntimeUserId ?? null,
getLatestAssistantReplyWithId: async (sessionId: number) => {
const messages = await d.sessionService.getMessages(sessionId);
let messageId: number | null = null;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'ASSISTANT') {
messageId = messages[i].id ?? null;
break;
}
}
return { text: channelAssistantReply(messages, '抱歉,暂时无法生成回复。'), messageId };
},
getMessages: async (sessionId: number) => d.sessionService.getMessages(sessionId),
},
p2pSessionControl: {
findActiveSession: async (accountId: any, context: any) => {
const triggerUserId = await resolveFeishuUserId(accountId, context);
const conversation = await feishuMessageService.findActiveP2p(accountId, context, triggerUserId);
return conversation == null ? null : { id: conversation.sessionId };
},
createSession: async (accountId: any, context: any) => {
const triggerUserId = await resolveFeishuUserId(accountId, context);
if (triggerUserId == null) throw new Error(`飞书用户未绑定: ${context.senderId ?? ''}`);
const conversation = await feishuMessageService.createP2pSession(accountId, context, triggerUserId);
await applyFeishuBotConfig(conversation.sessionId, Number(accountId));
return { id: conversation.sessionId };
},
switchSession: async (accountId: any, context: any, targetSessionId: number) => {
const triggerUserId = await resolveFeishuUserId(accountId, context);
if (triggerUserId != null) {
const targetSession = await d.sessionRepo.findById(targetSessionId);
if (targetSession == null || targetSession.userId !== triggerUserId) {
console.warn(`飞书引用切换拒绝: 目标会话不存在或归属其他用户, sessionId=${targetSessionId}, trigger=${triggerUserId}`);
return null;
}
}
const conversation = await feishuMessageService.switchP2pSession(accountId, context, targetSessionId, triggerUserId);
if (conversation == null) return null;
await applyFeishuBotConfig(conversation.sessionId, Number(accountId));
return { id: conversation.sessionId };
},
findSessionByMessageId: (accountId: any, messageId: any) => feishuMessageService.findP2pMessageSession(accountId, messageId),
recordMessageMapping: (accountId: any, messageId: any, sessionId: number, direction: any) => feishuMessageService.recordP2pMessage(accountId, messageId, sessionId, direction),
finalizeNewSessionTitle: async (sessionId: number, title: string) => {
const channel = await feishuMessageService.findSessionChannel(sessionId);
if (channel?.awaitingFirstMessageTitle !== 1) return false;
const session = await d.sessionService.getSession(sessionId);
if (session == null) return false;
await feishuMessageService.clearAwaitingFirstMessageTitle(sessionId);
if (session.title != null && session.title !== '飞书Bot会话') return false;
await d.sessionRepo.updateFields(sessionId, { title });
d.wsRegistry.send(session.userId!, wsEvent('session_title_updated', sessionId, {
title,
parentSessionId: session.parentSessionId ?? null,
sessionType: session.sessionType ?? 'NORMAL',
}));
return true;
},
isAwaitingFirstMessageTitle: async (sessionId: number) =>
(await feishuMessageService.findSessionChannel(sessionId))?.awaitingFirstMessageTitle === 1,
},
threadSessionControl: {
findSession: async (accountId: any, context: any) => {
const result = await feishuMessageService.findThreadSession(accountId, context.threadId);
return result == null ? null : { sessionId: result.sessionId, rootMessageId: result.rootMessageId };
},
getOrCreateSession: async (accountId: any, context: any) => {
const result = await feishuMessageService.getOrCreateThreadSession(accountId, context);
if (result == null) return null;
const triggerUserId = await resolveFeishuUserId(accountId, context);
await applyFeishuBotConfig(result.sessionId, Number(accountId));
return { sessionId: result.sessionId, rootMessageId: result.rootMessageId, workspace: result.workspace ?? null, executionUserId: triggerUserId ?? null };
},
},
titleGenerator: {
generate: (sessionId: number, text: string) => d.sessionTitleService.generateExternalTitle(sessionId, text),
},
onReply: async (context: any, text: string, sessionId?: number) => {
const messageId = await sendFeishuText(Number(context.accountId), context, text);
if (messageId != null && sessionId != null && context.chatType === 'p2p') {
await feishuMessageService.recordP2pMessage(context.accountId, messageId, sessionId, 'OUT');
}
return messageId;
},
harnessService: { execute: (...args: any[]) => d.harness.execute(...args), prepareMessage: (...args: any[]) => d.harness.prepareMessage(...args) } as never,
createCancelFlag: (sessionId: number) => d.agentLoop.registerCancelFlag(sessionId),
releaseCancelFlag: (sessionId: number) => d.agentLoop.removeCancelFlag(sessionId),
createProgressCard: (context: any, sessionId: number) => createFeishuProgressCard(context, sessionId),
onInterruptRunning: (sessionId: number) => {
d.agentLoop.requestCancel(sessionId);
try { d.shellManager.closeByConversation(sessionId); } catch (error) {
console.debug(`关闭飞书会话 Shell 失败, sessionId=${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
}
d.feishuAskFormStore.clearSession(sessionId);
d.askUserQuestionsRegistry.failAllForSession(sessionId);
},
resolveIdleRunning: async (sessionId: number) => {
if (d.agentLoop.getCancelFlag(sessionId) != null) return;
await persistCancelledIfActive(sessionId);
},
queueService: feishuTaskQueue,
createQueueCard: async (context: any, _queueId: any, sessionId: number) => feishuQueueCard(context, _queueId, await queuePositionOf(sessionId), sessionId),
resolveBotId: (accountId: any) => Number(accountId),
downloadMedia: async (context: any, workspace: string | null) => {
const botId = Number(context.accountId);
const client = await getFeishuClient(botId);
if (client == null) return null;
const inboundKeys = inboundImageKeys(context);
const fileItems = inboundFileItems(context);
const isMedia = inboundKeys.length > 0 || fileItems.length > 0;
if (!isMedia || context.messageId == null) return null;
const maxBytes = Math.max(1, cfg.feishu.bot.file.maxInboundFileMb) * 1024 * 1024;
const images: string[] = [];
const imagePaths: string[] = [];
const filePaths: string[] = [];
const errors: string[] = [];
try {
let imageIndex = 0;
for (const imageKey of inboundKeys) {
try {
const { buffer, contentType } = await downloadFeishuMediaBuffer(client, context.messageId, imageKey, 'image', maxBytes);
if (buffer.length === 0) {
errors.push('图片(接收失败)');
continue;
}
images.push(dataUriOf(contentType, buffer));
if (workspace != null && workspace !== '') {
try {
const ext = IMAGE_EXT_BY_CONTENT_TYPE[contentType] ?? '.jpg';
const dir = chatFilesDirOf(workspace);
mkdirSync(dir, { recursive: true });
const name = imageIndex === 0 ? `feishu-image-${context.messageId}${ext}` : `feishu-image-${context.messageId}-${imageIndex + 1}${ext}`;
const target = resolve(dir, name);
await writeFile(target, buffer);
imagePaths.push(target);
} catch (error) {
console.warn(`飞书图片落盘失败(不影响直传), bot=${botId}, messageId=${context.messageId}: ${error instanceof Error ? error.message : String(error)}`);
}
}
imageIndex++;
} catch (error) {
console.error(`飞书图片下载失败, bot=${botId}, messageId=${context.messageId}`, error);
errors.push('图片(接收失败)');
}
}
// 单文件消息(msg_type=file)与多文件消息(post.files)统一按文件项逐个下载。
if (fileItems.length > 0) {
if (workspace == null || workspace === '') {
for (const item of fileItems) errors.push(item.fileName ?? '文件(接收失败)');
} else {
for (let fileIndex = 0; fileIndex < fileItems.length; fileIndex++) {
const item = fileItems[fileIndex];
try {
const { buffer } = await downloadFeishuMediaBuffer(client, context.messageId, item.fileKey, 'file', maxBytes);
if (buffer.length === 0) {
errors.push(item.fileName ?? '文件(接收失败)');
continue;
}
const fileName = sanitizeFeishuFileName(item.fileName, `feishu-${item.fileKey}`);
const dir = chatFilesDirOf(workspace);
mkdirSync(dir, { recursive: true });
const target = resolveChatFileTarget(dir, fileName, context.messageId, fileIndex);
await writeFile(target, buffer);
filePaths.push(target);
} catch (error) {
console.error(`飞书文件下载失败, bot=${botId}, messageId=${context.messageId}`, error);
errors.push(item.fileName ?? '文件(接收失败)');
}
}
}
}
} finally { /* 下载失败已收集 errors */ }
return { images, imagePaths, filePaths, errors };
},
listenerFactory: async (sessionId: number, context: any, executionId: any) => {
const session = await d.sessionService.getSession(sessionId);
const ownerUserId = session.userId!;
return new WsStreamingEventListener({
registry: d.wsRegistry,
activityService: d.activityService as never,
activityHeartbeat: d.activityHeartbeat,
sessionTodoMapper: d.todoMapper,
sessionService: d.sessionService as never,
}, sessionId, ownerUserId, executionId, session.modelId != null && (await d.modelService.getModel(session.modelId))?.supportsVision === 1);
},
onExecutionFinished: async (sessionId: number, _context: any, executionId: any, phase: any, failureReason: any) => {
const session = await d.sessionService.getSession(sessionId);
await d.taskTerminal.finishExecution(sessionId, session.userId!, phase, executionId, failureReason);
if (phase === 'FAILED') return;
try { await feishuProgressCardRepo.deleteBySessionId(sessionId); } catch (error) {
console.warn(`清理飞书进度卡片映射失败, sessionId=${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
}
},
});
d.scheduledService.setFeishuResultPusher(async (sessionId: number, text: string) => {
const channel = await feishuMessageService.findSessionChannel(sessionId);
if (channel == null) return;
const client = await getFeishuClient(Number(channel.appId));
if (client == null) return;
const target = feishuSendTargetOf(channel.appId, channel.chatId);
const data = {
msg_type: 'interactive',
content: JSON.stringify(buildFeishuProgressCard('COMPLETED', 0, text, [], undefined, undefined, await resolveFeishuSessionDetailUrl(sessionId))),
};
const threadRootMessageId = channel.chatType === 'group'
? await feishuMessageService.findThreadRootMessageId(sessionId)
: null;
const response = threadRootMessageId != null
? await client.im.v1.message.reply({ path: { message_id: threadRootMessageId }, data })
: await client.im.v1.message.create({
params: { receive_id_type: target.receiveIdType },
data: { ...data, receive_id: target.receiveId },
});
const pushedMessageId = (response as { data?: { message_id?: string } }).data?.message_id ?? null;
if (pushedMessageId != null && channel.chatType === 'p2p') {
await feishuMessageService.recordP2pMessage(channel.appId, pushedMessageId, sessionId, 'OUT');
}
});
const feishuMediaHelper = createFeishuMediaHelper({
getFeishuClient,
feishuSenderNames,
feishuBinding: d.feishuBinding,
feishuMessageRepository,
userRepo: d.userRepo,
workspaceRoot: cfg.app.harness.workspaceRoot,
maxInboundFileMb: cfg.feishu.bot.file.maxInboundFileMb,
});
const { resolveFeishuSenderName, resolveFeishuUserNames, resolveMergeWorkspace, expandMergeForward, resolveQuotedWorkspace, downloadFeishuGroupImage, downloadFeishuImageTo, downloadFeishuGroupFile } = feishuMediaHelper;
const threadSessionHelper = createThreadSessionHelper({ feishuMessageService });
const feishuInboundProcessor = new FeishuInboundProcessor(feishuInboundHandler, {
messageService: feishuMessageService,
mergeWindowMs: cfg.feishu.bot.mergeWindowMs,
resolveSenderName: resolveFeishuSenderName,
downloadGroupImage: downloadFeishuGroupImage,
downloadGroupFile: downloadFeishuGroupFile,
resolveThreadSession: threadSessionHelper.resolveThreadSession,
ensureThreadSession: threadSessionHelper.ensureThreadSession,
resolveQuotedMessage: createResolveQuotedMessageHelper({
getFeishuClient, feishuMessageRepository, resolveQuotedWorkspace, expandMergeForward,
}),
expandMergeForward,
resolveMergeWorkspace,
resolveMessageText: createResolveMessageTextHelper({ getFeishuClient }),
resolveUserId: createResolveUserIdHelper({ feishuBinding: d.feishuBinding }),
authorizeSender: createAuthorizeSenderHelper({ feishuBinding: d.feishuBinding, feishuMessageRepository, ecpAuth: d.ecpAuth, ecpSessionRepo: d.ecpSessionRepo }),
sendReply: async (accountId: any, event: any, text: string) => { await sendFeishuText(Number(accountId), event, text); },
sendUnauthorizedCard: createSendUnauthorizedCardHelper({ getFeishuClient, ecpAuth: d.ecpAuth, feishu: d.feishu, pendingBindingMessages: d.pendingBindingMessages }),
unauthorizedText: createUnauthorizedTextHelper({ ecpAuth: d.ecpAuth }),
});
ctx.pendingBindingProcessor = feishuInboundProcessor;
const feishuCardActionService = new FeishuCardActionService({
queuePort: feishuTaskQueue,
renewAuthLink: createRenewAuthLinkHelper({
ecpAuth: d.ecpAuth, feishu: d.feishu, pendingBindingMessages: d.pendingBindingMessages,
}),
interrupt: (sessionId: number) => feishuInboundHandler.interrupt(sessionId),
interruptAndDrain: (sessionId: number) => feishuInboundHandler.interruptAndDrain(sessionId),
cancelRunning: async (sessionId: number) => {
feishuInboundHandler.cancel(sessionId);
return persistFeishuCancelIfIdle({
sessionId,
hadLoop: d.agentLoop.getCancelFlag(sessionId) != null,
persistCancelledIfActive,
drainNextIfPending: (id: number) => feishuInboundHandler.drainNextIfPending(id),
});
},
toggleDislike: async (sessionId: number, cardMessageId: string | null) => {
const session = await d.sessionService.getSession(sessionId).catch(() => null);
if (session == null) return null;
let messageId: number | undefined;
if (cardMessageId != null) {
const snapshot = await feishuCardSnapshotRepo.findByCardMessageId(cardMessageId).catch((error: any) => {
console.warn(`飞书点踩查询卡片快照失败, cardMessageId=${cardMessageId}, sessionId=${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
return null;
});
messageId = snapshot?.messageId;
}
const result = await d.feedbackService.toggleSessionDislike(session.userId, sessionId, FEISHU_DISLIKE_SOURCE, messageId);
if (result == null) return null;
if (cardMessageId == null) {
d.feishuActiveProgress.current(sessionId)?.setDisliked(result.disliked);
} else {
d.feishuActiveProgress.setDislikedByCard(cardMessageId, result.disliked);
}
return result;
},
retryFailed: async (sessionId: number, cardMessageId: string) => {
const mapping = await feishuProgressCardRepo.findBySessionId(sessionId);
if (mapping == null) return { ok: false, reason: 'NO_PROGRESS' };
return feishuInboundHandler.retryExecution(sessionId, async () => {
const client = await getFeishuClient(mapping.botId);
if (client == null) return null;
const sender = mapping.senderOpenId ?? '';
const sessionDetailUrl = await resolveFeishuSessionDetailUrl(sessionId);
const session = await d.sessionService.getSession(sessionId).catch(() => null);
const startedAtMs = parseSqlTimeMs(session?.startedAt) ?? Date.now();
try {
await feishuProgressCardRepo.upsert({
sessionId,
botId: mapping.botId,
cardMessageId,
chatType: mapping.chatType,
chatId: mapping.chatId,
senderOpenId: mapping.senderOpenId,
});
} catch (error) {
console.warn(`飞书重试进度卡片映射刷新失败, sessionId=${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
}
return createPatchedProgress(
client, cardMessageId, sessionId,
{ sessionId, sender, botId: mapping.botId },
startedAtMs, sessionDetailUrl,
{ content: '正在重试,请稍候…' },
mapping.botId,
);
});
},
patchCard: async (botId: number, cardMessageId: string, card: any) => {
const client = await getFeishuClient(botId);
if (client == null) throw new Error(`飞书客户端不可用, botId=${botId}, cardMessageId=${cardMessageId}`);
const response = await client.im.v1.message.patch({ path: { message_id: cardMessageId }, data: { content: JSON.stringify(card) } });
if (response?.code !== 0) {
throw new Error(`飞书卡片更新失败, code=${response?.code ?? 'missing'}, msg=${response?.msg ?? 'unknown'}`);
}
},
sessionDetailUrl: (sessionId: number) => resolveFeishuSessionDetailUrl(sessionId),
askForms: d.feishuAskFormStore,
renderProgressCard: (sessionId: number) => d.feishuActiveProgress.render(sessionId),
renderProgressCardByCard: (cardMessageId: string) => d.feishuActiveProgress.renderByCard(cardMessageId),
rebuildCardFromSnapshot: async (sessionId: number, cardMessageId: string, disliked: boolean, sender: string) => {
const snapshot = await feishuCardSnapshotRepo.findByCardMessageId(cardMessageId).catch((error: any) => {
console.warn(`飞书点踩查询卡片快照失败, cardMessageId=${cardMessageId}, sessionId=${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
return null;
});
if (snapshot == null) return null;
return buildFeishuProgressCard(
'COMPLETED', snapshot.round, snapshot.content ?? '', [],
{ sessionId, sender }, snapshot.elapsedMs, await resolveFeishuSessionDetailUrl(sessionId), undefined, disliked,
);
},
refreshProgress: (sessionId: number) => d.feishuActiveProgress.refresh(sessionId),
completeAsk: (sessionId: number, requestId: string, resultJson: string) => d.askUserQuestionsRegistry.complete(sessionId, requestId, resultJson),
notifyAskAnswered: (sessionId: number, requestId: string) => {
d.treeSignalPublisher.publishForSession(sessionId);
void d.localToolSessions.getUserIdForSession(sessionId).then((userId: number | null) => {
if (userId != null) {
d.wsRegistry.send(userId, wsEvent('ask_user_questions_cancelled', sessionId, { requestId }));
}
}).catch((error: any) => {
console.warn(`飞书提问结果通知客户端失败, sessionId=${sessionId}`, error);
});
},
});
const dingtalk = createDingtalkRuntime({
db,
config: {
enabled: cfg.dingtalk.enabled,
appSecretKey: cfg.dingtalk.appSecretKey,
reconcileIntervalMs: cfg.dingtalk.reconcileIntervalMs,
reconnectBaseMs: cfg.dingtalk.reconnectBaseMs,
reconnectMaxMs: cfg.dingtalk.reconnectMaxMs,
maxConsecutiveFailures: cfg.dingtalk.maxConsecutiveFailures,
progressCardTemplateId: cfg.dingtalk.progressCardTemplateId,
queueCardTemplateId: cfg.dingtalk.queueCardTemplateId,
replyMaxLength: cfg.dingtalk.replyMaxLength,
groupContextMaxItems: cfg.dingtalk.groupContext.maxItems,
groupContextMaxMinutes: cfg.dingtalk.groupContext.maxMinutes,
mergeWindowMs: cfg.dingtalk.mergeWindowMs,
},
workspaceRoot: cfg.app.harness.workspaceRoot,
jwt: d.jwt,
permissionService: d.permissionService,
sessionService: d.sessionService,
sessionRepo: d.sessionRepo,
harness: d.harness,
agentLoop: d.agentLoop,
wsRegistry: d.wsRegistry,
agentService: d.agentService,
modelService: d.modelService,
userRepo: d.userRepo,
settingService: d.settingService,
taskTerminal: d.taskTerminal,
activityService: d.activityService,
activityHeartbeat: d.activityHeartbeat,
todoMapper: d.todoMapper,
shellManager: d.shellManager,
// 跨通道队列接力(0.0.434):同飞书 handler —— WS 侧占用让行 + 队列排空后消费平台排队消息。
isSessionExecuting: (sessionId: number) => d.wsHandler?.hasExecutionClaim(sessionId) === true,
onQueuesDrained: createPlatformQueueRelay({ sessionService: d.sessionService, wsHandler: d.wsHandler }),
});
d.dingtalkMediaHolder.current = dingtalk.mediaSend;
d.scheduledService.setDingtalkResultPusher((sessionId: number, text: string) => dingtalk.pushScheduledResult(sessionId, text));
const feishuMonitor = new FeishuMonitorService(cfg.feishu.bot, d.feishuBots, feishuInboundProcessor, async (data: any) => feishuCardActionService.handle(data, ''));
const analyticsService = new AnalyticsService(new AnalyticsDbStore(db));
// wsRegistry:在线设备页快照来源(create-app 装配时已传入,见 FeishuHandlerDeps 的 any 透传)
const statisticsService = new StatisticsService(new StatisticsDbStore(db), d.wsRegistry);
const adminAnalytics = new AdminAnalyticsService(statisticsService, new AdminAnalyticsDbStore(db));
const httpToolStatsStore = new AdminAnalyticsDbStore(db);
const mcpValidator = new McpServerValidatorImpl(d.mcpServerLookup);
return {
feishuInboundHandler,
feishuInboundProcessor,
feishuCardActionService,
feishuMonitor,
dingtalk,
feishuTaskQueue,
feishuProgressCardRepo,
createFeishuRecoveryProgress,
analyticsService,
statisticsService,
adminAnalytics,
httpToolStatsStore,
mcpValidator,
};
}