@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.
Files changed (53) hide show
  1. package/dist/config.d.ts +4 -1
  2. package/dist/config.js +2 -2
  3. package/dist/cross-room.js +3 -1
  4. package/dist/daemon-config-map.js +6 -0
  5. package/dist/daemon.js +21 -1
  6. package/dist/diagnostics.d.ts +1 -0
  7. package/dist/diagnostics.js +35 -6
  8. package/dist/gateway/channels/feishu-registration.d.ts +35 -0
  9. package/dist/gateway/channels/feishu-registration.js +101 -0
  10. package/dist/gateway/channels/feishu.d.ts +16 -0
  11. package/dist/gateway/channels/feishu.js +459 -0
  12. package/dist/gateway/channels/index.d.ts +2 -0
  13. package/dist/gateway/channels/index.js +2 -0
  14. package/dist/gateway/channels/login-session.d.ts +9 -1
  15. package/dist/gateway/channels/login-session.js +1 -1
  16. package/dist/gateway/channels/wechat.js +26 -2
  17. package/dist/gateway/dispatcher.d.ts +3 -0
  18. package/dist/gateway/dispatcher.js +190 -30
  19. package/dist/gateway/policy-resolver.d.ts +10 -6
  20. package/dist/gateway/types.d.ts +1 -1
  21. package/dist/gateway-control.d.ts +8 -1
  22. package/dist/gateway-control.js +171 -18
  23. package/dist/index.js +9 -3
  24. package/dist/log.d.ts +9 -0
  25. package/dist/log.js +89 -1
  26. package/dist/provision.js +7 -1
  27. package/package.json +2 -1
  28. package/src/__tests__/cross-room.test.ts +2 -0
  29. package/src/__tests__/diagnostics.test.ts +37 -1
  30. package/src/__tests__/gateway-control.test.ts +84 -0
  31. package/src/__tests__/log.test.ts +28 -1
  32. package/src/__tests__/policy-updated-handler.test.ts +5 -7
  33. package/src/__tests__/third-party-gateway.test.ts +28 -0
  34. package/src/__tests__/wechat-channel.test.ts +47 -0
  35. package/src/config.ts +6 -3
  36. package/src/cross-room.ts +3 -1
  37. package/src/daemon-config-map.ts +3 -0
  38. package/src/daemon.ts +24 -3
  39. package/src/diagnostics.ts +36 -6
  40. package/src/gateway/__tests__/dispatcher.test.ts +62 -4
  41. package/src/gateway/__tests__/feishu-channel.test.ts +306 -0
  42. package/src/gateway/channels/feishu-registration.ts +155 -0
  43. package/src/gateway/channels/feishu.ts +554 -0
  44. package/src/gateway/channels/index.ts +6 -0
  45. package/src/gateway/channels/login-session.ts +10 -2
  46. package/src/gateway/channels/wechat.ts +29 -2
  47. package/src/gateway/dispatcher.ts +216 -29
  48. package/src/gateway/policy-resolver.ts +19 -11
  49. package/src/gateway/types.ts +1 -1
  50. package/src/gateway-control.ts +188 -17
  51. package/src/index.ts +9 -3
  52. package/src/log.ts +100 -1
  53. 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
- const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
302
- const route = resolveRoute(msg, this.config, managed);
303
- const mode = resolveQueueMode(route, msg.conversation.kind);
304
- const queueKey = buildQueueKey(msg);
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(msg);
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: msg.id,
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(msg);
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: msg.id,
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: msg.id,
379
- accountId: msg.accountId,
380
- conversationId: msg.conversation.id,
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: msg.accountId,
387
- roomId: msg.conversation.id,
388
- topicId: msg.conversation.threadId ?? null,
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: msg.accountId,
401
- roomId: msg.conversation.id,
402
- topicId: msg.conversation.threadId ?? null,
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(queueKey, route, text, msg, channel, turnId);
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(queueKey, route, text, msg, channel, turnId);
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 mergedFromTurnIds =
595
- drained.length > 1 ? drained.slice(0, -1).map((e) => e.turnId) : [];
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
- mergedFromTurnIds,
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 (canDeliverRuntimeText) {
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 (canDeliverRuntimeText) {
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 (canDeliverRuntimeText) {
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<AttentionPolicy>;
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: AttentionPolicy): void;
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<AttentionPolicy | undefined>;
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<AttentionPolicy | undefined>;
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: AttentionPolicy;
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(roomId: string | null, policy: AttentionPolicy): AttentionPolicy {
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(): AttentionPolicy {
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<AttentionPolicy> {
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<AttentionPolicy | undefined>,
136
- ): Promise<AttentionPolicy | undefined | typeof FETCH_FAILED> {
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: AttentionPolicy): void {
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),
@@ -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). */