@songsid/agend 2.1.4-beta.60 → 2.1.4-beta.61

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.
@@ -574,6 +574,46 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
574
574
  * message actually went out — a failed delivery keeps them queued.
575
575
  */
576
576
  private pendingReactionsMeta;
577
+ /**
578
+ * Which adapter's access policy governs an inbound message.
579
+ *
580
+ * `authoritative` means the thread resolved to an owning instance, so that
581
+ * adapter's policy is the only one entitled to decide. A resolved owner whose
582
+ * policy cannot be read is NOT the same as having no owner: the owner's world
583
+ * may not exist yet (an adapter that fails its token check returns before its
584
+ * world and AccessManager are created) while sibling bots are already live and
585
+ * receiving copies. Falling back to a sibling's policy — or to the fleet-wide
586
+ * one — would let the owner's rules be decided by another adapter, so callers
587
+ * must refuse instead.
588
+ *
589
+ * Without an owner (classic channels, the no-thread Telegram path, unrouted
590
+ * threads, no adapter identity) the receiving adapter's policy applies, as
591
+ * before.
592
+ */
593
+ private governingAccess;
594
+ /**
595
+ * Why this adapter should leave an inbound message to another adapter, or null
596
+ * to handle it.
597
+ *
598
+ * A fleet topic is served by exactly one instance, and that instance is
599
+ * answered by exactly one adapter. When several bots share a guild they each
600
+ * receive their own copy of every message, so the copy that is acted on must
601
+ * be chosen by ownership rather than by which inbound happened to fire first.
602
+ * Ownership is per-instance — the adapter the instance is bound to, falling
603
+ * back to channels[0] when it is not bound — so an instance answered by a
604
+ * non-default bot still receives its own traffic, and the access policy that
605
+ * applies is that instance's own adapter's.
606
+ *
607
+ * Callers must ask before claiming the shared dedup key: a copy that is going
608
+ * to be left alone must not consume the key, or the owner's copy would be
609
+ * discarded as a duplicate and the message lost.
610
+ *
611
+ * Exempt: classic channels (each bot owns its own agent there and handles its
612
+ * own copy, which is why their dedup key is adapter-scoped), the no-thread
613
+ * Telegram path (no thread to resolve an instance from), and messages with no
614
+ * adapter id (single-adapter fleets).
615
+ */
616
+ private topicOwnerDropReason;
577
617
  /**
578
618
  * Why this adapter must ignore a bot/webhook message, or null to accept it.
579
619
  * Pure: callers rely on being able to ask before claiming the dedup key.
@@ -3780,6 +3780,69 @@ export class FleetManager {
3780
3780
  consume: () => this.eventLog?.markReactionsConsumed(instanceName, pending.maxId),
3781
3781
  };
3782
3782
  }
3783
+ /**
3784
+ * Which adapter's access policy governs an inbound message.
3785
+ *
3786
+ * `authoritative` means the thread resolved to an owning instance, so that
3787
+ * adapter's policy is the only one entitled to decide. A resolved owner whose
3788
+ * policy cannot be read is NOT the same as having no owner: the owner's world
3789
+ * may not exist yet (an adapter that fails its token check returns before its
3790
+ * world and AccessManager are created) while sibling bots are already live and
3791
+ * receiving copies. Falling back to a sibling's policy — or to the fleet-wide
3792
+ * one — would let the owner's rules be decided by another adapter, so callers
3793
+ * must refuse instead.
3794
+ *
3795
+ * Without an owner (classic channels, the no-thread Telegram path, unrouted
3796
+ * threads, no adapter identity) the receiving adapter's policy applies, as
3797
+ * before.
3798
+ */
3799
+ governingAccess(msg, threadId) {
3800
+ if (threadId && !this.classicChannels?.hasChannel(threadId)) {
3801
+ const target = this.routing.resolve(threadId);
3802
+ if (target) {
3803
+ const owner = this.getInstanceAdapterId(target.name);
3804
+ if (owner)
3805
+ return { adapterId: owner, authoritative: true };
3806
+ }
3807
+ }
3808
+ return { adapterId: msg.adapterId, authoritative: false };
3809
+ }
3810
+ /**
3811
+ * Why this adapter should leave an inbound message to another adapter, or null
3812
+ * to handle it.
3813
+ *
3814
+ * A fleet topic is served by exactly one instance, and that instance is
3815
+ * answered by exactly one adapter. When several bots share a guild they each
3816
+ * receive their own copy of every message, so the copy that is acted on must
3817
+ * be chosen by ownership rather than by which inbound happened to fire first.
3818
+ * Ownership is per-instance — the adapter the instance is bound to, falling
3819
+ * back to channels[0] when it is not bound — so an instance answered by a
3820
+ * non-default bot still receives its own traffic, and the access policy that
3821
+ * applies is that instance's own adapter's.
3822
+ *
3823
+ * Callers must ask before claiming the shared dedup key: a copy that is going
3824
+ * to be left alone must not consume the key, or the owner's copy would be
3825
+ * discarded as a duplicate and the message lost.
3826
+ *
3827
+ * Exempt: classic channels (each bot owns its own agent there and handles its
3828
+ * own copy, which is why their dedup key is adapter-scoped), the no-thread
3829
+ * Telegram path (no thread to resolve an instance from), and messages with no
3830
+ * adapter id (single-adapter fleets).
3831
+ */
3832
+ topicOwnerDropReason(msg, threadId) {
3833
+ if (!threadId || !msg.adapterId)
3834
+ return null;
3835
+ if (this.classicChannels?.hasChannel(threadId))
3836
+ return null;
3837
+ const target = this.routing.resolve(threadId);
3838
+ if (!target)
3839
+ return null;
3840
+ const ownerAdapterId = this.getInstanceAdapterId(target.name);
3841
+ if (ownerAdapterId && msg.adapterId !== ownerAdapterId) {
3842
+ return `not the adapter bound to ${target.name} (that is ${ownerAdapterId})`;
3843
+ }
3844
+ return null;
3845
+ }
3783
3846
  /**
3784
3847
  * Why this adapter must ignore a bot/webhook message, or null to accept it.
3785
3848
  * Pure: callers rely on being able to ask before claiming the dedup key.
@@ -3806,18 +3869,6 @@ export class FleetManager {
3806
3869
  const target = this.routing.resolve(threadId);
3807
3870
  if (!target)
3808
3871
  return "fleet topic: no instance routed for this thread";
3809
- // A fleet topic is served by exactly one instance, so exactly one bot should
3810
- // answer a webhook there. Several bots sharing the guild each receive their
3811
- // own copy; pick that instance's own adapter deterministically rather than
3812
- // letting whichever inbound fired first win. The primary is per-instance —
3813
- // the adapter the instance is bound to, falling back to channels[0] when it
3814
- // is not bound — so an instance answered by a non-default bot still receives
3815
- // its webhooks. (Classic channels are exempt above: there each bot owns its
3816
- // own agent and must handle its own copy.)
3817
- const ownerAdapterId = this.getInstanceAdapterId(target.name);
3818
- if (msg.adapterId && ownerAdapterId && msg.adapterId !== ownerAdapterId) {
3819
- return `fleet topic: not the adapter bound to ${target.name} (that is ${ownerAdapterId})`;
3820
- }
3821
3872
  // Fleet topic: allow if collab enabled OR access mode is open
3822
3873
  const isOpen = this.getChannelConfig(msg.adapterId)?.access?.mode === "open";
3823
3874
  if (!isOpen && !this.collabInstances.has(target.name)) {
@@ -3828,18 +3879,47 @@ export class FleetManager {
3828
3879
  async handleInboundMessage(msg) {
3829
3880
  const threadId = msg.threadId || undefined;
3830
3881
  this.logger.debug({ source: msg.source, chatId: msg.chatId, threadId, userId: msg.userId, isBotMessage: msg.isBotMessage, textLen: (msg.text ?? "").length, text: (msg.text ?? "").slice(0, 80) }, "handleInboundMessage entry");
3831
- // Bot messages are filtered per-adapter, and the filter reads THIS adapter's
3832
- // access config. That has to happen before the dedup claim below: when two
3833
- // bots share a guild they both receive the same webhook, and a copy this
3834
- // adapter is going to drop must not consume the shared dedup key — otherwise
3835
- // the sibling adapter that would have accepted it sees a duplicate and the
3836
- // message is lost entirely. Claiming the key only on the copies that survive
3837
- // also keeps delivery single when several adapters would accept it.
3838
- if (msg.isBotMessage) {
3839
- const drop = this.botMessageDropReason(msg, threadId);
3840
- if (drop) {
3841
- this.logger.debug({ adapterId: msg.adapterId, threadId: threadId ?? null, messageId: msg.messageId, reason: drop }, "Bot message dropped by adapter filter — dedup key not consumed");
3842
- return;
3882
+ // Ownership and per-adapter filtering both run before the dedup claim below.
3883
+ // When several bots share a guild they all receive the same message; a copy
3884
+ // this adapter is going to leave alone must not consume the shared dedup key,
3885
+ // or the owning adapter's copy would be discarded as a duplicate and the
3886
+ // message lost entirely. Claiming the key only on surviving copies also keeps
3887
+ // delivery single when more than one adapter would otherwise accept it.
3888
+ // Only bot messages are settled by ownership: a bot answering in a fleet
3889
+ // topic would speak as the wrong identity, whereas a human message is just
3890
+ // input for the instance and its reply is canonicalized to the owning
3891
+ // adapter downstream — so a topic only a non-owner bot can see still works.
3892
+ const drop = msg.isBotMessage
3893
+ ? (this.topicOwnerDropReason(msg, threadId) ?? this.botMessageDropReason(msg, threadId))
3894
+ : null;
3895
+ if (drop) {
3896
+ this.logger.debug({ adapterId: msg.adapterId, threadId: threadId ?? null, messageId: msg.messageId, reason: drop }, "Inbound message dropped before the dedup claim — key not consumed");
3897
+ return;
3898
+ }
3899
+ // Access control — classic channels are open to all, others require an allowed
3900
+ // user. This runs before the dedup claim, and is judged by the adapter that
3901
+ // owns the topic rather than the one that happened to receive the message:
3902
+ // otherwise a sibling bot could settle the message under its own policy —
3903
+ // claiming the shared key and then refusing it, or admitting a user the
3904
+ // owning adapter does not allow — purely by arriving first.
3905
+ const governing = this.governingAccess(msg, threadId);
3906
+ const ownerWorld = governing.adapterId ? this.worlds.get(governing.adapterId) : undefined;
3907
+ if (governing.authoritative && !ownerWorld) {
3908
+ this.logger.warn({ adapterId: msg.adapterId, owner: governing.adapterId, threadId, messageId: msg.messageId }, "Refusing inbound: the owning adapter is not running, so its access policy cannot be applied");
3909
+ return;
3910
+ }
3911
+ const am = governing.authoritative
3912
+ ? ownerWorld?.accessManager
3913
+ : (ownerWorld?.accessManager ?? this.accessManager);
3914
+ if (am && !am.isAllowed(msg.userId)) {
3915
+ const adapterGroupId = String(this.getChannelConfig(msg.adapterId)?.group_id ?? "");
3916
+ const isTelegramClassicCandidate = msg.source === "telegram" && msg.chatId !== adapterGroupId && !threadId;
3917
+ if (!isTelegramClassicCandidate) {
3918
+ // Classic channels are open to all; check per-bot ownership (or fleet topic).
3919
+ const isClassic = !!(threadId && this.classicChannels?.hasChannel(threadId));
3920
+ this.logger.info({ userId: msg.userId, threadId, isClassic, owner: governing.adapterId }, "Access DENIED for non-allowed user");
3921
+ if (!isClassic)
3922
+ return;
3843
3923
  }
3844
3924
  }
3845
3925
  // Multi-adapter dedup: when several bots share a guild, each adapter fires
@@ -3870,19 +3950,6 @@ export class FleetManager {
3870
3950
  this.recentMessageIds.delete(oldest);
3871
3951
  }
3872
3952
  }
3873
- // Access control — classic channels are open to all, others require allowed user
3874
- const am = (msg.adapterId ? this.worlds.get(msg.adapterId)?.accessManager : undefined) ?? this.accessManager;
3875
- if (am && !am.isAllowed(msg.userId)) {
3876
- const adapterGroupId = String(this.getChannelConfig(msg.adapterId)?.group_id ?? "");
3877
- const isTelegramClassicCandidate = msg.source === "telegram" && msg.chatId !== adapterGroupId && !threadId;
3878
- if (!isTelegramClassicCandidate) {
3879
- // Classic channels are open to all; check per-bot ownership (or fleet topic).
3880
- const isClassic = !!(threadId && this.classicChannels?.hasChannel(threadId));
3881
- this.logger.info({ userId: msg.userId, threadId, isClassic }, "Access DENIED for non-allowed user");
3882
- if (!isClassic)
3883
- return;
3884
- }
3885
- }
3886
3953
  if (threadId == null) {
3887
3954
  // ── Telegram Classic Mode ──
3888
3955
  // Messages from chats other than the primary forum group are classic mode candidates.