@botcord/daemon 0.2.58 → 0.2.60
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/dist/config.d.ts +4 -1
- package/dist/config.js +2 -2
- package/dist/cross-room.js +3 -1
- package/dist/daemon-config-map.js +6 -0
- package/dist/daemon.js +21 -1
- package/dist/diagnostics.d.ts +1 -0
- package/dist/diagnostics.js +35 -6
- package/dist/gateway/channels/feishu-registration.d.ts +35 -0
- package/dist/gateway/channels/feishu-registration.js +101 -0
- package/dist/gateway/channels/feishu.d.ts +16 -0
- package/dist/gateway/channels/feishu.js +459 -0
- package/dist/gateway/channels/index.d.ts +2 -0
- package/dist/gateway/channels/index.js +2 -0
- package/dist/gateway/channels/login-session.d.ts +9 -1
- package/dist/gateway/channels/login-session.js +1 -1
- package/dist/gateway/channels/wechat.js +26 -2
- package/dist/gateway/dispatcher.d.ts +3 -0
- package/dist/gateway/dispatcher.js +190 -30
- package/dist/gateway/policy-resolver.d.ts +10 -6
- package/dist/gateway/types.d.ts +1 -1
- package/dist/gateway-control.d.ts +8 -1
- package/dist/gateway-control.js +171 -18
- package/dist/index.js +9 -3
- package/dist/log.d.ts +9 -0
- package/dist/log.js +89 -1
- package/dist/provision.js +7 -1
- package/package.json +2 -1
- package/src/__tests__/cross-room.test.ts +2 -0
- package/src/__tests__/diagnostics.test.ts +37 -1
- package/src/__tests__/gateway-control.test.ts +84 -0
- package/src/__tests__/log.test.ts +28 -1
- package/src/__tests__/policy-updated-handler.test.ts +5 -7
- package/src/__tests__/third-party-gateway.test.ts +28 -0
- package/src/__tests__/wechat-channel.test.ts +47 -0
- package/src/config.ts +6 -3
- package/src/cross-room.ts +3 -1
- package/src/daemon-config-map.ts +3 -0
- package/src/daemon.ts +24 -3
- package/src/diagnostics.ts +36 -6
- package/src/gateway/__tests__/dispatcher.test.ts +62 -4
- package/src/gateway/__tests__/feishu-channel.test.ts +306 -0
- package/src/gateway/channels/feishu-registration.ts +155 -0
- package/src/gateway/channels/feishu.ts +554 -0
- package/src/gateway/channels/index.ts +6 -0
- package/src/gateway/channels/login-session.ts +10 -2
- package/src/gateway/channels/wechat.ts +29 -2
- package/src/gateway/dispatcher.ts +216 -29
- package/src/gateway/policy-resolver.ts +19 -11
- package/src/gateway/types.ts +1 -1
- package/src/gateway-control.ts +188 -17
- package/src/index.ts +9 -3
- package/src/log.ts +100 -1
- package/src/provision.ts +13 -1
|
@@ -205,6 +205,10 @@ interface QueueState {
|
|
|
205
205
|
serialWorkerActive: boolean;
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
interface DeferredMultimodalEntry extends BufferedSerialEntry {
|
|
209
|
+
queuedAt: number;
|
|
210
|
+
}
|
|
211
|
+
|
|
208
212
|
/**
|
|
209
213
|
* Gateway dispatcher: consumes `GatewayInboundEnvelope` and drives a runtime
|
|
210
214
|
* turn per message, respecting queue mode, trust level, streaming, and
|
|
@@ -233,6 +237,7 @@ export class Dispatcher {
|
|
|
233
237
|
private readonly resolveHubUrl?: (accountId: string) => string | undefined;
|
|
234
238
|
private readonly transcript: TranscriptWriter;
|
|
235
239
|
private readonly queues: Map<string, QueueState> = new Map();
|
|
240
|
+
private readonly deferredMultimodal: Map<string, DeferredMultimodalEntry[]> = new Map();
|
|
236
241
|
/**
|
|
237
242
|
* Last `/hub/typing` ping timestamp per (accountId, conversationId).
|
|
238
243
|
* Used to debounce cancel-previous bursts so we don't trip Hub's 20/min
|
|
@@ -286,6 +291,11 @@ export class Dispatcher {
|
|
|
286
291
|
return;
|
|
287
292
|
}
|
|
288
293
|
|
|
294
|
+
const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
|
|
295
|
+
const route = resolveRoute(msg, this.config, managed);
|
|
296
|
+
const mode = resolveQueueMode(route, msg.conversation.kind);
|
|
297
|
+
const queueKey = buildQueueKey(msg);
|
|
298
|
+
|
|
289
299
|
// Pre-skip: empty/whitespace text.
|
|
290
300
|
const rawText = typeof msg.text === "string" ? msg.text.trim() : "";
|
|
291
301
|
if (!rawText) {
|
|
@@ -298,28 +308,87 @@ export class Dispatcher {
|
|
|
298
308
|
// turnId and write the inbound transcript record.
|
|
299
309
|
const turnId = randomUUID();
|
|
300
310
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
311
|
+
// Multimodal-only arrivals (files/images without sender-authored text)
|
|
312
|
+
// should not wake the runtime on their own. Ack them, record the inbound
|
|
313
|
+
// event, and prepend them to the next text-bearing turn for this queue.
|
|
314
|
+
if (isMultimodalOnlyMessage(msg)) {
|
|
315
|
+
await this.safeAck(envelope);
|
|
316
|
+
this.emitInbound(turnId, msg);
|
|
317
|
+
this.deferMultimodal(queueKey, { route, msg, channel, turnId, queuedAt: Date.now() });
|
|
318
|
+
this.log.info("dispatcher: deferred multimodal-only inbound", {
|
|
319
|
+
agentId: msg.accountId,
|
|
320
|
+
roomId: msg.conversation.id,
|
|
321
|
+
topicId: msg.conversation.threadId ?? null,
|
|
322
|
+
turnId,
|
|
323
|
+
messageId: msg.id,
|
|
324
|
+
senderId: msg.sender.id,
|
|
325
|
+
senderKind: msg.sender.kind,
|
|
326
|
+
mode,
|
|
327
|
+
queueKey,
|
|
328
|
+
});
|
|
329
|
+
if (this.onInbound) {
|
|
330
|
+
try {
|
|
331
|
+
await this.onInbound(msg);
|
|
332
|
+
} catch (err) {
|
|
333
|
+
this.log.warn("dispatcher: onInbound threw — continuing", {
|
|
334
|
+
messageId: msg.id,
|
|
335
|
+
error: err instanceof Error ? err.message : String(err),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const deferred = this.takeDeferredMultimodal(queueKey);
|
|
343
|
+
let dispatchMsg = msg;
|
|
344
|
+
let dispatchTurnId: string = turnId;
|
|
345
|
+
let dispatchRoute = route;
|
|
346
|
+
let dispatchChannel = channel;
|
|
347
|
+
let text = rawText;
|
|
348
|
+
let mergedFromDeferredTurnIds: string[] = [];
|
|
349
|
+
if (deferred.length > 0) {
|
|
350
|
+
const merged = this.mergeSerialBuffer(
|
|
351
|
+
[...deferred, { route, msg, channel, turnId }],
|
|
352
|
+
queueKey,
|
|
353
|
+
);
|
|
354
|
+
if (merged) {
|
|
355
|
+
dispatchMsg = merged.msg;
|
|
356
|
+
dispatchTurnId = merged.turnId;
|
|
357
|
+
dispatchRoute = merged.route;
|
|
358
|
+
dispatchChannel = merged.channel;
|
|
359
|
+
text = merged.text;
|
|
360
|
+
mergedFromDeferredTurnIds = deferred.map((e) => e.turnId);
|
|
361
|
+
for (const entry of deferred) {
|
|
362
|
+
this.transcript.write({
|
|
363
|
+
ts: nowIso(),
|
|
364
|
+
kind: "dropped",
|
|
365
|
+
turnId: entry.turnId,
|
|
366
|
+
agentId: entry.msg.accountId,
|
|
367
|
+
roomId: entry.msg.conversation.id,
|
|
368
|
+
topicId: entry.msg.conversation.threadId ?? null,
|
|
369
|
+
reason: "batch_merged",
|
|
370
|
+
supersededBy: dispatchTurnId,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
305
375
|
|
|
306
376
|
// Compose the final user-turn text only for cancel-previous mode, where
|
|
307
377
|
// the dispatcher consumes the pre-composed text directly. Serial mode
|
|
308
378
|
// re-runs the composer at drain time on the merged message (so it sees
|
|
309
379
|
// the full coalesced batch instead of any single arrival), so calling
|
|
310
380
|
// the composer here would just be redundant work.
|
|
311
|
-
let text = rawText;
|
|
312
381
|
let composeFailedError: string | undefined;
|
|
313
382
|
if (mode === "cancel-previous" && this.composeUserTurn) {
|
|
314
383
|
try {
|
|
315
|
-
const composed = this.composeUserTurn(
|
|
384
|
+
const composed = this.composeUserTurn(dispatchMsg);
|
|
316
385
|
if (typeof composed === "string" && composed.length > 0) {
|
|
317
386
|
text = composed;
|
|
318
387
|
}
|
|
319
388
|
} catch (err) {
|
|
320
389
|
composeFailedError = err instanceof Error ? err.message : String(err);
|
|
321
390
|
this.log.warn("dispatcher: composeUserTurn threw — using raw text", {
|
|
322
|
-
messageId:
|
|
391
|
+
messageId: dispatchMsg.id,
|
|
323
392
|
error: composeFailedError,
|
|
324
393
|
});
|
|
325
394
|
}
|
|
@@ -364,28 +433,28 @@ export class Dispatcher {
|
|
|
364
433
|
if (this.attentionGate) {
|
|
365
434
|
let wake = true;
|
|
366
435
|
try {
|
|
367
|
-
const result = this.attentionGate(
|
|
436
|
+
const result = this.attentionGate(dispatchMsg);
|
|
368
437
|
wake = result instanceof Promise ? await result : result;
|
|
369
438
|
} catch (err) {
|
|
370
439
|
this.log.warn("dispatcher: attentionGate threw — waking", {
|
|
371
|
-
messageId:
|
|
440
|
+
messageId: dispatchMsg.id,
|
|
372
441
|
error: err instanceof Error ? err.message : String(err),
|
|
373
442
|
});
|
|
374
443
|
wake = true;
|
|
375
444
|
}
|
|
376
445
|
if (!wake) {
|
|
377
446
|
this.log.debug("dispatcher skip turn: attention policy", {
|
|
378
|
-
messageId:
|
|
379
|
-
accountId:
|
|
380
|
-
conversationId:
|
|
447
|
+
messageId: dispatchMsg.id,
|
|
448
|
+
accountId: dispatchMsg.accountId,
|
|
449
|
+
conversationId: dispatchMsg.conversation.id,
|
|
381
450
|
});
|
|
382
451
|
this.transcript.write({
|
|
383
452
|
ts: nowIso(),
|
|
384
453
|
kind: "attention_skipped",
|
|
385
|
-
turnId,
|
|
386
|
-
agentId:
|
|
387
|
-
roomId:
|
|
388
|
-
topicId:
|
|
454
|
+
turnId: dispatchTurnId,
|
|
455
|
+
agentId: dispatchMsg.accountId,
|
|
456
|
+
roomId: dispatchMsg.conversation.id,
|
|
457
|
+
topicId: dispatchMsg.conversation.threadId ?? null,
|
|
389
458
|
reason: "attention_gate_false",
|
|
390
459
|
});
|
|
391
460
|
return;
|
|
@@ -396,19 +465,35 @@ export class Dispatcher {
|
|
|
396
465
|
this.transcript.write({
|
|
397
466
|
ts: nowIso(),
|
|
398
467
|
kind: "compose_failed",
|
|
399
|
-
turnId,
|
|
400
|
-
agentId:
|
|
401
|
-
roomId:
|
|
402
|
-
topicId:
|
|
468
|
+
turnId: dispatchTurnId,
|
|
469
|
+
agentId: dispatchMsg.accountId,
|
|
470
|
+
roomId: dispatchMsg.conversation.id,
|
|
471
|
+
topicId: dispatchMsg.conversation.threadId ?? null,
|
|
403
472
|
error: composeFailedError,
|
|
404
473
|
fallback: "raw_text",
|
|
405
474
|
});
|
|
406
475
|
}
|
|
407
476
|
|
|
408
477
|
if (mode === "cancel-previous") {
|
|
409
|
-
await this.runCancelPrevious(
|
|
478
|
+
await this.runCancelPrevious(
|
|
479
|
+
queueKey,
|
|
480
|
+
dispatchRoute,
|
|
481
|
+
text,
|
|
482
|
+
dispatchMsg,
|
|
483
|
+
dispatchChannel,
|
|
484
|
+
dispatchTurnId,
|
|
485
|
+
mergedFromDeferredTurnIds,
|
|
486
|
+
);
|
|
410
487
|
} else {
|
|
411
|
-
await this.runSerial(
|
|
488
|
+
await this.runSerial(
|
|
489
|
+
queueKey,
|
|
490
|
+
dispatchRoute,
|
|
491
|
+
text,
|
|
492
|
+
dispatchMsg,
|
|
493
|
+
dispatchChannel,
|
|
494
|
+
dispatchTurnId,
|
|
495
|
+
mergedFromDeferredTurnIds,
|
|
496
|
+
);
|
|
412
497
|
}
|
|
413
498
|
}
|
|
414
499
|
|
|
@@ -452,6 +537,37 @@ export class Dispatcher {
|
|
|
452
537
|
return q;
|
|
453
538
|
}
|
|
454
539
|
|
|
540
|
+
private deferMultimodal(queueKey: string, entry: DeferredMultimodalEntry): void {
|
|
541
|
+
const list = this.deferredMultimodal.get(queueKey) ?? [];
|
|
542
|
+
list.push(entry);
|
|
543
|
+
while (list.length > MAX_BATCH_BUFFER_ENTRIES) {
|
|
544
|
+
const dropped = list.shift()!;
|
|
545
|
+
this.log.warn("dispatcher: deferred multimodal buffer overflow — dropped oldest", {
|
|
546
|
+
queueKey,
|
|
547
|
+
droppedMessageId: dropped.msg.id,
|
|
548
|
+
bufferCap: MAX_BATCH_BUFFER_ENTRIES,
|
|
549
|
+
});
|
|
550
|
+
this.transcript.write({
|
|
551
|
+
ts: nowIso(),
|
|
552
|
+
kind: "dropped",
|
|
553
|
+
turnId: dropped.turnId,
|
|
554
|
+
agentId: dropped.msg.accountId,
|
|
555
|
+
roomId: dropped.msg.conversation.id,
|
|
556
|
+
topicId: dropped.msg.conversation.threadId ?? null,
|
|
557
|
+
reason: "queue_overflow",
|
|
558
|
+
supersededBy: null,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
this.deferredMultimodal.set(queueKey, list);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private takeDeferredMultimodal(queueKey: string): DeferredMultimodalEntry[] {
|
|
565
|
+
const list = this.deferredMultimodal.get(queueKey);
|
|
566
|
+
if (!list || list.length === 0) return [];
|
|
567
|
+
this.deferredMultimodal.delete(queueKey);
|
|
568
|
+
return list;
|
|
569
|
+
}
|
|
570
|
+
|
|
455
571
|
private async runCancelPrevious(
|
|
456
572
|
queueKey: string,
|
|
457
573
|
route: GatewayRoute,
|
|
@@ -459,6 +575,7 @@ export class Dispatcher {
|
|
|
459
575
|
msg: GatewayInboundEnvelope["message"],
|
|
460
576
|
channel: ChannelAdapter,
|
|
461
577
|
turnId: string,
|
|
578
|
+
mergedFromTurnIds: string[] = [],
|
|
462
579
|
): Promise<void> {
|
|
463
580
|
const q = this.getQueue(queueKey);
|
|
464
581
|
// Bump the generation on every arrival. Older arrivals still awaiting
|
|
@@ -518,7 +635,7 @@ export class Dispatcher {
|
|
|
518
635
|
});
|
|
519
636
|
return;
|
|
520
637
|
}
|
|
521
|
-
await this.runTurn(queueKey, route, text, msg, channel, turnId,
|
|
638
|
+
await this.runTurn(queueKey, route, text, msg, channel, turnId, mergedFromTurnIds);
|
|
522
639
|
}
|
|
523
640
|
|
|
524
641
|
/**
|
|
@@ -546,6 +663,7 @@ export class Dispatcher {
|
|
|
546
663
|
msg: GatewayInboundEnvelope["message"],
|
|
547
664
|
channel: ChannelAdapter,
|
|
548
665
|
turnId: string,
|
|
666
|
+
mergedFromTurnIds: string[] = [],
|
|
549
667
|
): Promise<void> {
|
|
550
668
|
const q = this.getQueue(queueKey);
|
|
551
669
|
q.serialBuffer.push({ route, msg, channel, turnId });
|
|
@@ -591,8 +709,10 @@ export class Dispatcher {
|
|
|
591
709
|
});
|
|
592
710
|
}
|
|
593
711
|
}
|
|
594
|
-
const
|
|
595
|
-
drained.length > 1
|
|
712
|
+
const mergedTurnIds =
|
|
713
|
+
drained.length > 1
|
|
714
|
+
? [...mergedFromTurnIds, ...drained.slice(0, -1).map((e) => e.turnId)]
|
|
715
|
+
: mergedFromTurnIds;
|
|
596
716
|
await this.runTurn(
|
|
597
717
|
queueKey,
|
|
598
718
|
merged.route,
|
|
@@ -600,7 +720,7 @@ export class Dispatcher {
|
|
|
600
720
|
merged.msg,
|
|
601
721
|
merged.channel,
|
|
602
722
|
merged.turnId,
|
|
603
|
-
|
|
723
|
+
mergedTurnIds,
|
|
604
724
|
);
|
|
605
725
|
}
|
|
606
726
|
} finally {
|
|
@@ -681,8 +801,13 @@ export class Dispatcher {
|
|
|
681
801
|
const latestRaw = (latest.msg.raw as Record<string, unknown> | null | undefined) ?? {};
|
|
682
802
|
const mergedRaw = { ...latestRaw, batch: items };
|
|
683
803
|
const anyMentioned = entries.some((e) => e.msg.mentioned === true);
|
|
804
|
+
const mergedText = entries
|
|
805
|
+
.map((e) => (typeof e.msg.text === "string" ? e.msg.text.trim() : ""))
|
|
806
|
+
.filter((s) => s.length > 0)
|
|
807
|
+
.join("\n");
|
|
684
808
|
const mergedMsg: GatewayInboundEnvelope["message"] = {
|
|
685
809
|
...latest.msg,
|
|
810
|
+
...(mergedText ? { text: mergedText } : {}),
|
|
686
811
|
mentioned: anyMentioned,
|
|
687
812
|
raw: mergedRaw,
|
|
688
813
|
};
|
|
@@ -1119,6 +1244,7 @@ export class Dispatcher {
|
|
|
1119
1244
|
// own loop-risk accounting downstream.
|
|
1120
1245
|
const isOwnerChat = isOwnerChatRoom(msg);
|
|
1121
1246
|
const canDeliverRuntimeText = isOwnerChat || !isBotCordChannel(channel);
|
|
1247
|
+
const canDeliverRuntimeDiagnostics = canDeliverRuntimeText || isBotCordChannel(channel);
|
|
1122
1248
|
|
|
1123
1249
|
if (slot.timedOut) {
|
|
1124
1250
|
this.transcript.write({
|
|
@@ -1132,7 +1258,7 @@ export class Dispatcher {
|
|
|
1132
1258
|
error: `runtime timeout after ${this.turnTimeoutMs}ms`,
|
|
1133
1259
|
durationMs: Date.now() - slot.dispatchedAt,
|
|
1134
1260
|
});
|
|
1135
|
-
if (
|
|
1261
|
+
if (canDeliverRuntimeDiagnostics) {
|
|
1136
1262
|
await this.sendReply(channel, {
|
|
1137
1263
|
channel: msg.channel,
|
|
1138
1264
|
accountId: msg.accountId,
|
|
@@ -1177,7 +1303,7 @@ export class Dispatcher {
|
|
|
1177
1303
|
error: errMsg,
|
|
1178
1304
|
durationMs: Date.now() - slot.dispatchedAt,
|
|
1179
1305
|
});
|
|
1180
|
-
if (
|
|
1306
|
+
if (canDeliverRuntimeDiagnostics) {
|
|
1181
1307
|
await this.sendReply(channel, {
|
|
1182
1308
|
channel: msg.channel,
|
|
1183
1309
|
accountId: msg.accountId,
|
|
@@ -1266,7 +1392,7 @@ export class Dispatcher {
|
|
|
1266
1392
|
runtime: route.runtime,
|
|
1267
1393
|
error: result.error,
|
|
1268
1394
|
});
|
|
1269
|
-
if (
|
|
1395
|
+
if (canDeliverRuntimeDiagnostics) {
|
|
1270
1396
|
const sendResult = await this.sendReply(channel, {
|
|
1271
1397
|
channel: msg.channel,
|
|
1272
1398
|
accountId: msg.accountId,
|
|
@@ -1527,6 +1653,67 @@ function isBotCordChannel(channel: ChannelAdapter): boolean {
|
|
|
1527
1653
|
return channel.type === "botcord" || channel.id === "botcord";
|
|
1528
1654
|
}
|
|
1529
1655
|
|
|
1656
|
+
function isMultimodalOnlyMessage(msg: GatewayInboundEnvelope["message"]): boolean {
|
|
1657
|
+
if (!hasMultimodalContent(msg.raw)) return false;
|
|
1658
|
+
return !hasAuthoredText(msg.raw);
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
function hasAuthoredText(raw: unknown): boolean {
|
|
1662
|
+
if (!raw || typeof raw !== "object") return false;
|
|
1663
|
+
const obj = raw as Record<string, unknown>;
|
|
1664
|
+
const batch = obj.batch;
|
|
1665
|
+
if (Array.isArray(batch)) return batch.some((item) => hasAuthoredText(item));
|
|
1666
|
+
|
|
1667
|
+
if (typeof obj.text === "string" && obj.text.trim().length > 0) {
|
|
1668
|
+
// BotCord's /hub/inbox `text` may be synthesized from attachment metadata
|
|
1669
|
+
// when payload text is empty, so prefer envelope payload below when present.
|
|
1670
|
+
if (!obj.envelope || typeof obj.envelope !== "object") return true;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
const envelope = obj.envelope as Record<string, unknown> | undefined;
|
|
1674
|
+
const payload = envelope?.payload as Record<string, unknown> | undefined;
|
|
1675
|
+
if (payload) {
|
|
1676
|
+
for (const key of ["text", "body", "message"]) {
|
|
1677
|
+
const value = payload[key];
|
|
1678
|
+
if (typeof value === "string" && value.trim().length > 0) return true;
|
|
1679
|
+
}
|
|
1680
|
+
return false;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
const itemList = obj.item_list;
|
|
1684
|
+
if (Array.isArray(itemList)) {
|
|
1685
|
+
return itemList.some((item) => {
|
|
1686
|
+
if (!item || typeof item !== "object") return false;
|
|
1687
|
+
const textItem = (item as { text_item?: { text?: unknown } }).text_item;
|
|
1688
|
+
return typeof textItem?.text === "string" && textItem.text.trim().length > 0;
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
return typeof obj.text === "string" && obj.text.trim().length > 0;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
function hasMultimodalContent(raw: unknown): boolean {
|
|
1696
|
+
if (!raw || typeof raw !== "object") return false;
|
|
1697
|
+
const obj = raw as Record<string, unknown>;
|
|
1698
|
+
const batch = obj.batch;
|
|
1699
|
+
if (Array.isArray(batch)) return batch.some((item) => hasMultimodalContent(item));
|
|
1700
|
+
|
|
1701
|
+
const envelope = obj.envelope as Record<string, unknown> | undefined;
|
|
1702
|
+
const payload = envelope?.payload as Record<string, unknown> | undefined;
|
|
1703
|
+
const attachments = payload?.attachments;
|
|
1704
|
+
if (Array.isArray(attachments) && attachments.length > 0) return true;
|
|
1705
|
+
|
|
1706
|
+
const itemList = obj.item_list;
|
|
1707
|
+
if (Array.isArray(itemList)) {
|
|
1708
|
+
return itemList.some((item) => {
|
|
1709
|
+
if (!item || typeof item !== "object") return false;
|
|
1710
|
+
return (item as { type?: unknown }).type !== 1;
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
return false;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1530
1717
|
function resolveQueueMode(
|
|
1531
1718
|
route: GatewayRoute,
|
|
1532
1719
|
kind: "direct" | "group",
|
|
@@ -24,21 +24,26 @@
|
|
|
24
24
|
|
|
25
25
|
import type { AttentionPolicy } from "@botcord/protocol-core";
|
|
26
26
|
|
|
27
|
+
export type DaemonAttentionPolicy = Omit<AttentionPolicy, "mode"> & {
|
|
28
|
+
mode: AttentionPolicy["mode"] | "allowed_senders";
|
|
29
|
+
allowedSenderIds?: string[];
|
|
30
|
+
};
|
|
31
|
+
|
|
27
32
|
/** Public surface — kept narrow so the dispatcher can mock easily in tests. */
|
|
28
33
|
export interface PolicyResolverLike {
|
|
29
|
-
resolve(agentId: string, roomId: string | null): Promise<
|
|
34
|
+
resolve(agentId: string, roomId: string | null): Promise<DaemonAttentionPolicy>;
|
|
30
35
|
invalidate(agentId: string, roomId?: string): void;
|
|
31
36
|
/**
|
|
32
37
|
* Install (or replace) the cached policy entry for an agent / room. Used
|
|
33
38
|
* by the `policy_updated` control-frame handler to apply embedded policy
|
|
34
39
|
* payloads without forcing a refetch.
|
|
35
40
|
*/
|
|
36
|
-
put(agentId: string, roomId: string | null, policy:
|
|
41
|
+
put(agentId: string, roomId: string | null, policy: DaemonAttentionPolicy): void;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
export interface PolicyResolverOptions {
|
|
40
45
|
/** Fetcher for the per-agent default. Returning `undefined` means "no policy known"; the resolver falls back to `mode=always`. */
|
|
41
|
-
fetchGlobal: (agentId: string) => Promise<
|
|
46
|
+
fetchGlobal: (agentId: string) => Promise<DaemonAttentionPolicy | undefined>;
|
|
42
47
|
/**
|
|
43
48
|
* Optional per-room fetcher. PR2 supplies this; PR3 leaves it
|
|
44
49
|
* unimplemented and the resolver collapses to the global policy.
|
|
@@ -46,13 +51,13 @@ export interface PolicyResolverOptions {
|
|
|
46
51
|
fetchEffective?: (
|
|
47
52
|
agentId: string,
|
|
48
53
|
roomId: string,
|
|
49
|
-
) => Promise<
|
|
54
|
+
) => Promise<DaemonAttentionPolicy | undefined>;
|
|
50
55
|
/** Cache TTL in milliseconds. Defaults to 5 minutes. */
|
|
51
56
|
ttlMs?: number;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
interface Entry {
|
|
55
|
-
policy:
|
|
60
|
+
policy: DaemonAttentionPolicy;
|
|
56
61
|
expiresAt: number;
|
|
57
62
|
}
|
|
58
63
|
|
|
@@ -64,14 +69,17 @@ const FETCH_FAILED = Symbol("fetch_failed");
|
|
|
64
69
|
* lets the user mute a DM, but a stale cache from before a UX bug is cheap
|
|
65
70
|
* to defend against here.
|
|
66
71
|
*/
|
|
67
|
-
function maybeForceDm(
|
|
72
|
+
function maybeForceDm(
|
|
73
|
+
roomId: string | null,
|
|
74
|
+
policy: DaemonAttentionPolicy,
|
|
75
|
+
): DaemonAttentionPolicy {
|
|
68
76
|
if (roomId && roomId.startsWith("rm_dm_") && policy.mode !== "always") {
|
|
69
77
|
return { ...policy, mode: "always" };
|
|
70
78
|
}
|
|
71
79
|
return policy;
|
|
72
80
|
}
|
|
73
81
|
|
|
74
|
-
function defaultPolicy():
|
|
82
|
+
function defaultPolicy(): DaemonAttentionPolicy {
|
|
75
83
|
return { mode: "always", keywords: [] };
|
|
76
84
|
}
|
|
77
85
|
|
|
@@ -87,7 +95,7 @@ export class PolicyResolver implements PolicyResolverLike {
|
|
|
87
95
|
this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
88
96
|
}
|
|
89
97
|
|
|
90
|
-
async resolve(agentId: string, roomId: string | null): Promise<
|
|
98
|
+
async resolve(agentId: string, roomId: string | null): Promise<DaemonAttentionPolicy> {
|
|
91
99
|
const now = Date.now();
|
|
92
100
|
|
|
93
101
|
// 1. Per-room cache — populated either by a `policy_updated{room_id}`
|
|
@@ -132,8 +140,8 @@ export class PolicyResolver implements PolicyResolverLike {
|
|
|
132
140
|
}
|
|
133
141
|
|
|
134
142
|
private async safeFetch(
|
|
135
|
-
fn: () => Promise<
|
|
136
|
-
): Promise<
|
|
143
|
+
fn: () => Promise<DaemonAttentionPolicy | undefined>,
|
|
144
|
+
): Promise<DaemonAttentionPolicy | undefined | typeof FETCH_FAILED> {
|
|
137
145
|
try {
|
|
138
146
|
return await fn();
|
|
139
147
|
} catch {
|
|
@@ -157,7 +165,7 @@ export class PolicyResolver implements PolicyResolverLike {
|
|
|
157
165
|
}
|
|
158
166
|
}
|
|
159
167
|
|
|
160
|
-
put(agentId: string, roomId: string | null, policy:
|
|
168
|
+
put(agentId: string, roomId: string | null, policy: DaemonAttentionPolicy): void {
|
|
161
169
|
const key = cacheKey(agentId, roomId);
|
|
162
170
|
this.cache.set(key, {
|
|
163
171
|
policy: maybeForceDm(roomId, policy),
|
package/src/gateway/types.ts
CHANGED
|
@@ -209,7 +209,7 @@ export interface ChannelStatusSnapshot {
|
|
|
209
209
|
lastStopAt?: number;
|
|
210
210
|
lastError?: string | null;
|
|
211
211
|
/** Third-party provider id when this channel is not the built-in BotCord. */
|
|
212
|
-
provider?: "wechat" | "telegram";
|
|
212
|
+
provider?: "wechat" | "telegram" | "feishu";
|
|
213
213
|
/** Last time the adapter polled the upstream provider (ms epoch). */
|
|
214
214
|
lastPollAt?: number;
|
|
215
215
|
/** Last time the adapter accepted an inbound message (ms epoch). */
|