@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.
- package/dist/channel/adapters/discord.d.ts +2 -0
- package/dist/channel/adapters/discord.js +38 -34
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/factory.d.ts +4 -0
- package/dist/channel/factory.js +1 -0
- package/dist/channel/factory.js.map +1 -1
- package/dist/cli.js +13 -0
- package/dist/cli.js.map +1 -1
- package/dist/fleet-manager.d.ts +9 -1
- package/dist/fleet-manager.js +69 -9
- package/dist/fleet-manager.js.map +1 -1
- package/dist/ui/view.html +272 -0
- package/dist/view-api.d.ts +20 -0
- package/dist/view-api.js +309 -0
- package/dist/view-api.js.map +1 -0
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 => {
|