@yaoyuanchao/dingtalk 1.6.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/monitor.ts +119 -9
package/package.json
CHANGED
package/src/monitor.ts
CHANGED
|
@@ -190,6 +190,32 @@ interface BufferedMessage {
|
|
|
190
190
|
const messageBuffer = new Map<string, BufferedMessage>();
|
|
191
191
|
const AGGREGATION_DELAY_MS = 2000; // 2 seconds - balance between UX and catching split messages
|
|
192
192
|
|
|
193
|
+
// ============================================================================
|
|
194
|
+
// Per-Session Message Queue - serializes dispatch to prevent concurrent
|
|
195
|
+
// processing of messages in the same conversation. Uses Promise chaining:
|
|
196
|
+
// each new message's dispatch waits for the previous one to complete.
|
|
197
|
+
// ============================================================================
|
|
198
|
+
|
|
199
|
+
const sessionQueues = new Map<string, Promise<void>>();
|
|
200
|
+
const sessionQueueLastActivity = new Map<string, number>();
|
|
201
|
+
const SESSION_QUEUE_TTL_MS = 5 * 60 * 1000; // 5 min
|
|
202
|
+
|
|
203
|
+
const QUEUE_BUSY_PHRASES = [
|
|
204
|
+
"收到,前面还有消息在处理,稍后按顺序继续。",
|
|
205
|
+
"当前正在处理中,你的新消息已排队,完成后马上继续。",
|
|
206
|
+
"我还在处理上一条,这条已记下,稍后继续。",
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
/** Clean up expired session queues (runs periodically) */
|
|
210
|
+
function cleanupExpiredSessionQueues(): void {
|
|
211
|
+
const now = Date.now();
|
|
212
|
+
for (const [key, ts] of sessionQueueLastActivity) {
|
|
213
|
+
if (now - ts > SESSION_QUEUE_TTL_MS && !sessionQueues.has(key)) {
|
|
214
|
+
sessionQueueLastActivity.delete(key);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
193
219
|
function getBufferKey(msg: DingTalkRobotMessage, accountId: string): string {
|
|
194
220
|
return `${accountId}:${msg.conversationId}:${msg.senderId || msg.senderStaffId}`;
|
|
195
221
|
}
|
|
@@ -219,10 +245,16 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
|
|
|
219
245
|
cleanupOldMedia();
|
|
220
246
|
}, 60 * 60 * 1000); // 1 hour
|
|
221
247
|
|
|
248
|
+
// Schedule session queue cleanup every 60s
|
|
249
|
+
const queueCleanupInterval = setInterval(cleanupExpiredSessionQueues, 60_000);
|
|
250
|
+
|
|
222
251
|
// Clean up on abort (only if abortSignal is provided)
|
|
223
252
|
if (abortSignal) {
|
|
224
253
|
abortSignal.addEventListener('abort', () => {
|
|
225
254
|
clearInterval(cleanupInterval);
|
|
255
|
+
clearInterval(queueCleanupInterval);
|
|
256
|
+
sessionQueues.clear();
|
|
257
|
+
sessionQueueLastActivity.clear();
|
|
226
258
|
});
|
|
227
259
|
}
|
|
228
260
|
|
|
@@ -299,10 +331,10 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
|
|
|
299
331
|
while (!abortSignal?.aborted) {
|
|
300
332
|
try {
|
|
301
333
|
await client.connect();
|
|
302
|
-
|
|
303
|
-
lastActivityTime =
|
|
334
|
+
const connectTime = Date.now();
|
|
335
|
+
lastActivityTime = connectTime;
|
|
304
336
|
log?.info?.("[dingtalk:" + account.accountId + "] Stream connected");
|
|
305
|
-
setStatus?.({ running: true, lastStartAt:
|
|
337
|
+
setStatus?.({ running: true, lastStartAt: connectTime });
|
|
306
338
|
|
|
307
339
|
// Start heartbeat monitor: if no activity for 5 minutes, force disconnect to trigger reconnect.
|
|
308
340
|
// The SDK's keepAlive ping/pong (8s interval) handles socket-level liveness and sets
|
|
@@ -331,6 +363,13 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
|
|
|
331
363
|
}, { once: true });
|
|
332
364
|
}
|
|
333
365
|
});
|
|
366
|
+
|
|
367
|
+
// Only reset backoff if connection was stable (survived > 30s)
|
|
368
|
+
// This prevents rapid reconnect loops when connect() succeeds but
|
|
369
|
+
// the socket drops immediately (e.g. gateway returns unreachable endpoint)
|
|
370
|
+
if (Date.now() - connectTime > 30_000) {
|
|
371
|
+
reconnectAttempt = 0;
|
|
372
|
+
}
|
|
334
373
|
} catch (err) {
|
|
335
374
|
log?.warn?.("[dingtalk] Connection error: " + (err instanceof Error ? err.message : String(err)));
|
|
336
375
|
}
|
|
@@ -1308,6 +1347,7 @@ async function flushMessageBuffer(bufferKey: string): Promise<void> {
|
|
|
1308
1347
|
|
|
1309
1348
|
/**
|
|
1310
1349
|
* Dispatch a message to the agent (after aggregation or immediately).
|
|
1350
|
+
* Enqueues into per-session queue to prevent concurrent processing.
|
|
1311
1351
|
*/
|
|
1312
1352
|
async function dispatchMessage(params: {
|
|
1313
1353
|
ctx: DingTalkMonitorContext;
|
|
@@ -1321,6 +1361,78 @@ async function dispatchMessage(params: {
|
|
|
1321
1361
|
conversationId: string;
|
|
1322
1362
|
mediaPath?: string;
|
|
1323
1363
|
mediaType?: string;
|
|
1364
|
+
}): Promise<void> {
|
|
1365
|
+
const { ctx, conversationId } = params;
|
|
1366
|
+
const { account, log } = ctx;
|
|
1367
|
+
|
|
1368
|
+
const queueKey = `${account.accountId}:${conversationId}`;
|
|
1369
|
+
const isQueueBusy = sessionQueues.has(queueKey);
|
|
1370
|
+
|
|
1371
|
+
// If queue is busy, send a recallable notification so it disappears when processing starts
|
|
1372
|
+
let queueAckCleanup: (() => Promise<void>) | null = null;
|
|
1373
|
+
if (isQueueBusy) {
|
|
1374
|
+
const phrase = QUEUE_BUSY_PHRASES[Math.floor(Math.random() * QUEUE_BUSY_PHRASES.length)];
|
|
1375
|
+
log?.info?.("[dingtalk] Queue busy for " + queueKey + ", notifying user");
|
|
1376
|
+
try {
|
|
1377
|
+
if (account.clientId && account.clientSecret) {
|
|
1378
|
+
const robotCode = account.robotCode || account.clientId;
|
|
1379
|
+
const result = await sendTypingIndicator({
|
|
1380
|
+
clientId: account.clientId,
|
|
1381
|
+
clientSecret: account.clientSecret,
|
|
1382
|
+
robotCode,
|
|
1383
|
+
userId: params.isDm ? params.senderId : undefined,
|
|
1384
|
+
conversationId: !params.isDm ? conversationId : undefined,
|
|
1385
|
+
message: '⏳ ' + phrase,
|
|
1386
|
+
});
|
|
1387
|
+
if (!result.error) {
|
|
1388
|
+
queueAckCleanup = result.cleanup;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
} catch (_) { /* best-effort notification */ }
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// Enqueue: chain onto previous task
|
|
1395
|
+
const previousTask = sessionQueues.get(queueKey) || Promise.resolve();
|
|
1396
|
+
const currentTask = previousTask
|
|
1397
|
+
.then(async () => {
|
|
1398
|
+
// Recall queue-busy notification before starting actual processing
|
|
1399
|
+
if (queueAckCleanup) {
|
|
1400
|
+
try { await queueAckCleanup(); log?.info?.("[dingtalk] Queue ack recalled, starting processing"); } catch (_) {}
|
|
1401
|
+
}
|
|
1402
|
+
await dispatchMessageInternal(params);
|
|
1403
|
+
})
|
|
1404
|
+
.catch((err) => {
|
|
1405
|
+
log?.info?.("[dingtalk] Queued dispatch error: " + (err instanceof Error ? err.message : String(err)));
|
|
1406
|
+
})
|
|
1407
|
+
.finally(() => {
|
|
1408
|
+
sessionQueueLastActivity.set(queueKey, Date.now());
|
|
1409
|
+
// Clean up only if this is still the latest task
|
|
1410
|
+
if (sessionQueues.get(queueKey) === currentTask) {
|
|
1411
|
+
sessionQueues.delete(queueKey);
|
|
1412
|
+
}
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
sessionQueues.set(queueKey, currentTask);
|
|
1416
|
+
sessionQueueLastActivity.set(queueKey, Date.now());
|
|
1417
|
+
|
|
1418
|
+
// Don't await — fire-and-forget so message buffering and SDK callback stay responsive
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* Internal dispatch: actually processes the message (typing indicator, agent call, reply).
|
|
1423
|
+
*/
|
|
1424
|
+
async function dispatchMessageInternal(params: {
|
|
1425
|
+
ctx: DingTalkMonitorContext;
|
|
1426
|
+
msg: DingTalkRobotMessage;
|
|
1427
|
+
rawBody: string;
|
|
1428
|
+
replyTarget: any;
|
|
1429
|
+
sessionKey: string;
|
|
1430
|
+
isDm: boolean;
|
|
1431
|
+
senderId: string;
|
|
1432
|
+
senderName: string;
|
|
1433
|
+
conversationId: string;
|
|
1434
|
+
mediaPath?: string;
|
|
1435
|
+
mediaType?: string;
|
|
1324
1436
|
}): Promise<void> {
|
|
1325
1437
|
const { ctx, msg, rawBody, replyTarget, sessionKey, isDm, senderId, senderName, conversationId, mediaPath, mediaType } = params;
|
|
1326
1438
|
const { account, cfg, log, setStatus } = ctx;
|
|
@@ -1437,15 +1549,16 @@ async function dispatchMessage(params: {
|
|
|
1437
1549
|
GroupSystemPrompt: _fallbackGroupSystemPrompt,
|
|
1438
1550
|
};
|
|
1439
1551
|
|
|
1440
|
-
//
|
|
1441
|
-
|
|
1552
|
+
// Await dispatch so per-session queue waits for reply delivery to complete
|
|
1553
|
+
// before starting the next queued message.
|
|
1554
|
+
await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1442
1555
|
ctx: ctxPayload,
|
|
1443
1556
|
cfg: actualCfg,
|
|
1444
1557
|
dispatcherOptions: {
|
|
1445
1558
|
deliver: async (payload: any) => {
|
|
1446
1559
|
// Recall typing indicator on first delivery
|
|
1447
1560
|
await cleanupTyping();
|
|
1448
|
-
|
|
1561
|
+
|
|
1449
1562
|
log?.info?.("[dingtalk] Deliver payload keys: " + Object.keys(payload || {}).join(',') + " text?=" + (typeof payload?.text) + " markdown?=" + (typeof payload?.markdown));
|
|
1450
1563
|
const textToSend = resolveDeliverText(payload, log);
|
|
1451
1564
|
if (textToSend) {
|
|
@@ -1461,9 +1574,6 @@ async function dispatchMessage(params: {
|
|
|
1461
1574
|
log?.info?.("[dingtalk] Reply error: " + err);
|
|
1462
1575
|
},
|
|
1463
1576
|
},
|
|
1464
|
-
}).catch((err) => {
|
|
1465
|
-
cleanupTyping().catch(() => {});
|
|
1466
|
-
log?.info?.("[dingtalk] Dispatch failed: " + err);
|
|
1467
1577
|
});
|
|
1468
1578
|
|
|
1469
1579
|
// Record activity
|