fix(dingtalk): async reply pattern — immediate ack + batchSend for LLM response
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
440819add8
commit
5aaa8600c5
|
|
@ -615,8 +615,16 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
const bridgeUrl = `http://${instance.serverHost}:${instance.hostPort}/task`;
|
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 {
|
try {
|
||||||
const result = await this.httpPostJson<{ ok: boolean; result?: unknown; error?: string }>(
|
const result = await this.httpPostJson<{ ok: boolean; result?: unknown; error?: string }>(
|
||||||
bridgeUrl,
|
bridgeUrl,
|
||||||
|
|
@ -624,7 +632,6 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy {
|
||||||
prompt: text,
|
prompt: text,
|
||||||
sessionKey: `agent:main:dt-${userId}`,
|
sessionKey: `agent:main:dt-${userId}`,
|
||||||
idempotencyKey: msg.msgId,
|
idempotencyKey: msg.msgId,
|
||||||
// Pass explicit timeout to bridge — default is 25s which is too short for LLM calls.
|
|
||||||
timeoutSeconds: TASK_TIMEOUT_S,
|
timeoutSeconds: TASK_TIMEOUT_S,
|
||||||
},
|
},
|
||||||
(TASK_TIMEOUT_S + 10) * 1000,
|
(TASK_TIMEOUT_S + 10) * 1000,
|
||||||
|
|
@ -642,41 +649,41 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy {
|
||||||
reply = '与小龙虾通信时出现错误,请稍后重试。';
|
reply = '与小龙虾通信时出现错误,请稍后重试。';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try sessionWebhook first; if it has expired by the time we have a reply (LLM took
|
await this.batchSend(staffId, reply, msg.msgId);
|
||||||
// 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;
|
|
||||||
|
|
||||||
if (Date.now() <= webhookExpiry) {
|
/** Send a proactive message to a DingTalk user via batchSend. Used for LLM replies
|
||||||
this.reply(msg, reply);
|
* so that users receive the response regardless of sessionWebhook state. */
|
||||||
} else {
|
private batchSend(staffId: string | undefined, content: string, msgId: string): Promise<void> {
|
||||||
this.logger.warn(
|
if (!staffId) {
|
||||||
`sessionWebhook expired for msgId=${msg.msgId} — falling back to batchSend for userId=${userId}`,
|
this.logger.warn(`batchSend skipped — no staffId for msgId=${msgId}`);
|
||||||
);
|
return Promise.resolve();
|
||||||
const staffId = msg.senderStaffId?.trim();
|
}
|
||||||
if (staffId) {
|
// Chunk content to stay within DingTalk's message size limit
|
||||||
this.getToken()
|
const safe = content.replace(/\s+at\s+\S+:\d+:\d+/g, '').trim() || '(空响应)';
|
||||||
.then((token) =>
|
const chunks: string[] = [];
|
||||||
this.httpsPost<unknown>(
|
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<unknown>(
|
||||||
'api.dingtalk.com',
|
'api.dingtalk.com',
|
||||||
'/v1.0/robot/oToMessages/batchSend',
|
'/v1.0/robot/oToMessages/batchSend',
|
||||||
{
|
{
|
||||||
robotCode: this.clientId,
|
robotCode: this.clientId,
|
||||||
userIds: [staffId],
|
userIds: [staffId],
|
||||||
msgKey: 'sampleText',
|
msgKey: 'sampleText',
|
||||||
msgParam: JSON.stringify({ content: reply }),
|
msgParam: JSON.stringify({ content: chunk }),
|
||||||
},
|
},
|
||||||
{ 'x-acs-dingtalk-access-token': token },
|
{ '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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch((e: Error) =>
|
||||||
|
this.logger.error(`batchSend failed for msgId=${msgId}:`, e.message),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Reply (chunked) ────────────────────────────────────────────────────────
|
// ── Reply (chunked) ────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue