@songsid/agend 2.0.11-beta.3 → 2.0.11-beta.31
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 +40 -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/channel/mcp-tools.js +3 -0
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/tool-router.js +29 -2
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/classic-channel-manager.d.ts +59 -12
- package/dist/classic-channel-manager.js +121 -30
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +94 -7
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.d.ts +20 -0
- package/dist/config-validator.js +154 -0
- package/dist/config-validator.js.map +1 -0
- package/dist/fleet-manager.d.ts +29 -5
- package/dist/fleet-manager.js +451 -233
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +1 -1
- package/dist/instance-lifecycle.js +7 -6
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.d.ts +19 -0
- package/dist/locale.js +137 -0
- package/dist/locale.js.map +1 -0
- package/dist/outbound-schemas.d.ts +1 -0
- package/dist/outbound-schemas.js +1 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/quickstart.js +252 -16
- package/dist/quickstart.js.map +1 -1
- package/dist/scheduler/scheduler.d.ts +1 -1
- package/dist/scheduler/scheduler.js +15 -4
- package/dist/scheduler/scheduler.js.map +1 -1
- package/dist/service-installer.d.ts +1 -0
- package/dist/service-installer.js +2 -1
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.d.ts +63 -0
- package/dist/settings-api.js +331 -0
- package/dist/settings-api.js.map +1 -0
- package/dist/topic-commands.d.ts +14 -0
- package/dist/topic-commands.js +78 -16
- package/dist/topic-commands.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/ui/settings.html +612 -0
- package/dist/ui/view.html +528 -0
- package/dist/view-api.d.ts +29 -0
- package/dist/view-api.js +370 -0
- package/dist/view-api.js.map +1 -0
- package/package.json +1 -1
- package/templates/systemd.service.ejs +2 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -8,6 +8,8 @@ import { sdNotify } from "./sd-notify.js";
|
|
|
8
8
|
import yaml from "js-yaml";
|
|
9
9
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
10
|
const __dirname = dirname(__filename);
|
|
11
|
+
/** Fallback access policy for a channel with no `access:` block — open (no gate). */
|
|
12
|
+
const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
|
|
11
13
|
import { isProbeableRouteTarget } from "./fleet-context.js";
|
|
12
14
|
import { loadFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
|
|
13
15
|
import { EventLog } from "./event-log.js";
|
|
@@ -22,7 +24,7 @@ import { processAttachments } from "./channel/attachment-handler.js";
|
|
|
22
24
|
import { routeToolCall } from "./channel/tool-router.js";
|
|
23
25
|
import { Scheduler } from "./scheduler/index.js";
|
|
24
26
|
import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
|
|
25
|
-
import { TopicCommands,
|
|
27
|
+
import { TopicCommands, saveCommandForBackend, parseSaveFilename, SAVE_FILENAME_RE, SAVE_UNSUPPORTED_MSG } from "./topic-commands.js";
|
|
26
28
|
import { DailySummary } from "./daily-summary.js";
|
|
27
29
|
import { WebhookEmitter } from "./webhook-emitter.js";
|
|
28
30
|
import { TmuxControlClient } from "./tmux-control.js";
|
|
@@ -33,8 +35,11 @@ import { TopicArchiver } from "./topic-archiver.js";
|
|
|
33
35
|
import { StatuslineWatcher } from "./statusline-watcher.js";
|
|
34
36
|
import { outboundHandlers } from "./outbound-handlers.js";
|
|
35
37
|
import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
|
|
38
|
+
import { handleViewRequest, isViewPath } from "./view-api.js";
|
|
39
|
+
import { handleSettingsRequest } from "./settings-api.js";
|
|
40
|
+
import { setLocale, detectLocale, t } from "./locale.js";
|
|
36
41
|
import { handleAgentRequest } from "./agent-endpoint.js";
|
|
37
|
-
import { ClassicChannelManager
|
|
42
|
+
import { ClassicChannelManager } from "./classic-channel-manager.js";
|
|
38
43
|
import { getTmuxSession } from "./config.js";
|
|
39
44
|
export function resolveReplyThreadId(argsThreadId, instanceConfig) {
|
|
40
45
|
if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
|
|
@@ -65,6 +70,9 @@ export class FleetManager {
|
|
|
65
70
|
adapters = new Map(); // derived view for backward compat
|
|
66
71
|
/** Track which world each instance is bound to */
|
|
67
72
|
instanceWorldBinding = new Map();
|
|
73
|
+
// Dedup inbound messages seen by more than one adapter (e.g. two DC bots in the
|
|
74
|
+
// same guild both receive every message). Bounded FIFO of recent message keys.
|
|
75
|
+
recentMessageIds = new Set();
|
|
68
76
|
accessManager = null;
|
|
69
77
|
/** Primary world (first adapter) — used for fleet-level notifications */
|
|
70
78
|
get primaryWorld() { return this.worlds.values().next().value; }
|
|
@@ -115,6 +123,7 @@ export class FleetManager {
|
|
|
115
123
|
// Web UI: SSE clients + auth token
|
|
116
124
|
sseClients = new Set();
|
|
117
125
|
webToken = null;
|
|
126
|
+
viewToken = null;
|
|
118
127
|
constructor(dataDir) {
|
|
119
128
|
this.dataDir = dataDir;
|
|
120
129
|
this.lifecycle = new InstanceLifecycle(this);
|
|
@@ -174,27 +183,51 @@ export class FleetManager {
|
|
|
174
183
|
}
|
|
175
184
|
return this.routing.map;
|
|
176
185
|
}
|
|
177
|
-
/**
|
|
186
|
+
/**
|
|
187
|
+
* Refresh each adapter's open-channel whitelist after a classic change.
|
|
188
|
+
* Classic channels are NOT registered in the routing engine (it's single-key
|
|
189
|
+
* per channel — can't represent two bots in one channel); routing resolves
|
|
190
|
+
* per-bot via ClassicChannelManager.getInstanceByChannel. Each adapter only
|
|
191
|
+
* opens the channels IT owns so a sibling bot doesn't process another's cross-
|
|
192
|
+
* guild channel.
|
|
193
|
+
*/
|
|
178
194
|
reregisterClassicChannels() {
|
|
179
195
|
if (!this.classicChannels)
|
|
180
196
|
return;
|
|
181
197
|
const channels = this.classicChannels.getAll();
|
|
182
|
-
for (const ch of channels) {
|
|
183
|
-
this.routing.register(ch.channelId, { kind: "classic", name: ch.instanceName });
|
|
184
|
-
}
|
|
185
198
|
// Always update adapter openChannels (including empty — clears stale entries on /stop)
|
|
186
|
-
for (const [, w] of this.worlds) {
|
|
199
|
+
for (const [adapterId, w] of this.worlds) {
|
|
187
200
|
if (typeof w.adapter?.setOpenChannels === "function") {
|
|
188
|
-
|
|
201
|
+
const owned = channels.filter(ch => ch.adapterId === adapterId).map(ch => ch.channelId);
|
|
202
|
+
w.adapter.setOpenChannels(owned);
|
|
189
203
|
}
|
|
190
204
|
}
|
|
191
205
|
if (channels.length > 0) {
|
|
192
|
-
this.logger.info({ count: channels.length }, "
|
|
206
|
+
this.logger.info({ count: channels.length }, "Refreshed classic channel open-lists");
|
|
193
207
|
}
|
|
194
208
|
}
|
|
195
209
|
getInstanceDir(name) {
|
|
196
210
|
return join(this.dataDir, "instances", name);
|
|
197
211
|
}
|
|
212
|
+
/** AgEnD package version (for the Settings "current version" / What's New). */
|
|
213
|
+
get currentVersion() {
|
|
214
|
+
try {
|
|
215
|
+
return JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8")).version ?? "unknown";
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return "unknown";
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Resolve a slash-command target in a channel. Classic channels are looked up
|
|
223
|
+
* per-bot (same-channel multi-bot); a fleet-topic instance is found via the
|
|
224
|
+
* routing engine. Used by commands that work in BOTH contexts (/ctx, /compact,
|
|
225
|
+
* /cancel). Classic-only commands (/chat, /load) must NOT use this.
|
|
226
|
+
*/
|
|
227
|
+
resolveSlashTarget(channelId, adapterId) {
|
|
228
|
+
return this.classicChannels?.getInstanceByChannel(channelId, adapterId)
|
|
229
|
+
?? this.routing.resolve(channelId)?.name;
|
|
230
|
+
}
|
|
198
231
|
/** Get the adapter bound to an instance, falling back to primary adapter */
|
|
199
232
|
getAdapterForInstance(name) {
|
|
200
233
|
const worldId = this.instanceWorldBinding.get(name);
|
|
@@ -221,9 +254,22 @@ export class FleetManager {
|
|
|
221
254
|
const world = this.getWorldForInstance(name);
|
|
222
255
|
return world?.groupId ?? String(this.fleetConfig?.channel?.group_id ?? "");
|
|
223
256
|
}
|
|
224
|
-
/**
|
|
257
|
+
/**
|
|
258
|
+
* Bind an instance to a specific world (the bot that answers for it).
|
|
259
|
+
* fromInbound=true (binding inferred from which adapter received a message)
|
|
260
|
+
* must not override a configured identity: skip when the instance is a general
|
|
261
|
+
* or has an explicit channel_id — otherwise a persona instance whose message
|
|
262
|
+
* was also seen by the main bot would get rebound to the wrong bot.
|
|
263
|
+
*/
|
|
225
264
|
bindInstanceAdapter(name, adapterId, fromInbound = false) {
|
|
226
|
-
|
|
265
|
+
const cfg = this.fleetConfig?.instances[name];
|
|
266
|
+
if (fromInbound && (cfg?.general_topic || cfg?.channel_id))
|
|
267
|
+
return;
|
|
268
|
+
// A classic instance is bound authoritatively at /start. Don't let an inbound
|
|
269
|
+
// (seen by every same-guild bot) override an existing binding — but if there
|
|
270
|
+
// is none yet (e.g. after a restart, before v2.1 persistence), allow inbound
|
|
271
|
+
// to re-establish it so replies don't fall back to the primary bot forever.
|
|
272
|
+
if (fromInbound && this.classicChannels?.getChannelIdByInstance(name) !== undefined && this.instanceWorldBinding.has(name))
|
|
227
273
|
return;
|
|
228
274
|
this.instanceWorldBinding.set(name, adapterId);
|
|
229
275
|
}
|
|
@@ -352,6 +398,7 @@ export class FleetManager {
|
|
|
352
398
|
const { rotateLogIfNeeded } = await import("./logger.js");
|
|
353
399
|
rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
|
|
354
400
|
const fleet = this.loadConfig(configPath);
|
|
401
|
+
setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
|
|
355
402
|
const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
|
|
356
403
|
// Set tmux socket isolation for custom AGEND_HOME
|
|
357
404
|
const { getTmuxSocketName: getSocket } = await import("./paths.js");
|
|
@@ -393,10 +440,19 @@ export class FleetManager {
|
|
|
393
440
|
const pidPath = join(this.dataDir, "fleet.pid");
|
|
394
441
|
writeFileSync(pidPath, String(process.pid), "utf-8");
|
|
395
442
|
this.eventLog = new EventLog(join(this.dataDir, "events.db"));
|
|
396
|
-
// Initialize classic channel manager
|
|
443
|
+
// Initialize classic channel manager. The primary adapter (channels[0])
|
|
444
|
+
// migrates legacy single-bot entries and names without a suffix. Classic
|
|
445
|
+
// routing does NOT go through the routing engine (single-key, can't hold two
|
|
446
|
+
// bots in one channel) — it resolves per-bot via getInstanceByChannel.
|
|
397
447
|
this.classicChannels = new ClassicChannelManager(this.dataDir, this.logger);
|
|
448
|
+
const primaryCh = fleet.channels?.[0] ?? fleet.channel;
|
|
449
|
+
if (primaryCh)
|
|
450
|
+
this.classicChannels.setPrimaryAdapterId(primaryCh.id ?? primaryCh.type);
|
|
451
|
+
// Restore the persisted bot binding so replies/cancel go through the right
|
|
452
|
+
// bot after a restart (before this, inbound would re-bind lazily).
|
|
398
453
|
for (const ch of this.classicChannels.getAll()) {
|
|
399
|
-
|
|
454
|
+
if (ch.adapterId)
|
|
455
|
+
this.instanceWorldBinding.set(ch.instanceName, ch.adapterId);
|
|
400
456
|
}
|
|
401
457
|
// Poll classicBot.yaml for external changes every 30s
|
|
402
458
|
this.classicReloadTimer = setInterval(async () => {
|
|
@@ -474,8 +530,9 @@ export class FleetManager {
|
|
|
474
530
|
// Rotate classic channel chat logs daily (piggyback on daily summary timer)
|
|
475
531
|
this.classicChannels?.rotateLogs();
|
|
476
532
|
this.rotateInboxes();
|
|
477
|
-
// Auto-create general
|
|
533
|
+
// Auto-create/adopt a general dispatcher — ONLY for the primary adapter.
|
|
478
534
|
const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
|
|
535
|
+
const primaryAdapterId = channelConfigs[0] ? (channelConfigs[0].id ?? channelConfigs[0].type) : undefined;
|
|
479
536
|
const generalInstances = Object.entries(fleet.instances).filter(([, inst]) => inst.general_topic === true);
|
|
480
537
|
let generalsCreated = false;
|
|
481
538
|
// Collect unbound generals (no channel_id set) for auto-assignment
|
|
@@ -484,6 +541,14 @@ export class FleetManager {
|
|
|
484
541
|
const needsGeneral = [];
|
|
485
542
|
for (const ch of channelConfigs) {
|
|
486
543
|
const adapterId = ch.id ?? ch.type;
|
|
544
|
+
// Only the primary adapter gets an auto-general. Secondary (persona) bots
|
|
545
|
+
// answer for their explicitly-bound instances only — they don't need or
|
|
546
|
+
// auto-claim a general dispatcher, and must never adopt the primary's
|
|
547
|
+
// unbound general. A general a user manually bound to a secondary
|
|
548
|
+
// (channel_id: <persona>) is left untouched — the auto logic just won't
|
|
549
|
+
// create or reassign bindings for non-primary adapters.
|
|
550
|
+
if (adapterId !== primaryAdapterId)
|
|
551
|
+
continue;
|
|
487
552
|
// Check if any general is explicitly bound to this adapter
|
|
488
553
|
if (generalInstances.some(([, inst]) => inst.channel_id === adapterId))
|
|
489
554
|
continue;
|
|
@@ -606,12 +671,19 @@ export class FleetManager {
|
|
|
606
671
|
catch (err) {
|
|
607
672
|
this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
|
|
608
673
|
}
|
|
609
|
-
//
|
|
674
|
+
// Bind instances to their adapter (which bot answers on their behalf).
|
|
675
|
+
// An explicit channel_id is authoritative — this is how a persona instance
|
|
676
|
+
// picks its bot when several share one guild. Generals without a channel_id
|
|
677
|
+
// fall back to a name-contains-adapterId heuristic.
|
|
678
|
+
const channelConfigsForBind = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
|
|
610
679
|
for (const [name, config] of Object.entries(fleet.instances)) {
|
|
680
|
+
if (config.channel_id) {
|
|
681
|
+
this.bindInstanceAdapter(name, config.channel_id);
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
611
684
|
if (!config.general_topic)
|
|
612
685
|
continue;
|
|
613
|
-
|
|
614
|
-
for (const ch of channelConfigs) {
|
|
686
|
+
for (const ch of channelConfigsForBind) {
|
|
615
687
|
const id = ch.id ?? ch.type;
|
|
616
688
|
if (name.includes(id)) {
|
|
617
689
|
this.bindInstanceAdapter(name, id);
|
|
@@ -619,6 +691,23 @@ export class FleetManager {
|
|
|
619
691
|
}
|
|
620
692
|
}
|
|
621
693
|
}
|
|
694
|
+
// Guard against a stale/invalid general topic_id. An old auto-general
|
|
695
|
+
// could have written the TG-convention "1" for a Discord general; the DC
|
|
696
|
+
// adapter then throws fetching channel "1" → unhandled → fleet crash loop.
|
|
697
|
+
// Unbind (+ warn) so it's simply skipped, never routed to a bogus channel.
|
|
698
|
+
let fixedGeneral = false;
|
|
699
|
+
for (const [name, cfg] of Object.entries(this.fleetConfig.instances)) {
|
|
700
|
+
if (!cfg.general_topic || cfg.topic_id == null)
|
|
701
|
+
continue;
|
|
702
|
+
const adapterId = this.instanceWorldBinding.get(name) ?? cfg.channel_id;
|
|
703
|
+
if (this.getChannelConfig(adapterId)?.type === "discord" && !/^\d{17,}$/.test(String(cfg.topic_id))) {
|
|
704
|
+
this.logger.warn({ name, topic_id: cfg.topic_id }, "Discord general topic_id is not a valid channel — unbinding to avoid a crash loop");
|
|
705
|
+
delete cfg.topic_id;
|
|
706
|
+
fixedGeneral = true;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
if (fixedGeneral)
|
|
710
|
+
this.saveFleetConfig();
|
|
622
711
|
// Auto-create topics AFTER adapter is ready (needs adapter.createTopic)
|
|
623
712
|
await this.topicCommands.autoCreateTopics();
|
|
624
713
|
const routeSummary = this.routing.rebuild(this.fleetConfig);
|
|
@@ -741,9 +830,13 @@ export class FleetManager {
|
|
|
741
830
|
const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
|
|
742
831
|
if (channelConfigs.length === 0)
|
|
743
832
|
return;
|
|
744
|
-
// Start primary adapter (first channel) — this.adapter for backward compat
|
|
833
|
+
// Start primary adapter (first channel) — this.adapter for backward compat.
|
|
745
834
|
await this.startSingleAdapter(fleet, channelConfigs[0]);
|
|
746
|
-
// Start additional adapters
|
|
835
|
+
// Start additional adapters. Every bot registers its own slash commands —
|
|
836
|
+
// Discord slash commands are per-application, so a same-guild secondary bot's
|
|
837
|
+
// /start etc. are distinct entries (labelled with the bot name) and the
|
|
838
|
+
// interaction only reaches the invoked bot. This is how ClassicBot supports
|
|
839
|
+
// multiple bots in one guild: the user picks which bot from autocomplete.
|
|
747
840
|
for (let i = 1; i < channelConfigs.length; i++) {
|
|
748
841
|
await this.startAdditionalAdapter(channelConfigs[i]);
|
|
749
842
|
}
|
|
@@ -757,7 +850,7 @@ export class FleetManager {
|
|
|
757
850
|
}
|
|
758
851
|
const accessDir = join(this.dataDir, "access");
|
|
759
852
|
mkdirSync(accessDir, { recursive: true });
|
|
760
|
-
const accessManager = new AccessManager(channelConfig.access, join(accessDir, "access.json"));
|
|
853
|
+
const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, join(accessDir, "access.json"));
|
|
761
854
|
this.accessManager = accessManager;
|
|
762
855
|
const inboxDir = join(this.dataDir, "inbox");
|
|
763
856
|
mkdirSync(inboxDir, { recursive: true });
|
|
@@ -813,28 +906,28 @@ export class FleetManager {
|
|
|
813
906
|
// Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
|
|
814
907
|
this.adapter.on("slash_command", safeHandler(async (data) => {
|
|
815
908
|
if (data.command === "start") {
|
|
816
|
-
const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId);
|
|
909
|
+
const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId, adapterId);
|
|
817
910
|
await data.respond(reply);
|
|
818
911
|
}
|
|
819
912
|
else if (data.command === "stop") {
|
|
820
|
-
const reply = await this.handleClassicStop(data.channelId);
|
|
913
|
+
const reply = await this.handleClassicStop(data.channelId, adapterId);
|
|
821
914
|
await data.respond(reply);
|
|
822
915
|
}
|
|
823
916
|
else if (data.command === "chat") {
|
|
824
917
|
const text = data.text ?? "";
|
|
825
918
|
if (!text) {
|
|
826
|
-
await data.respond("
|
|
919
|
+
await data.respond(t("chat.usage"));
|
|
827
920
|
return;
|
|
828
921
|
}
|
|
829
|
-
const
|
|
830
|
-
if (!
|
|
831
|
-
await data.respond("
|
|
922
|
+
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
923
|
+
if (!name) {
|
|
924
|
+
await data.respond(t("classic.no_agent_start"));
|
|
832
925
|
return;
|
|
833
926
|
}
|
|
834
927
|
const replyMsgId = await data.respond("👀");
|
|
835
928
|
const username = data.username ?? data.userId;
|
|
836
|
-
ClassicChannelManager.logMessage(
|
|
837
|
-
await this.forwardToClassicInstance(
|
|
929
|
+
ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
|
|
930
|
+
await this.forwardToClassicInstance(name, text, {
|
|
838
931
|
chatId: data.channelId,
|
|
839
932
|
threadId: data.channelId,
|
|
840
933
|
messageId: replyMsgId ?? "",
|
|
@@ -845,86 +938,88 @@ export class FleetManager {
|
|
|
845
938
|
});
|
|
846
939
|
}
|
|
847
940
|
else if (data.command === "save") {
|
|
848
|
-
await this.handleSlashSave(data);
|
|
941
|
+
await this.handleSlashSave(data, adapterId);
|
|
849
942
|
}
|
|
850
943
|
else if (data.command === "load") {
|
|
851
944
|
// load is kiro-cli/classic only — no claude-code equivalent.
|
|
852
945
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
853
|
-
await data.respond("
|
|
946
|
+
await data.respond(t("admin.required"));
|
|
854
947
|
return;
|
|
855
948
|
}
|
|
856
|
-
const
|
|
857
|
-
if (!
|
|
858
|
-
await data.respond("
|
|
949
|
+
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
950
|
+
if (!name) {
|
|
951
|
+
await data.respond(t("classic.no_agent_start"));
|
|
859
952
|
return;
|
|
860
953
|
}
|
|
861
954
|
const filename = data.options?.filename;
|
|
862
955
|
if (!SAVE_FILENAME_RE.test(filename ?? "")) {
|
|
863
|
-
await data.respond("
|
|
956
|
+
await data.respond(t("filename.invalid"));
|
|
864
957
|
return;
|
|
865
958
|
}
|
|
866
|
-
this.pasteRawToClassicInstance(
|
|
867
|
-
await data.respond(
|
|
959
|
+
this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
|
|
960
|
+
await data.respond(t("save.sent", `/chat load ${filename}`, name));
|
|
868
961
|
}
|
|
869
962
|
else if (data.command === "compact") {
|
|
870
|
-
const
|
|
871
|
-
if (!
|
|
872
|
-
await data.respond("
|
|
963
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
964
|
+
if (!name) {
|
|
965
|
+
await data.respond(t("classic.no_agent"));
|
|
873
966
|
return;
|
|
874
967
|
}
|
|
875
|
-
const result = await this.topicCommands.sendCompact(
|
|
968
|
+
const result = await this.topicCommands.sendCompact(name);
|
|
876
969
|
await data.respond(result);
|
|
877
970
|
}
|
|
878
971
|
else if (data.command === "cancel") {
|
|
879
|
-
const
|
|
880
|
-
if (!
|
|
881
|
-
await data.respond("
|
|
972
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
973
|
+
if (!name) {
|
|
974
|
+
await data.respond(t("classic.no_agent"));
|
|
882
975
|
return;
|
|
883
976
|
}
|
|
884
|
-
const ok = this.cancelInstance(
|
|
885
|
-
await data.respond(ok ?
|
|
977
|
+
const ok = this.cancelInstance(name);
|
|
978
|
+
await data.respond(ok ? t("cancel.sent", name) : t("cancel.not_running", name));
|
|
886
979
|
}
|
|
887
980
|
else if (data.command === "ctx") {
|
|
888
|
-
const
|
|
889
|
-
if (!
|
|
890
|
-
await data.respond("
|
|
981
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
982
|
+
if (!name) {
|
|
983
|
+
await data.respond(t("classic.no_agent"));
|
|
891
984
|
return;
|
|
892
985
|
}
|
|
893
986
|
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
894
|
-
await data.respond(await this.topicCommands.getCtxText(
|
|
987
|
+
await data.respond(await this.topicCommands.getCtxText(name));
|
|
895
988
|
}
|
|
896
989
|
else if (data.command === "collab") {
|
|
990
|
+
// Classic no longer lives in the routing engine, so a routing hit here is
|
|
991
|
+
// always a fleet-topic instance.
|
|
897
992
|
const collabTarget = this.routing.resolve(data.channelId);
|
|
898
|
-
if (collabTarget
|
|
993
|
+
if (collabTarget) {
|
|
899
994
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
900
995
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
901
|
-
await data.respond("
|
|
996
|
+
await data.respond(t("not_authorized"));
|
|
902
997
|
return;
|
|
903
998
|
}
|
|
904
999
|
const isCollab = this.toggleFleetCollab(collabTarget.name);
|
|
905
|
-
await data.respond(isCollab ? "
|
|
1000
|
+
await data.respond(isCollab ? t("collab.on") : t("collab.off"));
|
|
906
1001
|
return;
|
|
907
1002
|
}
|
|
908
1003
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
909
|
-
await data.respond("
|
|
1004
|
+
await data.respond(t("admin.required"));
|
|
910
1005
|
return;
|
|
911
1006
|
}
|
|
912
|
-
if (!this.classicChannels.isClassicChannel(data.channelId)) {
|
|
913
|
-
await data.respond("
|
|
1007
|
+
if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
|
|
1008
|
+
await data.respond(t("classic.no_agent_start"));
|
|
914
1009
|
return;
|
|
915
1010
|
}
|
|
916
|
-
const newState = this.classicChannels.toggleCollab(data.channelId);
|
|
1011
|
+
const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
|
|
917
1012
|
await data.respond(newState
|
|
918
|
-
? "
|
|
919
|
-
: "
|
|
1013
|
+
? t("collab.on.classic")
|
|
1014
|
+
: t("collab.off.classic"));
|
|
920
1015
|
}
|
|
921
1016
|
else if (data.command === "update") {
|
|
922
1017
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
923
1018
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
924
|
-
await data.respond("
|
|
1019
|
+
await data.respond(t("not_authorized"));
|
|
925
1020
|
return;
|
|
926
1021
|
}
|
|
927
|
-
await data.respond("
|
|
1022
|
+
await data.respond(t("update.running"));
|
|
928
1023
|
const { spawn } = await import("node:child_process");
|
|
929
1024
|
const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
|
|
930
1025
|
const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
|
|
@@ -934,7 +1029,7 @@ export class FleetManager {
|
|
|
934
1029
|
else if (data.command === "doctor") {
|
|
935
1030
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
936
1031
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
937
|
-
await data.respond("
|
|
1032
|
+
await data.respond(t("not_authorized"));
|
|
938
1033
|
return;
|
|
939
1034
|
}
|
|
940
1035
|
try {
|
|
@@ -956,22 +1051,36 @@ export class FleetManager {
|
|
|
956
1051
|
else if (data.command === "sysinfo") {
|
|
957
1052
|
await data.respond(this.topicCommands.getSysInfoText());
|
|
958
1053
|
}
|
|
1054
|
+
else if (data.command === "dashboard") {
|
|
1055
|
+
// Reply is ephemeral (adapter defers non-chat commands ephemerally), so
|
|
1056
|
+
// the web-token-bearing URLs are only visible to the caller.
|
|
1057
|
+
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
1058
|
+
if (allowed.length === 0) {
|
|
1059
|
+
await data.respond(t("dashboard.disabled"));
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
if (!allowed.some(u => String(u) === String(data.userId))) {
|
|
1063
|
+
await data.respond(t("not_authorized"));
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
await data.respond(this.topicCommands.getDashboardText());
|
|
1067
|
+
}
|
|
959
1068
|
else if (data.command === "restart") {
|
|
960
1069
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
961
1070
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
962
|
-
await data.respond("
|
|
1071
|
+
await data.respond(t("not_authorized"));
|
|
963
1072
|
return;
|
|
964
1073
|
}
|
|
965
|
-
await data.respond("
|
|
1074
|
+
await data.respond(t("restart.graceful"));
|
|
966
1075
|
process.kill(process.pid, "SIGUSR2");
|
|
967
1076
|
}
|
|
968
1077
|
else if (data.command === "compact") {
|
|
969
|
-
const
|
|
970
|
-
if (!
|
|
971
|
-
await data.respond("
|
|
1078
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
1079
|
+
if (!name) {
|
|
1080
|
+
await data.respond(t("classic.no_agent"));
|
|
972
1081
|
return;
|
|
973
1082
|
}
|
|
974
|
-
const result = await this.topicCommands.sendCompact(
|
|
1083
|
+
const result = await this.topicCommands.sendCompact(name);
|
|
975
1084
|
await data.respond(result);
|
|
976
1085
|
}
|
|
977
1086
|
}, this.logger, "adapter.slash_command"));
|
|
@@ -1015,7 +1124,7 @@ export class FleetManager {
|
|
|
1015
1124
|
}, 5 * 60 * 1000);
|
|
1016
1125
|
}
|
|
1017
1126
|
/** Start an additional (non-primary) adapter */
|
|
1018
|
-
async startAdditionalAdapter(channelConfig) {
|
|
1127
|
+
async startAdditionalAdapter(channelConfig, registerCommands = true) {
|
|
1019
1128
|
const adapterId = channelConfig.id ?? channelConfig.type;
|
|
1020
1129
|
const botToken = process.env[channelConfig.bot_token_env];
|
|
1021
1130
|
if (!botToken) {
|
|
@@ -1024,7 +1133,7 @@ export class FleetManager {
|
|
|
1024
1133
|
}
|
|
1025
1134
|
const accessDir = join(this.dataDir, "access");
|
|
1026
1135
|
mkdirSync(accessDir, { recursive: true });
|
|
1027
|
-
const accessManager = new AccessManager(channelConfig.access, join(accessDir, `access-${adapterId}.json`));
|
|
1136
|
+
const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, join(accessDir, `access-${adapterId}.json`));
|
|
1028
1137
|
const inboxDir = join(this.dataDir, "inbox");
|
|
1029
1138
|
mkdirSync(inboxDir, { recursive: true });
|
|
1030
1139
|
const adapter = await createAdapter(channelConfig, {
|
|
@@ -1032,6 +1141,7 @@ export class FleetManager {
|
|
|
1032
1141
|
botToken,
|
|
1033
1142
|
accessManager,
|
|
1034
1143
|
inboxDir,
|
|
1144
|
+
registerCommands,
|
|
1035
1145
|
});
|
|
1036
1146
|
const world = new AdapterWorld(adapterId, adapter, accessManager, channelConfig);
|
|
1037
1147
|
this.worlds.set(adapterId, world);
|
|
@@ -1075,28 +1185,28 @@ export class FleetManager {
|
|
|
1075
1185
|
// Slash commands: classic bot + admin commands
|
|
1076
1186
|
adapter.on("slash_command", safeHandler(async (data) => {
|
|
1077
1187
|
if (data.command === "start") {
|
|
1078
|
-
const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId);
|
|
1188
|
+
const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId, adapterId);
|
|
1079
1189
|
await data.respond(reply);
|
|
1080
1190
|
}
|
|
1081
1191
|
else if (data.command === "stop") {
|
|
1082
|
-
const reply = await this.handleClassicStop(data.channelId);
|
|
1192
|
+
const reply = await this.handleClassicStop(data.channelId, adapterId);
|
|
1083
1193
|
await data.respond(reply);
|
|
1084
1194
|
}
|
|
1085
1195
|
else if (data.command === "chat") {
|
|
1086
1196
|
const text = data.text ?? "";
|
|
1087
1197
|
if (!text) {
|
|
1088
|
-
await data.respond("
|
|
1198
|
+
await data.respond(t("chat.usage"));
|
|
1089
1199
|
return;
|
|
1090
1200
|
}
|
|
1091
|
-
const
|
|
1092
|
-
if (!
|
|
1093
|
-
await data.respond("
|
|
1201
|
+
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
1202
|
+
if (!name) {
|
|
1203
|
+
await data.respond(t("classic.no_agent_start"));
|
|
1094
1204
|
return;
|
|
1095
1205
|
}
|
|
1096
1206
|
const replyMsgId = await data.respond("👀");
|
|
1097
1207
|
const username = data.username ?? data.userId;
|
|
1098
|
-
ClassicChannelManager.logMessage(
|
|
1099
|
-
await this.forwardToClassicInstance(
|
|
1208
|
+
ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
|
|
1209
|
+
await this.forwardToClassicInstance(name, text, {
|
|
1100
1210
|
chatId: data.channelId,
|
|
1101
1211
|
threadId: data.channelId,
|
|
1102
1212
|
messageId: replyMsgId ?? "",
|
|
@@ -1107,86 +1217,79 @@ export class FleetManager {
|
|
|
1107
1217
|
});
|
|
1108
1218
|
}
|
|
1109
1219
|
else if (data.command === "save") {
|
|
1110
|
-
await this.handleSlashSave(data);
|
|
1220
|
+
await this.handleSlashSave(data, adapterId);
|
|
1111
1221
|
}
|
|
1112
1222
|
else if (data.command === "load") {
|
|
1113
1223
|
// load is kiro-cli/classic only — no claude-code equivalent.
|
|
1114
1224
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
1115
|
-
await data.respond("
|
|
1225
|
+
await data.respond(t("admin.required"));
|
|
1116
1226
|
return;
|
|
1117
1227
|
}
|
|
1118
|
-
const
|
|
1119
|
-
if (!
|
|
1120
|
-
await data.respond("
|
|
1228
|
+
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
1229
|
+
if (!name) {
|
|
1230
|
+
await data.respond(t("classic.no_agent_start"));
|
|
1121
1231
|
return;
|
|
1122
1232
|
}
|
|
1123
1233
|
const filename = data.options?.filename;
|
|
1124
1234
|
if (!SAVE_FILENAME_RE.test(filename ?? "")) {
|
|
1125
|
-
await data.respond("
|
|
1235
|
+
await data.respond(t("filename.invalid"));
|
|
1126
1236
|
return;
|
|
1127
1237
|
}
|
|
1128
|
-
this.pasteRawToClassicInstance(
|
|
1129
|
-
await data.respond(
|
|
1130
|
-
}
|
|
1131
|
-
else if (data.command === "compact") {
|
|
1132
|
-
const target = this.routing.resolve(data.channelId);
|
|
1133
|
-
if (!target) {
|
|
1134
|
-
await data.respond("No active agent in this channel.");
|
|
1135
|
-
return;
|
|
1136
|
-
}
|
|
1137
|
-
const result = await this.topicCommands.sendCompact(target.name);
|
|
1138
|
-
await data.respond(result);
|
|
1238
|
+
this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
|
|
1239
|
+
await data.respond(t("save.sent", `/chat load ${filename}`, name));
|
|
1139
1240
|
}
|
|
1140
1241
|
else if (data.command === "cancel") {
|
|
1141
|
-
const
|
|
1142
|
-
if (!
|
|
1143
|
-
await data.respond("
|
|
1242
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
1243
|
+
if (!name) {
|
|
1244
|
+
await data.respond(t("classic.no_agent"));
|
|
1144
1245
|
return;
|
|
1145
1246
|
}
|
|
1146
|
-
const ok = this.cancelInstance(
|
|
1147
|
-
await data.respond(ok ?
|
|
1247
|
+
const ok = this.cancelInstance(name);
|
|
1248
|
+
await data.respond(ok ? t("cancel.sent", name) : t("cancel.not_running", name));
|
|
1148
1249
|
}
|
|
1149
1250
|
else if (data.command === "ctx") {
|
|
1150
|
-
const
|
|
1151
|
-
if (!
|
|
1152
|
-
await data.respond("
|
|
1251
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
1252
|
+
if (!name) {
|
|
1253
|
+
await data.respond(t("classic.no_agent"));
|
|
1153
1254
|
return;
|
|
1154
1255
|
}
|
|
1155
1256
|
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
1156
|
-
await data.respond(await this.topicCommands.getCtxText(
|
|
1257
|
+
await data.respond(await this.topicCommands.getCtxText(name));
|
|
1157
1258
|
}
|
|
1158
1259
|
else if (data.command === "collab") {
|
|
1260
|
+
// Classic no longer lives in the routing engine, so a routing hit here is
|
|
1261
|
+
// always a fleet-topic instance.
|
|
1159
1262
|
const collabTarget2 = this.routing.resolve(data.channelId);
|
|
1160
|
-
if (collabTarget2
|
|
1263
|
+
if (collabTarget2) {
|
|
1161
1264
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
1162
1265
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
1163
|
-
await data.respond("
|
|
1266
|
+
await data.respond(t("not_authorized"));
|
|
1164
1267
|
return;
|
|
1165
1268
|
}
|
|
1166
1269
|
const isCollab = this.toggleFleetCollab(collabTarget2.name);
|
|
1167
|
-
await data.respond(isCollab ? "
|
|
1270
|
+
await data.respond(isCollab ? t("collab.on") : t("collab.off"));
|
|
1168
1271
|
return;
|
|
1169
1272
|
}
|
|
1170
1273
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
1171
|
-
await data.respond("
|
|
1274
|
+
await data.respond(t("admin.required"));
|
|
1172
1275
|
return;
|
|
1173
1276
|
}
|
|
1174
|
-
if (!this.classicChannels.isClassicChannel(data.channelId)) {
|
|
1175
|
-
await data.respond("
|
|
1277
|
+
if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
|
|
1278
|
+
await data.respond(t("classic.no_agent_start"));
|
|
1176
1279
|
return;
|
|
1177
1280
|
}
|
|
1178
|
-
const newState = this.classicChannels.toggleCollab(data.channelId);
|
|
1281
|
+
const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
|
|
1179
1282
|
await data.respond(newState
|
|
1180
|
-
? "
|
|
1181
|
-
: "
|
|
1283
|
+
? t("collab.on.classic")
|
|
1284
|
+
: t("collab.off.classic"));
|
|
1182
1285
|
}
|
|
1183
1286
|
else if (data.command === "update") {
|
|
1184
1287
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
1185
1288
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
1186
|
-
await data.respond("
|
|
1289
|
+
await data.respond(t("not_authorized"));
|
|
1187
1290
|
return;
|
|
1188
1291
|
}
|
|
1189
|
-
await data.respond("
|
|
1292
|
+
await data.respond(t("update.running"));
|
|
1190
1293
|
const { spawn } = await import("node:child_process");
|
|
1191
1294
|
const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
|
|
1192
1295
|
const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
|
|
@@ -1196,7 +1299,7 @@ export class FleetManager {
|
|
|
1196
1299
|
else if (data.command === "doctor") {
|
|
1197
1300
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
1198
1301
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
1199
|
-
await data.respond("
|
|
1302
|
+
await data.respond(t("not_authorized"));
|
|
1200
1303
|
return;
|
|
1201
1304
|
}
|
|
1202
1305
|
try {
|
|
@@ -1218,22 +1321,36 @@ export class FleetManager {
|
|
|
1218
1321
|
else if (data.command === "sysinfo") {
|
|
1219
1322
|
await data.respond(this.topicCommands.getSysInfoText());
|
|
1220
1323
|
}
|
|
1324
|
+
else if (data.command === "dashboard") {
|
|
1325
|
+
// Reply is ephemeral (adapter defers non-chat commands ephemerally), so
|
|
1326
|
+
// the web-token-bearing URLs are only visible to the caller.
|
|
1327
|
+
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
1328
|
+
if (allowed.length === 0) {
|
|
1329
|
+
await data.respond(t("dashboard.disabled"));
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
if (!allowed.some(u => String(u) === String(data.userId))) {
|
|
1333
|
+
await data.respond(t("not_authorized"));
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
await data.respond(this.topicCommands.getDashboardText());
|
|
1337
|
+
}
|
|
1221
1338
|
else if (data.command === "restart") {
|
|
1222
1339
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
1223
1340
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
1224
|
-
await data.respond("
|
|
1341
|
+
await data.respond(t("not_authorized"));
|
|
1225
1342
|
return;
|
|
1226
1343
|
}
|
|
1227
|
-
await data.respond("
|
|
1344
|
+
await data.respond(t("restart.graceful"));
|
|
1228
1345
|
process.kill(process.pid, "SIGUSR2");
|
|
1229
1346
|
}
|
|
1230
1347
|
else if (data.command === "compact") {
|
|
1231
|
-
const
|
|
1232
|
-
if (!
|
|
1233
|
-
await data.respond("
|
|
1348
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
1349
|
+
if (!name) {
|
|
1350
|
+
await data.respond(t("classic.no_agent"));
|
|
1234
1351
|
return;
|
|
1235
1352
|
}
|
|
1236
|
-
const result = await this.topicCommands.sendCompact(
|
|
1353
|
+
const result = await this.topicCommands.sendCompact(name);
|
|
1237
1354
|
await data.respond(result);
|
|
1238
1355
|
}
|
|
1239
1356
|
}, this.logger, `adapter[${adapterId}].slash_command`));
|
|
@@ -1451,6 +1568,34 @@ export class FleetManager {
|
|
|
1451
1568
|
async handleInboundMessage(msg) {
|
|
1452
1569
|
const threadId = msg.threadId || undefined;
|
|
1453
1570
|
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");
|
|
1571
|
+
// Multi-adapter dedup: when several bots share a guild, each adapter fires
|
|
1572
|
+
// its own "message" event for the same underlying message. Process it once.
|
|
1573
|
+
// Routing (by topic/channel) and reply-adapter selection (by channel_id
|
|
1574
|
+
// binding) are adapter-independent, so it's safe to let whichever adapter
|
|
1575
|
+
// arrives first handle it.
|
|
1576
|
+
//
|
|
1577
|
+
// EXCEPTION — classic channels with same-channel multi-bot: two bots may own
|
|
1578
|
+
// separate agents in one channel, so each bot must process its OWN copy of
|
|
1579
|
+
// the message (the @mention filter downstream decides who actually forwards).
|
|
1580
|
+
// Key the dedup per-adapter there so a sibling bot's copy isn't dropped.
|
|
1581
|
+
if (msg.messageId) {
|
|
1582
|
+
const classicCid = msg.threadId || msg.chatId;
|
|
1583
|
+
const isClassicMsg = this.classicChannels?.hasChannel(classicCid) ?? false;
|
|
1584
|
+
const dedupKey = isClassicMsg
|
|
1585
|
+
? `${msg.source}:${msg.chatId}:${msg.messageId}:${msg.adapterId ?? ""}`
|
|
1586
|
+
: `${msg.source}:${msg.chatId}:${msg.messageId}`;
|
|
1587
|
+
if (this.recentMessageIds.has(dedupKey)) {
|
|
1588
|
+
this.logger.debug({ dedupKey, adapterId: msg.adapterId }, "Duplicate inbound across adapters — skipping");
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
this.recentMessageIds.add(dedupKey);
|
|
1592
|
+
if (this.recentMessageIds.size > 1000) {
|
|
1593
|
+
// Set preserves insertion order — drop the oldest key.
|
|
1594
|
+
const oldest = this.recentMessageIds.values().next().value;
|
|
1595
|
+
if (oldest !== undefined)
|
|
1596
|
+
this.recentMessageIds.delete(oldest);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1454
1599
|
// Bot messages: only allow in collab channels or TG classic with @mention
|
|
1455
1600
|
if (msg.isBotMessage) {
|
|
1456
1601
|
if (!threadId) {
|
|
@@ -1465,21 +1610,25 @@ export class FleetManager {
|
|
|
1465
1610
|
return;
|
|
1466
1611
|
// Fall through to TG classic handling below
|
|
1467
1612
|
}
|
|
1613
|
+
else if (this.classicChannels?.hasChannel(threadId)) {
|
|
1614
|
+
// Classic channel (per-bot): bot messages only when THIS bot owns an
|
|
1615
|
+
// agent here and collab is on for it.
|
|
1616
|
+
const classicName = this.classicChannels.getInstanceByChannel(threadId, msg.adapterId);
|
|
1617
|
+
if (!classicName)
|
|
1618
|
+
return;
|
|
1619
|
+
if (!this.classicChannels.isCollab(threadId, msg.adapterId))
|
|
1620
|
+
return;
|
|
1621
|
+
// Fall through to channel handling
|
|
1622
|
+
}
|
|
1468
1623
|
else {
|
|
1469
1624
|
const target = this.routing.resolve(threadId);
|
|
1470
1625
|
if (!target)
|
|
1471
1626
|
return;
|
|
1472
|
-
if
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
// Fleet topic: allow if collab enabled OR access mode is open
|
|
1478
|
-
const channelCfg = this.getChannelConfig(msg.adapterId);
|
|
1479
|
-
const isOpen = channelCfg?.access?.mode === "open";
|
|
1480
|
-
if (!isOpen && !this.collabInstances.has(target.name))
|
|
1481
|
-
return;
|
|
1482
|
-
}
|
|
1627
|
+
// Fleet topic: allow if collab enabled OR access mode is open
|
|
1628
|
+
const channelCfg = this.getChannelConfig(msg.adapterId);
|
|
1629
|
+
const isOpen = channelCfg?.access?.mode === "open";
|
|
1630
|
+
if (!isOpen && !this.collabInstances.has(target.name))
|
|
1631
|
+
return;
|
|
1483
1632
|
// Fall through to channel handling
|
|
1484
1633
|
}
|
|
1485
1634
|
}
|
|
@@ -1489,9 +1638,10 @@ export class FleetManager {
|
|
|
1489
1638
|
const adapterGroupId = String(this.getChannelConfig(msg.adapterId)?.group_id ?? "");
|
|
1490
1639
|
const isTelegramClassicCandidate = msg.source === "telegram" && msg.chatId !== adapterGroupId && !threadId;
|
|
1491
1640
|
if (!isTelegramClassicCandidate) {
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1641
|
+
// Classic channels are open to all; check per-bot ownership (or fleet topic).
|
|
1642
|
+
const isClassic = !!(threadId && this.classicChannels?.hasChannel(threadId));
|
|
1643
|
+
this.logger.info({ userId: msg.userId, threadId, isClassic }, "Access DENIED for non-allowed user");
|
|
1644
|
+
if (!isClassic)
|
|
1495
1645
|
return;
|
|
1496
1646
|
}
|
|
1497
1647
|
}
|
|
@@ -1538,7 +1688,7 @@ export class FleetManager {
|
|
|
1538
1688
|
if (generalId) {
|
|
1539
1689
|
this.notifyInstanceTopic(generalId, `🆕 Unauthorized user tried /start in private chat:\n• Name: ${msg.username}\n• ID: ${msg.userId}\n• Platform: ${msg.source}\n\nTo allow: add \`${msg.userId}\` to classicBot.yaml \`allowed_users\``);
|
|
1540
1690
|
}
|
|
1541
|
-
await msgAdapter?.sendText(chatId, "
|
|
1691
|
+
await msgAdapter?.sendText(chatId, t("classic.not_allowed_user"));
|
|
1542
1692
|
return;
|
|
1543
1693
|
}
|
|
1544
1694
|
}
|
|
@@ -1551,11 +1701,11 @@ export class FleetManager {
|
|
|
1551
1701
|
if (generalId) {
|
|
1552
1702
|
this.notifyInstanceTopic(generalId, adminMsg);
|
|
1553
1703
|
}
|
|
1554
|
-
await msgAdapter?.sendText(chatId, "
|
|
1704
|
+
await msgAdapter?.sendText(chatId, t("classic.access_requested"));
|
|
1555
1705
|
return;
|
|
1556
1706
|
}
|
|
1557
1707
|
if (!this.classicChannels.isAdmin(msg.userId)) {
|
|
1558
|
-
await msgAdapter?.sendText(chatId, "
|
|
1708
|
+
await msgAdapter?.sendText(chatId, t("classic.admin_only_start"));
|
|
1559
1709
|
const generalId = this.findGeneralInstance(msg.adapterId);
|
|
1560
1710
|
if (generalId) {
|
|
1561
1711
|
this.notifyInstanceTopic(generalId, `🔑 User wants to /start but is not admin:\n• Name: ${msg.username}\n• ID: ${msg.userId}\n• Platform: ${msg.source}\n• Group: ${chatId}\n\nTo approve: add \`${msg.userId}\` to classicBot.yaml \`admin_users\``);
|
|
@@ -1564,118 +1714,117 @@ export class FleetManager {
|
|
|
1564
1714
|
}
|
|
1565
1715
|
}
|
|
1566
1716
|
const channelName = msg.username || chatId;
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
this.bindInstanceAdapter(classicInstanceName(sanitizeInstanceName(channelName || chatId), chatId), msg.adapterId, true);
|
|
1717
|
+
// handleClassicStart binds the instance to this adapter authoritatively.
|
|
1718
|
+
const reply = await this.handleClassicStart(chatId, channelName, msg.userId, undefined, msg.adapterId);
|
|
1570
1719
|
await msgAdapter?.sendText(chatId, reply);
|
|
1571
1720
|
return;
|
|
1572
1721
|
}
|
|
1573
1722
|
// Handle /stop command
|
|
1574
1723
|
if (text === "/stop" || text.startsWith("/stop ")) {
|
|
1575
1724
|
if (!this.classicChannels.isAdmin(msg.userId)) {
|
|
1576
|
-
await msgAdapter?.sendText(chatId, "
|
|
1725
|
+
await msgAdapter?.sendText(chatId, t("classic.admin_only_stop"));
|
|
1577
1726
|
const generalId = this.findGeneralInstance(msg.adapterId);
|
|
1578
1727
|
if (generalId) {
|
|
1579
1728
|
this.notifyInstanceTopic(generalId, `🔑 User wants to /stop but is not admin:\n• Name: ${msg.username}\n• ID: ${msg.userId}\n• Platform: ${msg.source}\n• Group: ${chatId}\n\nTo approve: add \`${msg.userId}\` to classicBot.yaml \`admin_users\``);
|
|
1580
1729
|
}
|
|
1581
1730
|
return;
|
|
1582
1731
|
}
|
|
1583
|
-
const reply = await this.handleClassicStop(chatId);
|
|
1732
|
+
const reply = await this.handleClassicStop(chatId, msg.adapterId);
|
|
1584
1733
|
await msgAdapter?.sendText(chatId, reply);
|
|
1585
1734
|
return;
|
|
1586
1735
|
}
|
|
1587
1736
|
// Handle /compact command (admin only)
|
|
1588
1737
|
if (text === "/compact" || text.startsWith("/compact@")) {
|
|
1589
1738
|
if (!this.classicChannels.isAdmin(msg.userId)) {
|
|
1590
|
-
await msgAdapter?.sendText(chatId, "
|
|
1739
|
+
await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/compact"));
|
|
1591
1740
|
return;
|
|
1592
1741
|
}
|
|
1593
|
-
const
|
|
1594
|
-
if (!
|
|
1595
|
-
await msgAdapter?.sendText(chatId, "
|
|
1742
|
+
const compactName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
1743
|
+
if (!compactName) {
|
|
1744
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
1596
1745
|
return;
|
|
1597
1746
|
}
|
|
1598
|
-
const result = await this.topicCommands.sendCompact(
|
|
1747
|
+
const result = await this.topicCommands.sendCompact(compactName);
|
|
1599
1748
|
await msgAdapter?.sendText(chatId, result);
|
|
1600
1749
|
return;
|
|
1601
1750
|
}
|
|
1602
1751
|
// Handle /cancel command
|
|
1603
1752
|
if (text === "/cancel" || text.startsWith("/cancel@")) {
|
|
1604
|
-
const
|
|
1605
|
-
if (!
|
|
1606
|
-
await msgAdapter?.sendText(chatId, "
|
|
1753
|
+
const cancelName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
1754
|
+
if (!cancelName) {
|
|
1755
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
1607
1756
|
return;
|
|
1608
1757
|
}
|
|
1609
|
-
const ok = this.cancelInstance(
|
|
1610
|
-
await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${
|
|
1758
|
+
const ok = this.cancelInstance(cancelName);
|
|
1759
|
+
await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelName}。` : `❌ ${cancelName} 未在執行。`);
|
|
1611
1760
|
return;
|
|
1612
1761
|
}
|
|
1613
1762
|
// Handle /ctx command
|
|
1614
1763
|
if (text === "/ctx" || text.startsWith("/ctx@")) {
|
|
1615
|
-
const
|
|
1616
|
-
if (!
|
|
1617
|
-
await msgAdapter?.sendText(chatId, "
|
|
1764
|
+
const ctxName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
1765
|
+
if (!ctxName) {
|
|
1766
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
1618
1767
|
return;
|
|
1619
1768
|
}
|
|
1620
|
-
const reply = await this.topicCommands.getCtxText(
|
|
1769
|
+
const reply = await this.topicCommands.getCtxText(ctxName);
|
|
1621
1770
|
await msgAdapter?.sendText(chatId, reply);
|
|
1622
1771
|
return;
|
|
1623
1772
|
}
|
|
1624
1773
|
// Handle /save command (admin only)
|
|
1625
1774
|
if (text === "/save" || text.startsWith("/save ") || text.startsWith("/save@")) {
|
|
1626
1775
|
if (!this.classicChannels.isAdmin(msg.userId)) {
|
|
1627
|
-
await msgAdapter?.sendText(chatId, "
|
|
1776
|
+
await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/save"));
|
|
1628
1777
|
return;
|
|
1629
1778
|
}
|
|
1630
|
-
const
|
|
1631
|
-
if (!
|
|
1632
|
-
await msgAdapter?.sendText(chatId, "
|
|
1779
|
+
const saveName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
1780
|
+
if (!saveName) {
|
|
1781
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
1633
1782
|
return;
|
|
1634
1783
|
}
|
|
1635
1784
|
const filename = parseSaveFilename(text);
|
|
1636
1785
|
if (!filename) {
|
|
1637
|
-
await msgAdapter?.sendText(chatId, "
|
|
1786
|
+
await msgAdapter?.sendText(chatId, t("save.usage"));
|
|
1638
1787
|
return;
|
|
1639
1788
|
}
|
|
1640
1789
|
if (!SAVE_FILENAME_RE.test(filename)) {
|
|
1641
|
-
await msgAdapter?.sendText(chatId, "
|
|
1790
|
+
await msgAdapter?.sendText(chatId, t("filename.invalid"));
|
|
1642
1791
|
return;
|
|
1643
1792
|
}
|
|
1644
|
-
const backend = this.classicChannels.getBackendByInstance(
|
|
1793
|
+
const backend = this.classicChannels.getBackendByInstance(saveName, this.fleetConfig?.defaults?.backend);
|
|
1645
1794
|
const cmd = saveCommandForBackend(backend, filename);
|
|
1646
1795
|
if (!cmd) {
|
|
1647
1796
|
await msgAdapter?.sendText(chatId, SAVE_UNSUPPORTED_MSG);
|
|
1648
1797
|
return;
|
|
1649
1798
|
}
|
|
1650
|
-
this.pasteRawToClassicInstance(
|
|
1651
|
-
await msgAdapter?.sendText(chatId,
|
|
1799
|
+
this.pasteRawToClassicInstance(saveName, cmd);
|
|
1800
|
+
await msgAdapter?.sendText(chatId, t("save.sent", cmd, saveName));
|
|
1652
1801
|
return;
|
|
1653
1802
|
}
|
|
1654
|
-
// Route to classic channel if
|
|
1655
|
-
const
|
|
1656
|
-
if (
|
|
1803
|
+
// Route to classic channel if this bot has an agent here (per-bot).
|
|
1804
|
+
const classicName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
1805
|
+
if (classicName) {
|
|
1657
1806
|
if (msg.adapterId)
|
|
1658
|
-
this.bindInstanceAdapter(
|
|
1807
|
+
this.bindInstanceAdapter(classicName, msg.adapterId, true);
|
|
1659
1808
|
// TG ClassicBot: group requires @mention, private chat forwards directly.
|
|
1660
1809
|
if (!isPrivateChat && !isBotMentioned) {
|
|
1661
1810
|
// No trigger: save attachments + react, log, but don't forward to agent
|
|
1662
1811
|
const syntheticMsg = { ...msg, threadId: chatId, text: rawText.startsWith("/") ? "" : rawText };
|
|
1663
|
-
await this.handleClassicChannelMessage(
|
|
1812
|
+
await this.handleClassicChannelMessage(classicName, syntheticMsg);
|
|
1664
1813
|
return;
|
|
1665
1814
|
}
|
|
1666
1815
|
// Strip @bot from text and forward as /chat
|
|
1667
1816
|
const cleanText = botUser ? text.replace(new RegExp(`@${botUser}`, "gi"), "").trim() : text;
|
|
1668
1817
|
if (cleanText.startsWith("/raw") && !this.classicChannels.isAdmin(msg.userId)) {
|
|
1669
|
-
await msgAdapter?.sendText(chatId, "
|
|
1818
|
+
await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/raw"));
|
|
1670
1819
|
return;
|
|
1671
1820
|
}
|
|
1672
1821
|
const syntheticMsg = { ...msg, threadId: chatId, text: `/chat ${cleanText}` };
|
|
1673
|
-
await this.handleClassicChannelMessage(
|
|
1822
|
+
await this.handleClassicChannelMessage(classicName, syntheticMsg);
|
|
1674
1823
|
return;
|
|
1675
1824
|
}
|
|
1676
1825
|
// Handle @bot without active agent
|
|
1677
1826
|
if (isBotMentioned) {
|
|
1678
|
-
await msgAdapter?.sendText(chatId, "
|
|
1827
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
1679
1828
|
return;
|
|
1680
1829
|
}
|
|
1681
1830
|
// Unregistered private chat: ignore (don't fall through to General)
|
|
@@ -1695,9 +1844,12 @@ export class FleetManager {
|
|
|
1695
1844
|
if (msg.adapterId)
|
|
1696
1845
|
this.bindInstanceAdapter(generalInstance, msg.adapterId, true);
|
|
1697
1846
|
const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
1698
|
-
// React immediately — before any other API calls
|
|
1847
|
+
// React immediately — before any other API calls. Use the adapter BOUND to
|
|
1848
|
+
// the instance (not whichever same-guild bot received the event first) so
|
|
1849
|
+
// exactly the owning bot reacts — no duplicate 👀 from a sibling bot.
|
|
1699
1850
|
if (msg.chatId && msg.messageId) {
|
|
1700
|
-
|
|
1851
|
+
const reactAdapter = this.getAdapterForInstance(generalInstance) ?? inboundAdapter;
|
|
1852
|
+
reactAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
|
|
1701
1853
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
1702
1854
|
}
|
|
1703
1855
|
this.warnIfRateLimited(generalInstance, msg);
|
|
@@ -1733,6 +1885,18 @@ export class FleetManager {
|
|
|
1733
1885
|
}
|
|
1734
1886
|
return;
|
|
1735
1887
|
}
|
|
1888
|
+
// Classic channels resolve per-bot (same-channel multi-bot) — a channel can
|
|
1889
|
+
// host two bots' agents. If this channel is classic but THIS bot has no
|
|
1890
|
+
// agent here, a sibling bot owns it; skip rather than misroute to it.
|
|
1891
|
+
if (this.classicChannels?.hasChannel(threadId)) {
|
|
1892
|
+
const classicName = this.classicChannels.getInstanceByChannel(threadId, msg.adapterId);
|
|
1893
|
+
if (!classicName)
|
|
1894
|
+
return;
|
|
1895
|
+
if (msg.adapterId)
|
|
1896
|
+
this.bindInstanceAdapter(classicName, msg.adapterId, true);
|
|
1897
|
+
await this.handleClassicChannelMessage(classicName, msg);
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1736
1900
|
const target = this.routing.resolve(threadId);
|
|
1737
1901
|
if (!target) {
|
|
1738
1902
|
// Only show unbound message for actual forum topics (same group, has threadId)
|
|
@@ -1764,9 +1928,12 @@ export class FleetManager {
|
|
|
1764
1928
|
if (msg.adapterId)
|
|
1765
1929
|
this.bindInstanceAdapter(instanceName, msg.adapterId, true);
|
|
1766
1930
|
const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
1767
|
-
// React immediately — before any other Discord API calls
|
|
1931
|
+
// React immediately — before any other Discord API calls. Use the adapter
|
|
1932
|
+
// BOUND to the instance (not whichever same-guild bot received the event
|
|
1933
|
+
// first) so exactly the owning bot reacts — no duplicate 👀 from a sibling.
|
|
1768
1934
|
if (msg.chatId && msg.messageId) {
|
|
1769
|
-
|
|
1935
|
+
const reactAdapter = this.getAdapterForInstance(instanceName) ?? inboundAdapter;
|
|
1936
|
+
reactAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
|
|
1770
1937
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
1771
1938
|
}
|
|
1772
1939
|
// These may hit Discord API (topic icon, archive) — do after react
|
|
@@ -1891,7 +2058,7 @@ export class FleetManager {
|
|
|
1891
2058
|
ts: new Date().toISOString(),
|
|
1892
2059
|
});
|
|
1893
2060
|
// Log bot reply to classic instance chat-log
|
|
1894
|
-
const isClassic =
|
|
2061
|
+
const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
|
|
1895
2062
|
if (isClassic) {
|
|
1896
2063
|
ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
|
|
1897
2064
|
}
|
|
@@ -1941,6 +2108,21 @@ export class FleetManager {
|
|
|
1941
2108
|
// ===================== Scheduler =====================
|
|
1942
2109
|
async handleScheduleTrigger(schedule) {
|
|
1943
2110
|
const { target, reply_chat_id, reply_thread_id, message, label, id, source } = schedule;
|
|
2111
|
+
// Reserved auto-update schedule: run `agend update` instead of delivering a
|
|
2112
|
+
// message to an instance. message === "beta" selects the beta channel.
|
|
2113
|
+
if (target === "__fleet_update__") {
|
|
2114
|
+
const cmd = message === "beta" ? "agend update --beta" : "agend update";
|
|
2115
|
+
this.logger.info({ cmd, scheduleId: id }, "Auto-update schedule fired");
|
|
2116
|
+
try {
|
|
2117
|
+
const { spawn } = await import("node:child_process");
|
|
2118
|
+
spawn("sh", ["-c", `sleep 1 && ${cmd}`], { detached: true, stdio: "ignore" }).unref();
|
|
2119
|
+
this.scheduler?.recordRun(id, "delivered", `ran ${cmd}`);
|
|
2120
|
+
}
|
|
2121
|
+
catch (err) {
|
|
2122
|
+
this.scheduler?.recordRun(id, "retry", `update spawn failed: ${err.message}`);
|
|
2123
|
+
}
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
1944
2126
|
const RATE_LIMIT_DEFER_THRESHOLD = 85;
|
|
1945
2127
|
const rl = this.statuslineWatcher.getRateLimits(target);
|
|
1946
2128
|
if (rl && rl.five_hour_pct > RATE_LIMIT_DEFER_THRESHOLD) {
|
|
@@ -2510,15 +2692,17 @@ export class FleetManager {
|
|
|
2510
2692
|
startStatuslineWatcher(name) {
|
|
2511
2693
|
this.statuslineWatcher.watch(name);
|
|
2512
2694
|
}
|
|
2513
|
-
reactMessageStatus(chatId, messageId, emoji) {
|
|
2514
|
-
//
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2695
|
+
reactMessageStatus(instanceName, chatId, messageId, emoji) {
|
|
2696
|
+
// React via the adapter BOUND to this instance — NOT the first discord world.
|
|
2697
|
+
// Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
|
|
2698
|
+
// (bound bot) and the delivery/confirm reactions (some other bot) come from
|
|
2699
|
+
// different bots, leaving a duplicate 👀 that never turns into ✅.
|
|
2700
|
+
const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
2701
|
+
// Status reactions are Discord-only (TG/others use the inbound react path).
|
|
2702
|
+
if (!adapter || adapter.type !== "discord")
|
|
2703
|
+
return;
|
|
2704
|
+
adapter.react(chatId, messageId, emoji)
|
|
2705
|
+
.catch(e => this.logger.debug({ err: e.message }, "Message status react failed"));
|
|
2522
2706
|
}
|
|
2523
2707
|
// ── Model failover ──────────────────────────────────────────────────────
|
|
2524
2708
|
static FAILOVER_TRIGGER_PCT = 90;
|
|
@@ -2580,13 +2764,12 @@ export class FleetManager {
|
|
|
2580
2764
|
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send instance topic notification"));
|
|
2581
2765
|
return;
|
|
2582
2766
|
}
|
|
2583
|
-
// Classic instance: find
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
}
|
|
2767
|
+
// Classic instance: find its channelId from the classic manager
|
|
2768
|
+
const classicChatId = this.classicChannels?.getChannelIdByInstance(instanceName);
|
|
2769
|
+
if (classicChatId) {
|
|
2770
|
+
adapter.sendText(classicChatId, text)
|
|
2771
|
+
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send classic notification"));
|
|
2772
|
+
return;
|
|
2590
2773
|
}
|
|
2591
2774
|
// Fallback: send to group without threadId
|
|
2592
2775
|
if (groupId) {
|
|
@@ -2603,19 +2786,23 @@ export class FleetManager {
|
|
|
2603
2786
|
* Picks the backend-appropriate command (kiro → /chat save, claude → /export);
|
|
2604
2787
|
* unsupported backends get a clear error. Routes via classic paste or fleet IPC.
|
|
2605
2788
|
*/
|
|
2606
|
-
async handleSlashSave(data) {
|
|
2789
|
+
async handleSlashSave(data, adapterId) {
|
|
2607
2790
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
2608
|
-
await data.respond("
|
|
2791
|
+
await data.respond(t("admin.required"));
|
|
2609
2792
|
return;
|
|
2610
2793
|
}
|
|
2611
|
-
|
|
2794
|
+
// Classic resolves per-bot (same-channel multi-bot); otherwise a fleet topic.
|
|
2795
|
+
const classicName = this.classicChannels.getInstanceByChannel(data.channelId, adapterId);
|
|
2796
|
+
const target = classicName
|
|
2797
|
+
? { kind: "classic", name: classicName }
|
|
2798
|
+
: this.routing.resolve(data.channelId);
|
|
2612
2799
|
if (!target) {
|
|
2613
|
-
await data.respond("
|
|
2800
|
+
await data.respond(t("classic.no_agent_start"));
|
|
2614
2801
|
return;
|
|
2615
2802
|
}
|
|
2616
2803
|
const filename = data.options?.filename ?? "";
|
|
2617
2804
|
if (!SAVE_FILENAME_RE.test(filename)) {
|
|
2618
|
-
await data.respond("
|
|
2805
|
+
await data.respond(t("filename.invalid"));
|
|
2619
2806
|
return;
|
|
2620
2807
|
}
|
|
2621
2808
|
const backend = target.kind === "classic"
|
|
@@ -2634,7 +2821,7 @@ export class FleetManager {
|
|
|
2634
2821
|
else {
|
|
2635
2822
|
this.instanceIpcClients.get(target.name)?.send({ type: "raw_paste", content: cmd });
|
|
2636
2823
|
}
|
|
2637
|
-
await data.respond(
|
|
2824
|
+
await data.respond(t("save.sent", cmd, target.name));
|
|
2638
2825
|
}
|
|
2639
2826
|
/** Whether the instance currently has at least one live cancel button. */
|
|
2640
2827
|
hasCancelButton(instanceName) {
|
|
@@ -2663,13 +2850,8 @@ export class FleetManager {
|
|
|
2663
2850
|
threadId = String(topicId);
|
|
2664
2851
|
}
|
|
2665
2852
|
else {
|
|
2666
|
-
// Classic instance:
|
|
2667
|
-
|
|
2668
|
-
if (target.kind === "classic" && target.name === instanceName) {
|
|
2669
|
-
chatId = cid;
|
|
2670
|
-
break;
|
|
2671
|
-
}
|
|
2672
|
-
}
|
|
2853
|
+
// Classic instance: channelId from the classic manager.
|
|
2854
|
+
chatId = this.classicChannels?.getChannelIdByInstance(instanceName);
|
|
2673
2855
|
// General / flat fallback: post to the group (no thread).
|
|
2674
2856
|
if (!chatId && groupId)
|
|
2675
2857
|
chatId = String(groupId);
|
|
@@ -2681,7 +2863,7 @@ export class FleetManager {
|
|
|
2681
2863
|
type: "cancel",
|
|
2682
2864
|
instanceName,
|
|
2683
2865
|
message: "👀 處理中…",
|
|
2684
|
-
choices: [{ id: `cancel:${instanceName}`, label: "
|
|
2866
|
+
choices: [{ id: `cancel:${instanceName}`, label: t("cancel.button") }],
|
|
2685
2867
|
}, threadId ? { threadId } : undefined);
|
|
2686
2868
|
// A concurrent sendCancelButton for the same instance may have posted its
|
|
2687
2869
|
// own button while we awaited notifyAlert. Retire any other buttons for
|
|
@@ -3152,7 +3334,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
3152
3334
|
async handleClassicChannelMessage(instanceName, msg) {
|
|
3153
3335
|
const text = msg.text ?? "";
|
|
3154
3336
|
const channelId = msg.threadId ?? msg.chatId;
|
|
3155
|
-
const isCollabMode = this.classicChannels?.isCollab(channelId) ?? false;
|
|
3337
|
+
const isCollabMode = this.classicChannels?.isCollab(channelId, msg.adapterId) ?? false;
|
|
3156
3338
|
// Handle /ctx in classic mode — always, regardless of collab mode
|
|
3157
3339
|
if (text === "/ctx" || text.startsWith("/ctx@")) {
|
|
3158
3340
|
const reply = await this.topicCommands.getCtxText(instanceName);
|
|
@@ -3179,8 +3361,15 @@ When users create specialized instances, suggest these configurations:
|
|
|
3179
3361
|
: "");
|
|
3180
3362
|
ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText);
|
|
3181
3363
|
this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message");
|
|
3182
|
-
// Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here
|
|
3183
|
-
|
|
3364
|
+
// Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here.
|
|
3365
|
+
// Each bot matches ONLY its own id. A secondary bot must NOT fall back to the
|
|
3366
|
+
// process-wide botUserId (the primary's) — otherwise, in a same-channel
|
|
3367
|
+
// multi-bot setup, an @mention of the primary would also match the secondary
|
|
3368
|
+
// and BOTH bots would react 👀 and forward. Only the primary adapter may use
|
|
3369
|
+
// the fallback.
|
|
3370
|
+
const mentionWorld = this.worlds.get(msg.adapterId ?? "");
|
|
3371
|
+
const isPrimaryAdapter = !mentionWorld || mentionWorld.adapter === this.adapter;
|
|
3372
|
+
const adapterBotUserId = mentionWorld?.botUserId ?? (isPrimaryAdapter ? this.botUserId : undefined);
|
|
3184
3373
|
const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
|
|
3185
3374
|
const isMentioned = mentionTag && text.includes(mentionTag);
|
|
3186
3375
|
if (!isMentioned) {
|
|
@@ -3358,7 +3547,11 @@ When users create specialized instances, suggest these configurations:
|
|
|
3358
3547
|
}
|
|
3359
3548
|
/** Forward a message to a classic channel instance with chat log context */
|
|
3360
3549
|
async forwardToClassicInstance(instanceName, text, msg, extraMeta) {
|
|
3361
|
-
|
|
3550
|
+
// Resolve the channel/adapter from the instance itself so per-channel context
|
|
3551
|
+
// config is correct even for a same-channel second bot.
|
|
3552
|
+
const ctxAdapterId = this.classicChannels?.getAdapterIdByInstance(instanceName);
|
|
3553
|
+
const ctxChannelId = this.classicChannels?.getChannelIdByInstance(instanceName) ?? msg.chatId;
|
|
3554
|
+
const contextLines = this.classicChannels?.getContextLines(ctxChannelId, ctxAdapterId) ?? 5;
|
|
3362
3555
|
const logContext = this.getRecentChatLog(instanceName, contextLines);
|
|
3363
3556
|
const fullText = logContext
|
|
3364
3557
|
? `[Chat log for context]\n${logContext}\n\n[User message]\n${text}`
|
|
@@ -3444,7 +3637,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
3444
3637
|
await this.startInstance(instanceName, config, topicMode);
|
|
3445
3638
|
}
|
|
3446
3639
|
/** Handle /start slash command — register classic channel */
|
|
3447
|
-
async handleClassicStart(channelId, channelName, userId, guildId) {
|
|
3640
|
+
async handleClassicStart(channelId, channelName, userId, guildId, adapterId) {
|
|
3448
3641
|
if (!this.classicChannels)
|
|
3449
3642
|
return "Classic channel manager not initialized.";
|
|
3450
3643
|
if (guildId && !this.classicChannels.isGuildAllowed(guildId)) {
|
|
@@ -3452,36 +3645,43 @@ When users create specialized instances, suggest these configurations:
|
|
|
3452
3645
|
if (generalId) {
|
|
3453
3646
|
this.notifyInstanceTopic(generalId, `🆕 Unauthorized guild tried /start:\n• Guild ID: ${guildId}\n• User: ${userId}\n• Platform: discord\n\nTo allow: add \`${guildId}\` to classicBot.yaml \`allowed_guilds\``);
|
|
3454
3647
|
}
|
|
3455
|
-
return "
|
|
3648
|
+
return t("classic.not_authorized_guild");
|
|
3456
3649
|
}
|
|
3457
|
-
|
|
3458
|
-
|
|
3650
|
+
// Per-bot check: a second bot may /start in the same channel (own agent).
|
|
3651
|
+
if (this.classicChannels.isClassicChannel(channelId, adapterId))
|
|
3652
|
+
return t("classic.already_active");
|
|
3653
|
+
// Classic no longer lives in the routing engine, so this only guards against
|
|
3654
|
+
// a fleet topic-mode instance colliding with the channel.
|
|
3459
3655
|
if (this.routing.resolve(channelId))
|
|
3460
|
-
return "
|
|
3461
|
-
const instanceName =
|
|
3462
|
-
this.classicChannels.register(channelId, instanceName, channelName || channelId, userId);
|
|
3463
|
-
|
|
3464
|
-
|
|
3656
|
+
return t("classic.topic_bound");
|
|
3657
|
+
const instanceName = this.classicChannels.deriveInstanceName(channelName || channelId, channelId, adapterId);
|
|
3658
|
+
this.classicChannels.register(channelId, adapterId, instanceName, channelName || channelId, userId);
|
|
3659
|
+
// Bind this classic instance to the bot that started it (authoritative), so
|
|
3660
|
+
// replies/cancel go out through that bot even though every same-guild bot
|
|
3661
|
+
// also sees the channel's messages.
|
|
3662
|
+
if (adapterId)
|
|
3663
|
+
this.bindInstanceAdapter(instanceName, adapterId);
|
|
3664
|
+
await this.startClassicInstance(instanceName, this.classicChannels.getBackend(channelId, adapterId, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channelId, adapterId), this.classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model));
|
|
3465
3665
|
this.reregisterClassicChannels();
|
|
3466
3666
|
// Auto-enable collab for Discord classic channels (TG uses @mention directly without collab mode)
|
|
3467
|
-
if (guildId && !this.classicChannels.isCollab(channelId)) {
|
|
3468
|
-
this.classicChannels.toggleCollab(channelId);
|
|
3667
|
+
if (guildId && !this.classicChannels.isCollab(channelId, adapterId)) {
|
|
3668
|
+
this.classicChannels.toggleCollab(channelId, adapterId);
|
|
3469
3669
|
}
|
|
3470
|
-
this.logger.info({ channelId, instanceName, userId }, "Classic channel started");
|
|
3471
|
-
return
|
|
3670
|
+
this.logger.info({ channelId, adapterId, instanceName, userId }, "Classic channel started");
|
|
3671
|
+
return t("classic.started");
|
|
3472
3672
|
}
|
|
3473
3673
|
/** Handle /stop slash command — unregister classic channel */
|
|
3474
|
-
async handleClassicStop(channelId) {
|
|
3674
|
+
async handleClassicStop(channelId, adapterId) {
|
|
3475
3675
|
if (!this.classicChannels)
|
|
3476
3676
|
return "Classic channel manager not initialized.";
|
|
3477
|
-
const ch = this.classicChannels.unregister(channelId);
|
|
3677
|
+
const ch = this.classicChannels.unregister(channelId, adapterId);
|
|
3478
3678
|
if (!ch)
|
|
3479
|
-
return "
|
|
3480
|
-
this.
|
|
3679
|
+
return t("classic.no_agent");
|
|
3680
|
+
this.instanceWorldBinding.delete(ch.instanceName);
|
|
3481
3681
|
await this.stopInstance(ch.instanceName).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance"));
|
|
3482
3682
|
this.reregisterClassicChannels();
|
|
3483
|
-
this.logger.info({ channelId, instanceName: ch.instanceName }, "Classic channel stopped");
|
|
3484
|
-
return
|
|
3683
|
+
this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
|
|
3684
|
+
return t("classic.stopped");
|
|
3485
3685
|
}
|
|
3486
3686
|
async stopAll() {
|
|
3487
3687
|
this.ipcStoppingInstances.add("__fleet_stopping__");
|
|
@@ -3892,7 +4092,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
3892
4092
|
if (target && this.semverGt(target, currentVersion)) {
|
|
3893
4093
|
const generalId = this.findGeneralInstance();
|
|
3894
4094
|
if (generalId) {
|
|
3895
|
-
this.notifyInstanceTopic(generalId,
|
|
4095
|
+
this.notifyInstanceTopic(generalId, t("update.available", `v${target}`) + ` (current: v${currentVersion})`);
|
|
3896
4096
|
}
|
|
3897
4097
|
}
|
|
3898
4098
|
}
|
|
@@ -3955,6 +4155,15 @@ When users create specialized instances, suggest these configurations:
|
|
|
3955
4155
|
catch {
|
|
3956
4156
|
// best-effort
|
|
3957
4157
|
}
|
|
4158
|
+
// Separate read-only token for the /view page: grants terminal-view + profile
|
|
4159
|
+
// read, but never write (POSTs still require the full web token).
|
|
4160
|
+
this.viewToken = randomBytes(24).toString("hex");
|
|
4161
|
+
const viewTokenPath = join(this.dataDir, "view.token");
|
|
4162
|
+
writeFileSync(viewTokenPath, this.viewToken, { mode: 0o600 });
|
|
4163
|
+
try {
|
|
4164
|
+
chmodSync(viewTokenPath, 0o600);
|
|
4165
|
+
}
|
|
4166
|
+
catch { /* best-effort */ }
|
|
3958
4167
|
this.healthServer = createServer((req, res) => {
|
|
3959
4168
|
res.setHeader("Content-Type", "application/json");
|
|
3960
4169
|
// Public health probe — no auth required.
|
|
@@ -3964,6 +4173,10 @@ When users create specialized instances, suggest these configurations:
|
|
|
3964
4173
|
else if (req.method === "POST" && req.url === "/agent") {
|
|
3965
4174
|
// /agent handles its own instance-level auth via X-Agend-Instance-Token
|
|
3966
4175
|
}
|
|
4176
|
+
else if (isViewPath(new URL(req.url ?? "/", `http://localhost:${port}`).pathname)) {
|
|
4177
|
+
// /view routes accept the read-only view.token (or web.token) and do
|
|
4178
|
+
// their own per-method auth in view-api.ts — skip the web-token gate.
|
|
4179
|
+
}
|
|
3967
4180
|
else {
|
|
3968
4181
|
// All other endpoints require a valid token (query ?token= or X-Agend-Token header).
|
|
3969
4182
|
// /ui/* will also re-check in web-api.ts, which is harmless.
|
|
@@ -4147,6 +4360,10 @@ When users create specialized instances, suggest these configurations:
|
|
|
4147
4360
|
}
|
|
4148
4361
|
// ── Web UI endpoints (delegated to web-api.ts) ─────
|
|
4149
4362
|
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
4363
|
+
if (handleViewRequest(req, res, url, this))
|
|
4364
|
+
return;
|
|
4365
|
+
if (handleSettingsRequest(req, res, url, this))
|
|
4366
|
+
return;
|
|
4150
4367
|
if (handleWebRequest(req, res, url, this))
|
|
4151
4368
|
return;
|
|
4152
4369
|
res.writeHead(404);
|
|
@@ -4188,6 +4405,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
4188
4405
|
this.logger.info({ port }, "Health endpoint listening");
|
|
4189
4406
|
});
|
|
4190
4407
|
this.logger.info({ url: `http://localhost:${port}/ui?token=${this.webToken}` }, "Web UI available");
|
|
4408
|
+
this.logger.info({ url: `http://localhost:${port}/view?token=${this.viewToken}` }, "Web View available");
|
|
4191
4409
|
}
|
|
4192
4410
|
getUiStatus() {
|
|
4193
4411
|
const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => {
|