From 5aaa8600c569d2a1dabc39da9f1cbdf2c29828ed Mon Sep 17 00:00:00 2001 From: hailin Date: Sun, 8 Mar 2026 23:50:34 -0700 Subject: [PATCH] =?UTF-8?q?fix(dingtalk):=20async=20reply=20pattern=20?= =?UTF-8?q?=E2=80=94=20immediate=20ack=20+=20batchSend=20for=20LLM=20respo?= =?UTF-8?q?nse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Send 'πŸ€” ε°θ™Ύη±³ζ­£εœ¨ζ€θ€ƒοΌŒη¨η­‰...' immediately via sessionWebhook on each message - Await LLM bridge call (serial queue preserved) then deliver response via batchSend - batchSend decoupled from sessionWebhook β€” works regardless of webhook state - Fix duplicate const staffId declaration (TS compile error) - TASK_TIMEOUT_S=55 passed explicitly to bridge (was using bridge default 25s) - senderStaffId-first routing (OAuth binding) with senderId fallback (code binding) Co-Authored-By: Claude Sonnet 4.6 --- .../dingtalk/dingtalk-router.service.ts | 81 ++++++++++--------- 1 file changed, 44 insertions(+), 37 deletions(-) diff --git a/packages/services/agent-service/src/infrastructure/dingtalk/dingtalk-router.service.ts b/packages/services/agent-service/src/infrastructure/dingtalk/dingtalk-router.service.ts index 868145a..9d8b26a 100644 --- a/packages/services/agent-service/src/infrastructure/dingtalk/dingtalk-router.service.ts +++ b/packages/services/agent-service/src/infrastructure/dingtalk/dingtalk-router.service.ts @@ -615,16 +615,23 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy { } const bridgeUrl = `http://${instance.serverHost}:${instance.hostPort}/task`; - let reply: string; + // sessionWebhook TTL is ~90 minutes (per DingTalk docs), but delivering the actual LLM + // response synchronously makes the user wait with no feedback. Strategy: + // 1. Immediately send "倄理中..." via sessionWebhook β€” user sees instant acknowledgment + // 2. Await the bridge call (LLM processing) β€” the serial queue still blocks here, + // preventing concurrent LLM calls for the same user + // 3. Always deliver the actual response via batchSend β€” decoupled from webhook window + this.reply(msg, 'πŸ€” ε°θ™Ύη±³ζ­£εœ¨ζ€θ€ƒοΌŒη¨η­‰...'); + + let reply: string; try { const result = await this.httpPostJson<{ ok: boolean; result?: unknown; error?: string }>( bridgeUrl, { - prompt: text, - sessionKey: `agent:main:dt-${userId}`, + prompt: text, + sessionKey: `agent:main:dt-${userId}`, idempotencyKey: msg.msgId, - // Pass explicit timeout to bridge β€” default is 25s which is too short for LLM calls. timeoutSeconds: TASK_TIMEOUT_S, }, (TASK_TIMEOUT_S + 10) * 1000, @@ -642,41 +649,41 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy { reply = 'δΈŽε°ιΎ™θ™Ύι€šδΏ‘ζ—Άε‡ΊηŽ°ι”™θ――οΌŒθ―·η¨εŽι‡θ―•γ€‚'; } - // Try sessionWebhook first; if it has expired by the time we have a reply (LLM took - // longer than ~30s), fall back to proactive batchSend so the reply still reaches the user. - const webhookExpiry = msg.sessionWebhookExpiredTime > 1e11 - ? msg.sessionWebhookExpiredTime - : msg.sessionWebhookExpiredTime * 1000; + await this.batchSend(staffId, reply, msg.msgId); + } - if (Date.now() <= webhookExpiry) { - this.reply(msg, reply); - } else { - this.logger.warn( - `sessionWebhook expired for msgId=${msg.msgId} β€” falling back to batchSend for userId=${userId}`, - ); - const staffId = msg.senderStaffId?.trim(); - if (staffId) { - this.getToken() - .then((token) => - this.httpsPost( - 'api.dingtalk.com', - '/v1.0/robot/oToMessages/batchSend', - { - robotCode: this.clientId, - userIds: [staffId], - msgKey: 'sampleText', - msgParam: JSON.stringify({ content: reply }), - }, - { 'x-acs-dingtalk-access-token': token }, - ), - ) - .catch((e: Error) => - this.logger.error(`batchSend fallback failed for msgId=${msg.msgId}:`, e.message), - ); - } else { - this.logger.warn(`No staffId for batchSend fallback, reply lost for msgId=${msg.msgId}`); - } + /** Send a proactive message to a DingTalk user via batchSend. Used for LLM replies + * so that users receive the response regardless of sessionWebhook state. */ + private batchSend(staffId: string | undefined, content: string, msgId: string): Promise { + if (!staffId) { + this.logger.warn(`batchSend skipped β€” no staffId for msgId=${msgId}`); + return Promise.resolve(); } + // Chunk content to stay within DingTalk's message size limit + const safe = content.replace(/\s+at\s+\S+:\d+:\d+/g, '').trim() || 'οΌˆη©Ίε“εΊ”οΌ‰'; + const chunks: string[] = []; + for (let i = 0; i < safe.length; i += DINGTALK_MAX_CHARS) { + chunks.push(safe.slice(i, i + DINGTALK_MAX_CHARS)); + } + return this.getToken() + .then(async (token) => { + for (const chunk of chunks) { + await this.httpsPost( + 'api.dingtalk.com', + '/v1.0/robot/oToMessages/batchSend', + { + robotCode: this.clientId, + userIds: [staffId], + msgKey: 'sampleText', + msgParam: JSON.stringify({ content: chunk }), + }, + { 'x-acs-dingtalk-access-token': token }, + ); + } + }) + .catch((e: Error) => + this.logger.error(`batchSend failed for msgId=${msgId}:`, e.message), + ); } // ── Reply (chunked) ────────────────────────────────────────────────────────