@songsid/agend 2.0.11-beta.3 → 2.0.11-beta.5

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.
@@ -33,6 +33,7 @@ import { TopicArchiver } from "./topic-archiver.js";
33
33
  import { StatuslineWatcher } from "./statusline-watcher.js";
34
34
  import { outboundHandlers } from "./outbound-handlers.js";
35
35
  import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
36
+ import { handleViewRequest, isViewPath } from "./view-api.js";
36
37
  import { handleAgentRequest } from "./agent-endpoint.js";
37
38
  import { ClassicChannelManager, classicInstanceName } from "./classic-channel-manager.js";
38
39
  import { getTmuxSession } from "./config.js";
@@ -65,6 +66,9 @@ export class FleetManager {
65
66
  adapters = new Map(); // derived view for backward compat
66
67
  /** Track which world each instance is bound to */
67
68
  instanceWorldBinding = new Map();
69
+ // Dedup inbound messages seen by more than one adapter (e.g. two DC bots in the
70
+ // same guild both receive every message). Bounded FIFO of recent message keys.
71
+ recentMessageIds = new Set();
68
72
  accessManager = null;
69
73
  /** Primary world (first adapter) — used for fleet-level notifications */
70
74
  get primaryWorld() { return this.worlds.values().next().value; }
@@ -115,6 +119,7 @@ export class FleetManager {
115
119
  // Web UI: SSE clients + auth token
116
120
  sseClients = new Set();
117
121
  webToken = null;
122
+ viewToken = null;
118
123
  constructor(dataDir) {
119
124
  this.dataDir = dataDir;
120
125
  this.lifecycle = new InstanceLifecycle(this);
@@ -221,9 +226,16 @@ export class FleetManager {
221
226
  const world = this.getWorldForInstance(name);
222
227
  return world?.groupId ?? String(this.fleetConfig?.channel?.group_id ?? "");
223
228
  }
224
- /** Bind an instance to a specific world. fromInbound=true skips general_topic to prevent overwrite. */
229
+ /**
230
+ * Bind an instance to a specific world (the bot that answers for it).
231
+ * fromInbound=true (binding inferred from which adapter received a message)
232
+ * must not override a configured identity: skip when the instance is a general
233
+ * or has an explicit channel_id — otherwise a persona instance whose message
234
+ * was also seen by the main bot would get rebound to the wrong bot.
235
+ */
225
236
  bindInstanceAdapter(name, adapterId, fromInbound = false) {
226
- if (fromInbound && this.fleetConfig?.instances[name]?.general_topic)
237
+ const cfg = this.fleetConfig?.instances[name];
238
+ if (fromInbound && (cfg?.general_topic || cfg?.channel_id))
227
239
  return;
228
240
  this.instanceWorldBinding.set(name, adapterId);
229
241
  }
@@ -606,12 +618,19 @@ export class FleetManager {
606
618
  catch (err) {
607
619
  this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
608
620
  }
609
- // Pre-bind general instances to their corresponding adapter
621
+ // Bind instances to their adapter (which bot answers on their behalf).
622
+ // An explicit channel_id is authoritative — this is how a persona instance
623
+ // picks its bot when several share one guild. Generals without a channel_id
624
+ // fall back to a name-contains-adapterId heuristic.
625
+ const channelConfigsForBind = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
610
626
  for (const [name, config] of Object.entries(fleet.instances)) {
627
+ if (config.channel_id) {
628
+ this.bindInstanceAdapter(name, config.channel_id);
629
+ continue;
630
+ }
611
631
  if (!config.general_topic)
612
632
  continue;
613
- const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
614
- for (const ch of channelConfigs) {
633
+ for (const ch of channelConfigsForBind) {
615
634
  const id = ch.id ?? ch.type;
616
635
  if (name.includes(id)) {
617
636
  this.bindInstanceAdapter(name, id);
@@ -741,11 +760,16 @@ export class FleetManager {
741
760
  const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
742
761
  if (channelConfigs.length === 0)
743
762
  return;
744
- // Start primary adapter (first channel) — this.adapter for backward compat
763
+ // Start primary adapter (first channel) — this.adapter for backward compat.
764
+ // The primary owns the guild's slash commands.
745
765
  await this.startSingleAdapter(fleet, channelConfigs[0]);
746
- // Start additional adapters
766
+ const primaryGroup = String(channelConfigs[0].group_id ?? "");
767
+ // Start additional adapters. A secondary bot in the SAME guild as the primary
768
+ // must not re-register the guild's slash commands (they'd appear twice); a bot
769
+ // in a different guild still registers its own.
747
770
  for (let i = 1; i < channelConfigs.length; i++) {
748
- await this.startAdditionalAdapter(channelConfigs[i]);
771
+ const sameGuild = String(channelConfigs[i].group_id ?? "") === primaryGroup;
772
+ await this.startAdditionalAdapter(channelConfigs[i], !sameGuild);
749
773
  }
750
774
  }
751
775
  /** Start the primary adapter (backward-compatible, sets this.adapter) */
@@ -1015,7 +1039,7 @@ export class FleetManager {
1015
1039
  }, 5 * 60 * 1000);
1016
1040
  }
1017
1041
  /** Start an additional (non-primary) adapter */
1018
- async startAdditionalAdapter(channelConfig) {
1042
+ async startAdditionalAdapter(channelConfig, registerCommands = true) {
1019
1043
  const adapterId = channelConfig.id ?? channelConfig.type;
1020
1044
  const botToken = process.env[channelConfig.bot_token_env];
1021
1045
  if (!botToken) {
@@ -1032,6 +1056,7 @@ export class FleetManager {
1032
1056
  botToken,
1033
1057
  accessManager,
1034
1058
  inboxDir,
1059
+ registerCommands,
1035
1060
  });
1036
1061
  const world = new AdapterWorld(adapterId, adapter, accessManager, channelConfig);
1037
1062
  this.worlds.set(adapterId, world);
@@ -1451,6 +1476,25 @@ export class FleetManager {
1451
1476
  async handleInboundMessage(msg) {
1452
1477
  const threadId = msg.threadId || undefined;
1453
1478
  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");
1479
+ // Multi-adapter dedup: when several bots share a guild, each adapter fires
1480
+ // its own "message" event for the same underlying message. Process it once.
1481
+ // Routing (by topic/channel) and reply-adapter selection (by channel_id
1482
+ // binding) are adapter-independent, so it's safe to let whichever adapter
1483
+ // arrives first handle it.
1484
+ if (msg.messageId) {
1485
+ const dedupKey = `${msg.source}:${msg.chatId}:${msg.messageId}`;
1486
+ if (this.recentMessageIds.has(dedupKey)) {
1487
+ this.logger.debug({ dedupKey, adapterId: msg.adapterId }, "Duplicate inbound across adapters — skipping");
1488
+ return;
1489
+ }
1490
+ this.recentMessageIds.add(dedupKey);
1491
+ if (this.recentMessageIds.size > 1000) {
1492
+ // Set preserves insertion order — drop the oldest key.
1493
+ const oldest = this.recentMessageIds.values().next().value;
1494
+ if (oldest !== undefined)
1495
+ this.recentMessageIds.delete(oldest);
1496
+ }
1497
+ }
1454
1498
  // Bot messages: only allow in collab channels or TG classic with @mention
1455
1499
  if (msg.isBotMessage) {
1456
1500
  if (!threadId) {
@@ -3955,6 +3999,15 @@ When users create specialized instances, suggest these configurations:
3955
3999
  catch {
3956
4000
  // best-effort
3957
4001
  }
4002
+ // Separate read-only token for the /view page: grants terminal-view + profile
4003
+ // read, but never write (POSTs still require the full web token).
4004
+ this.viewToken = randomBytes(24).toString("hex");
4005
+ const viewTokenPath = join(this.dataDir, "view.token");
4006
+ writeFileSync(viewTokenPath, this.viewToken, { mode: 0o600 });
4007
+ try {
4008
+ chmodSync(viewTokenPath, 0o600);
4009
+ }
4010
+ catch { /* best-effort */ }
3958
4011
  this.healthServer = createServer((req, res) => {
3959
4012
  res.setHeader("Content-Type", "application/json");
3960
4013
  // Public health probe — no auth required.
@@ -3964,6 +4017,10 @@ When users create specialized instances, suggest these configurations:
3964
4017
  else if (req.method === "POST" && req.url === "/agent") {
3965
4018
  // /agent handles its own instance-level auth via X-Agend-Instance-Token
3966
4019
  }
4020
+ else if (isViewPath(new URL(req.url ?? "/", `http://localhost:${port}`).pathname)) {
4021
+ // /view routes accept the read-only view.token (or web.token) and do
4022
+ // their own per-method auth in view-api.ts — skip the web-token gate.
4023
+ }
3967
4024
  else {
3968
4025
  // All other endpoints require a valid token (query ?token= or X-Agend-Token header).
3969
4026
  // /ui/* will also re-check in web-api.ts, which is harmless.
@@ -4147,6 +4204,8 @@ When users create specialized instances, suggest these configurations:
4147
4204
  }
4148
4205
  // ── Web UI endpoints (delegated to web-api.ts) ─────
4149
4206
  const url = new URL(req.url ?? "/", `http://localhost:${port}`);
4207
+ if (handleViewRequest(req, res, url, this))
4208
+ return;
4150
4209
  if (handleWebRequest(req, res, url, this))
4151
4210
  return;
4152
4211
  res.writeHead(404);
@@ -4188,6 +4247,7 @@ When users create specialized instances, suggest these configurations:
4188
4247
  this.logger.info({ port }, "Health endpoint listening");
4189
4248
  });
4190
4249
  this.logger.info({ url: `http://localhost:${port}/ui?token=${this.webToken}` }, "Web UI available");
4250
+ this.logger.info({ url: `http://localhost:${port}/view?token=${this.viewToken}` }, "Web View available");
4191
4251
  }
4192
4252
  getUiStatus() {
4193
4253
  const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => {