@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/monitor.ts +119 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaoyuanchao/dingtalk",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "type": "module",
5
5
  "description": "DingTalk channel plugin for ClawdBot/OpenClaw with Stream Mode support",
6
6
  "license": "MIT",
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
- reconnectAttempt = 0;
303
- lastActivityTime = Date.now();
334
+ const connectTime = Date.now();
335
+ lastActivityTime = connectTime;
304
336
  log?.info?.("[dingtalk:" + account.accountId + "] Stream connected");
305
- setStatus?.({ running: true, lastStartAt: Date.now() });
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
- // Fire-and-forget: don't await to avoid blocking SDK callback during long agent runs
1441
- runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
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