@songsid/agend 2.1.4-beta.58 → 2.1.4-beta.59

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,11 @@ 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
+ * Why this adapter must ignore a bot/webhook message, or null to accept it.
579
+ * Pure: callers rely on being able to ask before claiming the dedup key.
580
+ */
581
+ private botMessageDropReason;
577
582
  private handleInboundMessage;
578
583
  /** Handle outbound tool calls from a daemon instance */
579
584
  /** Warn (but don't block) when rate limits are high. 30-min debounce per instance. */
@@ -3780,9 +3780,68 @@ export class FleetManager {
3780
3780
  consume: () => this.eventLog?.markReactionsConsumed(instanceName, pending.maxId),
3781
3781
  };
3782
3782
  }
3783
+ /**
3784
+ * Why this adapter must ignore a bot/webhook message, or null to accept it.
3785
+ * Pure: callers rely on being able to ask before claiming the dedup key.
3786
+ */
3787
+ botMessageDropReason(msg, threadId) {
3788
+ if (!threadId) {
3789
+ // TG classic: allow if the bot @mentions our bot or access mode is open
3790
+ const world = this.worlds.get(msg.adapterId ?? "");
3791
+ const botUser = world?.botUsername;
3792
+ const isOpen = this.getChannelConfig(msg.adapterId)?.access?.mode === "open";
3793
+ const mentionsUs = !!(botUser && msg.text?.toLowerCase().includes(`@${botUser.toLowerCase()}`));
3794
+ return (!isOpen && !mentionsUs) ? "tg-classic: not open and does not mention us" : null;
3795
+ }
3796
+ if (this.classicChannels?.hasChannel(threadId)) {
3797
+ // Classic channel (per-bot): bot messages only when THIS bot owns an
3798
+ // agent here and collab is on for it.
3799
+ if (!this.classicChannels.getInstanceByChannel(threadId, msg.adapterId)) {
3800
+ return "classic: this bot owns no agent in the channel";
3801
+ }
3802
+ if (!this.classicChannels.isCollab(threadId, msg.adapterId))
3803
+ return "classic: collab off";
3804
+ return null;
3805
+ }
3806
+ const target = this.routing.resolve(threadId);
3807
+ if (!target)
3808
+ 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
+ // Fleet topic: allow if collab enabled OR access mode is open
3822
+ const isOpen = this.getChannelConfig(msg.adapterId)?.access?.mode === "open";
3823
+ if (!isOpen && !this.collabInstances.has(target.name)) {
3824
+ return `fleet topic: adapter not open and collab off for ${target.name}`;
3825
+ }
3826
+ return null;
3827
+ }
3783
3828
  async handleInboundMessage(msg) {
3784
3829
  const threadId = msg.threadId || undefined;
3785
3830
  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;
3843
+ }
3844
+ }
3786
3845
  // Multi-adapter dedup: when several bots share a guild, each adapter fires
3787
3846
  // its own "message" event for the same underlying message. Process it once.
3788
3847
  // Routing (by topic/channel) and reply-adapter selection (by channel_id
@@ -3811,42 +3870,6 @@ export class FleetManager {
3811
3870
  this.recentMessageIds.delete(oldest);
3812
3871
  }
3813
3872
  }
3814
- // Bot messages: only allow in collab channels or TG classic with @mention
3815
- if (msg.isBotMessage) {
3816
- if (!threadId) {
3817
- // TG classic: allow if bot @mentions our bot or access mode is open
3818
- const world = this.worlds.get(msg.adapterId ?? "");
3819
- const botUser = world?.botUsername;
3820
- const channelCfg = this.getChannelConfig(msg.adapterId);
3821
- const isOpen = channelCfg?.access?.mode === "open";
3822
- const mentionsUs = !!(botUser && msg.text?.toLowerCase().includes(`@${botUser.toLowerCase()}`));
3823
- this.logger.debug({ botUser, mentionsUs, isOpen, isBotMessage: true, threadId: null }, "Bot message filter (no threadId path)");
3824
- if (!isOpen && !mentionsUs)
3825
- return;
3826
- // Fall through to TG classic handling below
3827
- }
3828
- else if (this.classicChannels?.hasChannel(threadId)) {
3829
- // Classic channel (per-bot): bot messages only when THIS bot owns an
3830
- // agent here and collab is on for it.
3831
- const classicName = this.classicChannels.getInstanceByChannel(threadId, msg.adapterId);
3832
- if (!classicName)
3833
- return;
3834
- if (!this.classicChannels.isCollab(threadId, msg.adapterId))
3835
- return;
3836
- // Fall through to channel handling
3837
- }
3838
- else {
3839
- const target = this.routing.resolve(threadId);
3840
- if (!target)
3841
- return;
3842
- // Fleet topic: allow if collab enabled OR access mode is open
3843
- const channelCfg = this.getChannelConfig(msg.adapterId);
3844
- const isOpen = channelCfg?.access?.mode === "open";
3845
- if (!isOpen && !this.collabInstances.has(target.name))
3846
- return;
3847
- // Fall through to channel handling
3848
- }
3849
- }
3850
3873
  // Access control — classic channels are open to all, others require allowed user
3851
3874
  const am = (msg.adapterId ? this.worlds.get(msg.adapterId)?.accessManager : undefined) ?? this.accessManager;
3852
3875
  if (am && !am.isAllowed(msg.userId)) {