@songsid/agend 2.0.11-beta.4 → 2.0.11-beta.40
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 +45 -36
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.js +1 -0
- package/dist/channel/adapters/telegram.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/channel/types.d.ts +2 -0
- 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 -6
- package/dist/fleet-manager.js +455 -258
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +1 -1
- package/dist/instance-lifecycle.js +15 -13
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.d.ts +19 -0
- package/dist/locale.js +179 -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.js +8 -2
- 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 +33 -0
- package/dist/settings-api.js +283 -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 +617 -0
- package/dist/ui/view.html +295 -39
- package/dist/view-api.d.ts +9 -0
- package/dist/view-api.js +101 -40
- package/dist/view-api.js.map +1 -1
- 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";
|
|
@@ -34,8 +36,10 @@ import { StatuslineWatcher } from "./statusline-watcher.js";
|
|
|
34
36
|
import { outboundHandlers } from "./outbound-handlers.js";
|
|
35
37
|
import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
|
|
36
38
|
import { handleViewRequest, isViewPath } from "./view-api.js";
|
|
39
|
+
import { handleSettingsRequest } from "./settings-api.js";
|
|
40
|
+
import { setLocale, detectLocale, t } from "./locale.js";
|
|
37
41
|
import { handleAgentRequest } from "./agent-endpoint.js";
|
|
38
|
-
import { ClassicChannelManager
|
|
42
|
+
import { ClassicChannelManager } from "./classic-channel-manager.js";
|
|
39
43
|
import { getTmuxSession } from "./config.js";
|
|
40
44
|
export function resolveReplyThreadId(argsThreadId, instanceConfig) {
|
|
41
45
|
if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
|
|
@@ -66,6 +70,9 @@ export class FleetManager {
|
|
|
66
70
|
adapters = new Map(); // derived view for backward compat
|
|
67
71
|
/** Track which world each instance is bound to */
|
|
68
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();
|
|
69
76
|
accessManager = null;
|
|
70
77
|
/** Primary world (first adapter) — used for fleet-level notifications */
|
|
71
78
|
get primaryWorld() { return this.worlds.values().next().value; }
|
|
@@ -176,27 +183,51 @@ export class FleetManager {
|
|
|
176
183
|
}
|
|
177
184
|
return this.routing.map;
|
|
178
185
|
}
|
|
179
|
-
/**
|
|
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
|
+
*/
|
|
180
194
|
reregisterClassicChannels() {
|
|
181
195
|
if (!this.classicChannels)
|
|
182
196
|
return;
|
|
183
197
|
const channels = this.classicChannels.getAll();
|
|
184
|
-
for (const ch of channels) {
|
|
185
|
-
this.routing.register(ch.channelId, { kind: "classic", name: ch.instanceName });
|
|
186
|
-
}
|
|
187
198
|
// Always update adapter openChannels (including empty — clears stale entries on /stop)
|
|
188
|
-
for (const [, w] of this.worlds) {
|
|
199
|
+
for (const [adapterId, w] of this.worlds) {
|
|
189
200
|
if (typeof w.adapter?.setOpenChannels === "function") {
|
|
190
|
-
|
|
201
|
+
const owned = channels.filter(ch => ch.adapterId === adapterId).map(ch => ch.channelId);
|
|
202
|
+
w.adapter.setOpenChannels(owned);
|
|
191
203
|
}
|
|
192
204
|
}
|
|
193
205
|
if (channels.length > 0) {
|
|
194
|
-
this.logger.info({ count: channels.length }, "
|
|
206
|
+
this.logger.info({ count: channels.length }, "Refreshed classic channel open-lists");
|
|
195
207
|
}
|
|
196
208
|
}
|
|
197
209
|
getInstanceDir(name) {
|
|
198
210
|
return join(this.dataDir, "instances", name);
|
|
199
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
|
+
}
|
|
200
231
|
/** Get the adapter bound to an instance, falling back to primary adapter */
|
|
201
232
|
getAdapterForInstance(name) {
|
|
202
233
|
const worldId = this.instanceWorldBinding.get(name);
|
|
@@ -223,9 +254,22 @@ export class FleetManager {
|
|
|
223
254
|
const world = this.getWorldForInstance(name);
|
|
224
255
|
return world?.groupId ?? String(this.fleetConfig?.channel?.group_id ?? "");
|
|
225
256
|
}
|
|
226
|
-
/**
|
|
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
|
+
*/
|
|
227
264
|
bindInstanceAdapter(name, adapterId, fromInbound = false) {
|
|
228
|
-
|
|
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))
|
|
229
273
|
return;
|
|
230
274
|
this.instanceWorldBinding.set(name, adapterId);
|
|
231
275
|
}
|
|
@@ -354,6 +398,7 @@ export class FleetManager {
|
|
|
354
398
|
const { rotateLogIfNeeded } = await import("./logger.js");
|
|
355
399
|
rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
|
|
356
400
|
const fleet = this.loadConfig(configPath);
|
|
401
|
+
setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
|
|
357
402
|
const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
|
|
358
403
|
// Set tmux socket isolation for custom AGEND_HOME
|
|
359
404
|
const { getTmuxSocketName: getSocket } = await import("./paths.js");
|
|
@@ -395,10 +440,19 @@ export class FleetManager {
|
|
|
395
440
|
const pidPath = join(this.dataDir, "fleet.pid");
|
|
396
441
|
writeFileSync(pidPath, String(process.pid), "utf-8");
|
|
397
442
|
this.eventLog = new EventLog(join(this.dataDir, "events.db"));
|
|
398
|
-
// 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.
|
|
399
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).
|
|
400
453
|
for (const ch of this.classicChannels.getAll()) {
|
|
401
|
-
|
|
454
|
+
if (ch.adapterId)
|
|
455
|
+
this.instanceWorldBinding.set(ch.instanceName, ch.adapterId);
|
|
402
456
|
}
|
|
403
457
|
// Poll classicBot.yaml for external changes every 30s
|
|
404
458
|
this.classicReloadTimer = setInterval(async () => {
|
|
@@ -406,21 +460,27 @@ export class FleetManager {
|
|
|
406
460
|
if (!this.classicChannels)
|
|
407
461
|
return;
|
|
408
462
|
const fleetBackend = this.fleetConfig?.defaults?.backend;
|
|
463
|
+
const fleetModel = this.fleetConfig?.defaults?.model;
|
|
409
464
|
const oldBackends = new Map();
|
|
465
|
+
const oldModels = new Map();
|
|
410
466
|
for (const ch of this.classicChannels.getAll()) {
|
|
411
467
|
oldBackends.set(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend));
|
|
468
|
+
oldModels.set(ch.instanceName, this.classicChannels.getModel(ch.channelId, ch.adapterId, fleetModel));
|
|
412
469
|
}
|
|
413
470
|
if (!this.classicChannels.checkReload())
|
|
414
471
|
return;
|
|
415
472
|
this.reregisterClassicChannels();
|
|
416
473
|
for (const ch of this.classicChannels.getAll()) {
|
|
417
474
|
const newBackend = this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend);
|
|
418
|
-
|
|
419
|
-
|
|
475
|
+
const newModel = this.classicChannels.getModel(ch.channelId, ch.adapterId, fleetModel);
|
|
476
|
+
const backendChanged = oldBackends.get(ch.instanceName) !== newBackend;
|
|
477
|
+
const modelChanged = oldModels.get(ch.instanceName) !== newModel;
|
|
478
|
+
if (this.daemons.has(ch.instanceName) && (backendChanged || modelChanged)) {
|
|
479
|
+
this.logger.info({ instanceName: ch.instanceName, backendFrom: oldBackends.get(ch.instanceName), backendTo: newBackend, modelFrom: oldModels.get(ch.instanceName), modelTo: newModel }, "Backend/model changed — restarting");
|
|
420
480
|
await this.stopInstance(ch.instanceName).catch(() => { });
|
|
421
481
|
// Small delay to let tmux window clean up
|
|
422
482
|
await new Promise(r => setTimeout(r, 2000));
|
|
423
|
-
await this.startClassicInstance(ch.instanceName, newBackend).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to restart classic instance"));
|
|
483
|
+
await this.startClassicInstance(ch.instanceName, newBackend, this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), newModel).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to restart classic instance"));
|
|
424
484
|
}
|
|
425
485
|
}
|
|
426
486
|
}
|
|
@@ -440,11 +500,11 @@ export class FleetManager {
|
|
|
440
500
|
this.logger.info({ count: webhookConfigs.length }, "Webhook emitter initialized");
|
|
441
501
|
}
|
|
442
502
|
this.costGuard.on("warn", safeHandler((instance, totalCents, limitCents) => {
|
|
443
|
-
this.notifyInstanceTopic(instance,
|
|
503
|
+
this.notifyInstanceTopic(instance, t("cost.approaching", instance, formatCents(totalCents), formatCents(limitCents), Math.round(totalCents / limitCents * 100)));
|
|
444
504
|
this.webhookEmitter?.emit("cost_warning", instance, { cost_cents: totalCents, limit_cents: limitCents });
|
|
445
505
|
}, this.logger, "costGuard.warn"));
|
|
446
506
|
this.costGuard.on("limit", safeHandler(async (instance, totalCents, limitCents) => {
|
|
447
|
-
this.notifyInstanceTopic(instance,
|
|
507
|
+
this.notifyInstanceTopic(instance, t("cost.limit_reached", instance, formatCents(limitCents)));
|
|
448
508
|
this.eventLog?.insert(instance, "instance_paused", { reason: "cost_limit", cost_cents: totalCents });
|
|
449
509
|
this.webhookEmitter?.emit("cost_limit", instance, { cost_cents: totalCents, limit_cents: limitCents });
|
|
450
510
|
await this.stopInstance(instance);
|
|
@@ -476,8 +536,9 @@ export class FleetManager {
|
|
|
476
536
|
// Rotate classic channel chat logs daily (piggyback on daily summary timer)
|
|
477
537
|
this.classicChannels?.rotateLogs();
|
|
478
538
|
this.rotateInboxes();
|
|
479
|
-
// Auto-create general
|
|
539
|
+
// Auto-create/adopt a general dispatcher — ONLY for the primary adapter.
|
|
480
540
|
const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
|
|
541
|
+
const primaryAdapterId = channelConfigs[0] ? (channelConfigs[0].id ?? channelConfigs[0].type) : undefined;
|
|
481
542
|
const generalInstances = Object.entries(fleet.instances).filter(([, inst]) => inst.general_topic === true);
|
|
482
543
|
let generalsCreated = false;
|
|
483
544
|
// Collect unbound generals (no channel_id set) for auto-assignment
|
|
@@ -486,6 +547,14 @@ export class FleetManager {
|
|
|
486
547
|
const needsGeneral = [];
|
|
487
548
|
for (const ch of channelConfigs) {
|
|
488
549
|
const adapterId = ch.id ?? ch.type;
|
|
550
|
+
// Only the primary adapter gets an auto-general. Secondary (persona) bots
|
|
551
|
+
// answer for their explicitly-bound instances only — they don't need or
|
|
552
|
+
// auto-claim a general dispatcher, and must never adopt the primary's
|
|
553
|
+
// unbound general. A general a user manually bound to a secondary
|
|
554
|
+
// (channel_id: <persona>) is left untouched — the auto logic just won't
|
|
555
|
+
// create or reassign bindings for non-primary adapters.
|
|
556
|
+
if (adapterId !== primaryAdapterId)
|
|
557
|
+
continue;
|
|
489
558
|
// Check if any general is explicitly bound to this adapter
|
|
490
559
|
if (generalInstances.some(([, inst]) => inst.channel_id === adapterId))
|
|
491
560
|
continue;
|
|
@@ -588,7 +657,7 @@ export class FleetManager {
|
|
|
588
657
|
if (this.adapter && topicId) {
|
|
589
658
|
const chatId = this.adapter.getChatId?.() ?? "";
|
|
590
659
|
if (chatId) {
|
|
591
|
-
this.adapter.sendText(chatId,
|
|
660
|
+
this.adapter.sendText(chatId, t("general.start_failed", name, errorMsg), { threadId: topicId }).catch(() => { });
|
|
592
661
|
}
|
|
593
662
|
}
|
|
594
663
|
}
|
|
@@ -608,12 +677,19 @@ export class FleetManager {
|
|
|
608
677
|
catch (err) {
|
|
609
678
|
this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
|
|
610
679
|
}
|
|
611
|
-
//
|
|
680
|
+
// Bind instances to their adapter (which bot answers on their behalf).
|
|
681
|
+
// An explicit channel_id is authoritative — this is how a persona instance
|
|
682
|
+
// picks its bot when several share one guild. Generals without a channel_id
|
|
683
|
+
// fall back to a name-contains-adapterId heuristic.
|
|
684
|
+
const channelConfigsForBind = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
|
|
612
685
|
for (const [name, config] of Object.entries(fleet.instances)) {
|
|
686
|
+
if (config.channel_id) {
|
|
687
|
+
this.bindInstanceAdapter(name, config.channel_id);
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
613
690
|
if (!config.general_topic)
|
|
614
691
|
continue;
|
|
615
|
-
|
|
616
|
-
for (const ch of channelConfigs) {
|
|
692
|
+
for (const ch of channelConfigsForBind) {
|
|
617
693
|
const id = ch.id ?? ch.type;
|
|
618
694
|
if (name.includes(id)) {
|
|
619
695
|
this.bindInstanceAdapter(name, id);
|
|
@@ -621,6 +697,23 @@ export class FleetManager {
|
|
|
621
697
|
}
|
|
622
698
|
}
|
|
623
699
|
}
|
|
700
|
+
// Guard against a stale/invalid general topic_id. An old auto-general
|
|
701
|
+
// could have written the TG-convention "1" for a Discord general; the DC
|
|
702
|
+
// adapter then throws fetching channel "1" → unhandled → fleet crash loop.
|
|
703
|
+
// Unbind (+ warn) so it's simply skipped, never routed to a bogus channel.
|
|
704
|
+
let fixedGeneral = false;
|
|
705
|
+
for (const [name, cfg] of Object.entries(this.fleetConfig.instances)) {
|
|
706
|
+
if (!cfg.general_topic || cfg.topic_id == null)
|
|
707
|
+
continue;
|
|
708
|
+
const adapterId = this.instanceWorldBinding.get(name) ?? cfg.channel_id;
|
|
709
|
+
if (this.getChannelConfig(adapterId)?.type === "discord" && !/^\d{17,}$/.test(String(cfg.topic_id))) {
|
|
710
|
+
this.logger.warn({ name, topic_id: cfg.topic_id }, "Discord general topic_id is not a valid channel — unbinding to avoid a crash loop");
|
|
711
|
+
delete cfg.topic_id;
|
|
712
|
+
fixedGeneral = true;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
if (fixedGeneral)
|
|
716
|
+
this.saveFleetConfig();
|
|
624
717
|
// Auto-create topics AFTER adapter is ready (needs adapter.createTopic)
|
|
625
718
|
await this.topicCommands.autoCreateTopics();
|
|
626
719
|
const routeSummary = this.routing.rebuild(this.fleetConfig);
|
|
@@ -640,7 +733,7 @@ export class FleetManager {
|
|
|
640
733
|
let idx = 0;
|
|
641
734
|
while (idx < channels.length) {
|
|
642
735
|
const batch = channels.slice(idx, idx + concurrency);
|
|
643
|
-
await Promise.allSettled(batch.map(ch => this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend)).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance"))));
|
|
736
|
+
await Promise.allSettled(batch.map(ch => this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend), this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model)).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance"))));
|
|
644
737
|
idx += concurrency;
|
|
645
738
|
}
|
|
646
739
|
}
|
|
@@ -659,8 +752,8 @@ export class FleetManager {
|
|
|
659
752
|
const agendVersion = _require("../package.json").version ?? "unknown";
|
|
660
753
|
if (this.adapter && fleet.channel?.group_id) {
|
|
661
754
|
const text = failedNames.length === 0
|
|
662
|
-
?
|
|
663
|
-
:
|
|
755
|
+
? t("fleet.ready", started, total, agendVersion)
|
|
756
|
+
: t("fleet.ready_with_failed", started, total, agendVersion, failedNames.join(", "));
|
|
664
757
|
this.adapter.sendText(String(fleet.channel.group_id), text, {
|
|
665
758
|
threadId: generalThreadId != null ? String(generalThreadId) : undefined,
|
|
666
759
|
}).catch(e => this.logger.warn({ err: e }, "Failed to send fleet start notification"));
|
|
@@ -743,9 +836,13 @@ export class FleetManager {
|
|
|
743
836
|
const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
|
|
744
837
|
if (channelConfigs.length === 0)
|
|
745
838
|
return;
|
|
746
|
-
// Start primary adapter (first channel) — this.adapter for backward compat
|
|
839
|
+
// Start primary adapter (first channel) — this.adapter for backward compat.
|
|
747
840
|
await this.startSingleAdapter(fleet, channelConfigs[0]);
|
|
748
|
-
// Start additional adapters
|
|
841
|
+
// Start additional adapters. Every bot registers its own slash commands —
|
|
842
|
+
// Discord slash commands are per-application, so a same-guild secondary bot's
|
|
843
|
+
// /start etc. are distinct entries (labelled with the bot name) and the
|
|
844
|
+
// interaction only reaches the invoked bot. This is how ClassicBot supports
|
|
845
|
+
// multiple bots in one guild: the user picks which bot from autocomplete.
|
|
749
846
|
for (let i = 1; i < channelConfigs.length; i++) {
|
|
750
847
|
await this.startAdditionalAdapter(channelConfigs[i]);
|
|
751
848
|
}
|
|
@@ -759,7 +856,7 @@ export class FleetManager {
|
|
|
759
856
|
}
|
|
760
857
|
const accessDir = join(this.dataDir, "access");
|
|
761
858
|
mkdirSync(accessDir, { recursive: true });
|
|
762
|
-
const accessManager = new AccessManager(channelConfig.access, join(accessDir, "access.json"));
|
|
859
|
+
const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, join(accessDir, "access.json"));
|
|
763
860
|
this.accessManager = accessManager;
|
|
764
861
|
const inboxDir = join(this.dataDir, "inbox");
|
|
765
862
|
mkdirSync(inboxDir, { recursive: true });
|
|
@@ -815,28 +912,28 @@ export class FleetManager {
|
|
|
815
912
|
// Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
|
|
816
913
|
this.adapter.on("slash_command", safeHandler(async (data) => {
|
|
817
914
|
if (data.command === "start") {
|
|
818
|
-
const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId);
|
|
915
|
+
const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId, adapterId);
|
|
819
916
|
await data.respond(reply);
|
|
820
917
|
}
|
|
821
918
|
else if (data.command === "stop") {
|
|
822
|
-
const reply = await this.handleClassicStop(data.channelId);
|
|
919
|
+
const reply = await this.handleClassicStop(data.channelId, adapterId);
|
|
823
920
|
await data.respond(reply);
|
|
824
921
|
}
|
|
825
922
|
else if (data.command === "chat") {
|
|
826
923
|
const text = data.text ?? "";
|
|
827
924
|
if (!text) {
|
|
828
|
-
await data.respond("
|
|
925
|
+
await data.respond(t("chat.usage"));
|
|
829
926
|
return;
|
|
830
927
|
}
|
|
831
|
-
const
|
|
832
|
-
if (!
|
|
833
|
-
await data.respond("
|
|
928
|
+
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
929
|
+
if (!name) {
|
|
930
|
+
await data.respond(t("classic.no_agent_start"));
|
|
834
931
|
return;
|
|
835
932
|
}
|
|
836
933
|
const replyMsgId = await data.respond("👀");
|
|
837
934
|
const username = data.username ?? data.userId;
|
|
838
|
-
ClassicChannelManager.logMessage(
|
|
839
|
-
await this.forwardToClassicInstance(
|
|
935
|
+
ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
|
|
936
|
+
await this.forwardToClassicInstance(name, text, {
|
|
840
937
|
chatId: data.channelId,
|
|
841
938
|
threadId: data.channelId,
|
|
842
939
|
messageId: replyMsgId ?? "",
|
|
@@ -847,86 +944,88 @@ export class FleetManager {
|
|
|
847
944
|
});
|
|
848
945
|
}
|
|
849
946
|
else if (data.command === "save") {
|
|
850
|
-
await this.handleSlashSave(data);
|
|
947
|
+
await this.handleSlashSave(data, adapterId);
|
|
851
948
|
}
|
|
852
949
|
else if (data.command === "load") {
|
|
853
950
|
// load is kiro-cli/classic only — no claude-code equivalent.
|
|
854
951
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
855
|
-
await data.respond("
|
|
952
|
+
await data.respond(t("admin.required"));
|
|
856
953
|
return;
|
|
857
954
|
}
|
|
858
|
-
const
|
|
859
|
-
if (!
|
|
860
|
-
await data.respond("
|
|
955
|
+
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
956
|
+
if (!name) {
|
|
957
|
+
await data.respond(t("classic.no_agent_start"));
|
|
861
958
|
return;
|
|
862
959
|
}
|
|
863
960
|
const filename = data.options?.filename;
|
|
864
961
|
if (!SAVE_FILENAME_RE.test(filename ?? "")) {
|
|
865
|
-
await data.respond("
|
|
962
|
+
await data.respond(t("filename.invalid"));
|
|
866
963
|
return;
|
|
867
964
|
}
|
|
868
|
-
this.pasteRawToClassicInstance(
|
|
869
|
-
await data.respond(
|
|
965
|
+
this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
|
|
966
|
+
await data.respond(t("save.sent", `/chat load ${filename}`, name));
|
|
870
967
|
}
|
|
871
968
|
else if (data.command === "compact") {
|
|
872
|
-
const
|
|
873
|
-
if (!
|
|
874
|
-
await data.respond("
|
|
969
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
970
|
+
if (!name) {
|
|
971
|
+
await data.respond(t("classic.no_agent"));
|
|
875
972
|
return;
|
|
876
973
|
}
|
|
877
|
-
const result = await this.topicCommands.sendCompact(
|
|
974
|
+
const result = await this.topicCommands.sendCompact(name);
|
|
878
975
|
await data.respond(result);
|
|
879
976
|
}
|
|
880
977
|
else if (data.command === "cancel") {
|
|
881
|
-
const
|
|
882
|
-
if (!
|
|
883
|
-
await data.respond("
|
|
978
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
979
|
+
if (!name) {
|
|
980
|
+
await data.respond(t("classic.no_agent"));
|
|
884
981
|
return;
|
|
885
982
|
}
|
|
886
|
-
const ok = this.cancelInstance(
|
|
887
|
-
await data.respond(ok ?
|
|
983
|
+
const ok = this.cancelInstance(name);
|
|
984
|
+
await data.respond(ok ? t("cancel.sent", name) : t("cancel.not_running", name));
|
|
888
985
|
}
|
|
889
986
|
else if (data.command === "ctx") {
|
|
890
|
-
const
|
|
891
|
-
if (!
|
|
892
|
-
await data.respond("
|
|
987
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
988
|
+
if (!name) {
|
|
989
|
+
await data.respond(t("classic.no_agent"));
|
|
893
990
|
return;
|
|
894
991
|
}
|
|
895
992
|
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
896
|
-
await data.respond(await this.topicCommands.getCtxText(
|
|
993
|
+
await data.respond(await this.topicCommands.getCtxText(name));
|
|
897
994
|
}
|
|
898
995
|
else if (data.command === "collab") {
|
|
996
|
+
// Classic no longer lives in the routing engine, so a routing hit here is
|
|
997
|
+
// always a fleet-topic instance.
|
|
899
998
|
const collabTarget = this.routing.resolve(data.channelId);
|
|
900
|
-
if (collabTarget
|
|
999
|
+
if (collabTarget) {
|
|
901
1000
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
902
1001
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
903
|
-
await data.respond("
|
|
1002
|
+
await data.respond(t("not_authorized"));
|
|
904
1003
|
return;
|
|
905
1004
|
}
|
|
906
1005
|
const isCollab = this.toggleFleetCollab(collabTarget.name);
|
|
907
|
-
await data.respond(isCollab ? "
|
|
1006
|
+
await data.respond(isCollab ? t("collab.on") : t("collab.off"));
|
|
908
1007
|
return;
|
|
909
1008
|
}
|
|
910
1009
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
911
|
-
await data.respond("
|
|
1010
|
+
await data.respond(t("admin.required"));
|
|
912
1011
|
return;
|
|
913
1012
|
}
|
|
914
|
-
if (!this.classicChannels.isClassicChannel(data.channelId)) {
|
|
915
|
-
await data.respond("
|
|
1013
|
+
if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
|
|
1014
|
+
await data.respond(t("classic.no_agent_start"));
|
|
916
1015
|
return;
|
|
917
1016
|
}
|
|
918
|
-
const newState = this.classicChannels.toggleCollab(data.channelId);
|
|
1017
|
+
const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
|
|
919
1018
|
await data.respond(newState
|
|
920
|
-
? "
|
|
921
|
-
: "
|
|
1019
|
+
? t("collab.on.classic")
|
|
1020
|
+
: t("collab.off.classic"));
|
|
922
1021
|
}
|
|
923
1022
|
else if (data.command === "update") {
|
|
924
1023
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
925
1024
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
926
|
-
await data.respond("
|
|
1025
|
+
await data.respond(t("not_authorized"));
|
|
927
1026
|
return;
|
|
928
1027
|
}
|
|
929
|
-
await data.respond("
|
|
1028
|
+
await data.respond(t("update.running"));
|
|
930
1029
|
const { spawn } = await import("node:child_process");
|
|
931
1030
|
const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
|
|
932
1031
|
const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
|
|
@@ -936,7 +1035,7 @@ export class FleetManager {
|
|
|
936
1035
|
else if (data.command === "doctor") {
|
|
937
1036
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
938
1037
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
939
|
-
await data.respond("
|
|
1038
|
+
await data.respond(t("not_authorized"));
|
|
940
1039
|
return;
|
|
941
1040
|
}
|
|
942
1041
|
try {
|
|
@@ -958,22 +1057,36 @@ export class FleetManager {
|
|
|
958
1057
|
else if (data.command === "sysinfo") {
|
|
959
1058
|
await data.respond(this.topicCommands.getSysInfoText());
|
|
960
1059
|
}
|
|
1060
|
+
else if (data.command === "dashboard") {
|
|
1061
|
+
// Reply is ephemeral (adapter defers non-chat commands ephemerally), so
|
|
1062
|
+
// the web-token-bearing URLs are only visible to the caller.
|
|
1063
|
+
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
1064
|
+
if (allowed.length === 0) {
|
|
1065
|
+
await data.respond(t("dashboard.disabled"));
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
if (!allowed.some(u => String(u) === String(data.userId))) {
|
|
1069
|
+
await data.respond(t("not_authorized"));
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
await data.respond(this.topicCommands.getDashboardText());
|
|
1073
|
+
}
|
|
961
1074
|
else if (data.command === "restart") {
|
|
962
1075
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
963
1076
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
964
|
-
await data.respond("
|
|
1077
|
+
await data.respond(t("not_authorized"));
|
|
965
1078
|
return;
|
|
966
1079
|
}
|
|
967
|
-
await data.respond("
|
|
1080
|
+
await data.respond(t("restart.graceful"));
|
|
968
1081
|
process.kill(process.pid, "SIGUSR2");
|
|
969
1082
|
}
|
|
970
1083
|
else if (data.command === "compact") {
|
|
971
|
-
const
|
|
972
|
-
if (!
|
|
973
|
-
await data.respond("
|
|
1084
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
1085
|
+
if (!name) {
|
|
1086
|
+
await data.respond(t("classic.no_agent"));
|
|
974
1087
|
return;
|
|
975
1088
|
}
|
|
976
|
-
const result = await this.topicCommands.sendCompact(
|
|
1089
|
+
const result = await this.topicCommands.sendCompact(name);
|
|
977
1090
|
await data.respond(result);
|
|
978
1091
|
}
|
|
979
1092
|
}, this.logger, "adapter.slash_command"));
|
|
@@ -1000,7 +1113,7 @@ export class FleetManager {
|
|
|
1000
1113
|
this.restartAdapter(this.adapter, "primary").catch(() => { });
|
|
1001
1114
|
});
|
|
1002
1115
|
this.adapter.on("new_group_detected", safeHandler((data) => {
|
|
1003
|
-
const adminMsg =
|
|
1116
|
+
const adminMsg = t("alert.bot_added", data.groupTitle, data.groupId, data.source);
|
|
1004
1117
|
const generalId = this.findGeneralInstance();
|
|
1005
1118
|
if (generalId)
|
|
1006
1119
|
this.notifyInstanceTopic(generalId, adminMsg);
|
|
@@ -1017,7 +1130,7 @@ export class FleetManager {
|
|
|
1017
1130
|
}, 5 * 60 * 1000);
|
|
1018
1131
|
}
|
|
1019
1132
|
/** Start an additional (non-primary) adapter */
|
|
1020
|
-
async startAdditionalAdapter(channelConfig) {
|
|
1133
|
+
async startAdditionalAdapter(channelConfig, registerCommands = true) {
|
|
1021
1134
|
const adapterId = channelConfig.id ?? channelConfig.type;
|
|
1022
1135
|
const botToken = process.env[channelConfig.bot_token_env];
|
|
1023
1136
|
if (!botToken) {
|
|
@@ -1026,7 +1139,7 @@ export class FleetManager {
|
|
|
1026
1139
|
}
|
|
1027
1140
|
const accessDir = join(this.dataDir, "access");
|
|
1028
1141
|
mkdirSync(accessDir, { recursive: true });
|
|
1029
|
-
const accessManager = new AccessManager(channelConfig.access, join(accessDir, `access-${adapterId}.json`));
|
|
1142
|
+
const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, join(accessDir, `access-${adapterId}.json`));
|
|
1030
1143
|
const inboxDir = join(this.dataDir, "inbox");
|
|
1031
1144
|
mkdirSync(inboxDir, { recursive: true });
|
|
1032
1145
|
const adapter = await createAdapter(channelConfig, {
|
|
@@ -1034,6 +1147,7 @@ export class FleetManager {
|
|
|
1034
1147
|
botToken,
|
|
1035
1148
|
accessManager,
|
|
1036
1149
|
inboxDir,
|
|
1150
|
+
registerCommands,
|
|
1037
1151
|
});
|
|
1038
1152
|
const world = new AdapterWorld(adapterId, adapter, accessManager, channelConfig);
|
|
1039
1153
|
this.worlds.set(adapterId, world);
|
|
@@ -1077,28 +1191,28 @@ export class FleetManager {
|
|
|
1077
1191
|
// Slash commands: classic bot + admin commands
|
|
1078
1192
|
adapter.on("slash_command", safeHandler(async (data) => {
|
|
1079
1193
|
if (data.command === "start") {
|
|
1080
|
-
const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId);
|
|
1194
|
+
const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId, adapterId);
|
|
1081
1195
|
await data.respond(reply);
|
|
1082
1196
|
}
|
|
1083
1197
|
else if (data.command === "stop") {
|
|
1084
|
-
const reply = await this.handleClassicStop(data.channelId);
|
|
1198
|
+
const reply = await this.handleClassicStop(data.channelId, adapterId);
|
|
1085
1199
|
await data.respond(reply);
|
|
1086
1200
|
}
|
|
1087
1201
|
else if (data.command === "chat") {
|
|
1088
1202
|
const text = data.text ?? "";
|
|
1089
1203
|
if (!text) {
|
|
1090
|
-
await data.respond("
|
|
1204
|
+
await data.respond(t("chat.usage"));
|
|
1091
1205
|
return;
|
|
1092
1206
|
}
|
|
1093
|
-
const
|
|
1094
|
-
if (!
|
|
1095
|
-
await data.respond("
|
|
1207
|
+
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
1208
|
+
if (!name) {
|
|
1209
|
+
await data.respond(t("classic.no_agent_start"));
|
|
1096
1210
|
return;
|
|
1097
1211
|
}
|
|
1098
1212
|
const replyMsgId = await data.respond("👀");
|
|
1099
1213
|
const username = data.username ?? data.userId;
|
|
1100
|
-
ClassicChannelManager.logMessage(
|
|
1101
|
-
await this.forwardToClassicInstance(
|
|
1214
|
+
ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
|
|
1215
|
+
await this.forwardToClassicInstance(name, text, {
|
|
1102
1216
|
chatId: data.channelId,
|
|
1103
1217
|
threadId: data.channelId,
|
|
1104
1218
|
messageId: replyMsgId ?? "",
|
|
@@ -1109,86 +1223,79 @@ export class FleetManager {
|
|
|
1109
1223
|
});
|
|
1110
1224
|
}
|
|
1111
1225
|
else if (data.command === "save") {
|
|
1112
|
-
await this.handleSlashSave(data);
|
|
1226
|
+
await this.handleSlashSave(data, adapterId);
|
|
1113
1227
|
}
|
|
1114
1228
|
else if (data.command === "load") {
|
|
1115
1229
|
// load is kiro-cli/classic only — no claude-code equivalent.
|
|
1116
1230
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
1117
|
-
await data.respond("
|
|
1231
|
+
await data.respond(t("admin.required"));
|
|
1118
1232
|
return;
|
|
1119
1233
|
}
|
|
1120
|
-
const
|
|
1121
|
-
if (!
|
|
1122
|
-
await data.respond("
|
|
1234
|
+
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
1235
|
+
if (!name) {
|
|
1236
|
+
await data.respond(t("classic.no_agent_start"));
|
|
1123
1237
|
return;
|
|
1124
1238
|
}
|
|
1125
1239
|
const filename = data.options?.filename;
|
|
1126
1240
|
if (!SAVE_FILENAME_RE.test(filename ?? "")) {
|
|
1127
|
-
await data.respond("
|
|
1241
|
+
await data.respond(t("filename.invalid"));
|
|
1128
1242
|
return;
|
|
1129
1243
|
}
|
|
1130
|
-
this.pasteRawToClassicInstance(
|
|
1131
|
-
await data.respond(
|
|
1132
|
-
}
|
|
1133
|
-
else if (data.command === "compact") {
|
|
1134
|
-
const target = this.routing.resolve(data.channelId);
|
|
1135
|
-
if (!target) {
|
|
1136
|
-
await data.respond("No active agent in this channel.");
|
|
1137
|
-
return;
|
|
1138
|
-
}
|
|
1139
|
-
const result = await this.topicCommands.sendCompact(target.name);
|
|
1140
|
-
await data.respond(result);
|
|
1244
|
+
this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
|
|
1245
|
+
await data.respond(t("save.sent", `/chat load ${filename}`, name));
|
|
1141
1246
|
}
|
|
1142
1247
|
else if (data.command === "cancel") {
|
|
1143
|
-
const
|
|
1144
|
-
if (!
|
|
1145
|
-
await data.respond("
|
|
1248
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
1249
|
+
if (!name) {
|
|
1250
|
+
await data.respond(t("classic.no_agent"));
|
|
1146
1251
|
return;
|
|
1147
1252
|
}
|
|
1148
|
-
const ok = this.cancelInstance(
|
|
1149
|
-
await data.respond(ok ?
|
|
1253
|
+
const ok = this.cancelInstance(name);
|
|
1254
|
+
await data.respond(ok ? t("cancel.sent", name) : t("cancel.not_running", name));
|
|
1150
1255
|
}
|
|
1151
1256
|
else if (data.command === "ctx") {
|
|
1152
|
-
const
|
|
1153
|
-
if (!
|
|
1154
|
-
await data.respond("
|
|
1257
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
1258
|
+
if (!name) {
|
|
1259
|
+
await data.respond(t("classic.no_agent"));
|
|
1155
1260
|
return;
|
|
1156
1261
|
}
|
|
1157
1262
|
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
1158
|
-
await data.respond(await this.topicCommands.getCtxText(
|
|
1263
|
+
await data.respond(await this.topicCommands.getCtxText(name));
|
|
1159
1264
|
}
|
|
1160
1265
|
else if (data.command === "collab") {
|
|
1266
|
+
// Classic no longer lives in the routing engine, so a routing hit here is
|
|
1267
|
+
// always a fleet-topic instance.
|
|
1161
1268
|
const collabTarget2 = this.routing.resolve(data.channelId);
|
|
1162
|
-
if (collabTarget2
|
|
1269
|
+
if (collabTarget2) {
|
|
1163
1270
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
1164
1271
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
1165
|
-
await data.respond("
|
|
1272
|
+
await data.respond(t("not_authorized"));
|
|
1166
1273
|
return;
|
|
1167
1274
|
}
|
|
1168
1275
|
const isCollab = this.toggleFleetCollab(collabTarget2.name);
|
|
1169
|
-
await data.respond(isCollab ? "
|
|
1276
|
+
await data.respond(isCollab ? t("collab.on") : t("collab.off"));
|
|
1170
1277
|
return;
|
|
1171
1278
|
}
|
|
1172
1279
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
1173
|
-
await data.respond("
|
|
1280
|
+
await data.respond(t("admin.required"));
|
|
1174
1281
|
return;
|
|
1175
1282
|
}
|
|
1176
|
-
if (!this.classicChannels.isClassicChannel(data.channelId)) {
|
|
1177
|
-
await data.respond("
|
|
1283
|
+
if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
|
|
1284
|
+
await data.respond(t("classic.no_agent_start"));
|
|
1178
1285
|
return;
|
|
1179
1286
|
}
|
|
1180
|
-
const newState = this.classicChannels.toggleCollab(data.channelId);
|
|
1287
|
+
const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
|
|
1181
1288
|
await data.respond(newState
|
|
1182
|
-
? "
|
|
1183
|
-
: "
|
|
1289
|
+
? t("collab.on.classic")
|
|
1290
|
+
: t("collab.off.classic"));
|
|
1184
1291
|
}
|
|
1185
1292
|
else if (data.command === "update") {
|
|
1186
1293
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
1187
1294
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
1188
|
-
await data.respond("
|
|
1295
|
+
await data.respond(t("not_authorized"));
|
|
1189
1296
|
return;
|
|
1190
1297
|
}
|
|
1191
|
-
await data.respond("
|
|
1298
|
+
await data.respond(t("update.running"));
|
|
1192
1299
|
const { spawn } = await import("node:child_process");
|
|
1193
1300
|
const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
|
|
1194
1301
|
const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
|
|
@@ -1198,7 +1305,7 @@ export class FleetManager {
|
|
|
1198
1305
|
else if (data.command === "doctor") {
|
|
1199
1306
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
1200
1307
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
1201
|
-
await data.respond("
|
|
1308
|
+
await data.respond(t("not_authorized"));
|
|
1202
1309
|
return;
|
|
1203
1310
|
}
|
|
1204
1311
|
try {
|
|
@@ -1220,22 +1327,36 @@ export class FleetManager {
|
|
|
1220
1327
|
else if (data.command === "sysinfo") {
|
|
1221
1328
|
await data.respond(this.topicCommands.getSysInfoText());
|
|
1222
1329
|
}
|
|
1330
|
+
else if (data.command === "dashboard") {
|
|
1331
|
+
// Reply is ephemeral (adapter defers non-chat commands ephemerally), so
|
|
1332
|
+
// the web-token-bearing URLs are only visible to the caller.
|
|
1333
|
+
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
1334
|
+
if (allowed.length === 0) {
|
|
1335
|
+
await data.respond(t("dashboard.disabled"));
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
if (!allowed.some(u => String(u) === String(data.userId))) {
|
|
1339
|
+
await data.respond(t("not_authorized"));
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
await data.respond(this.topicCommands.getDashboardText());
|
|
1343
|
+
}
|
|
1223
1344
|
else if (data.command === "restart") {
|
|
1224
1345
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
1225
1346
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
1226
|
-
await data.respond("
|
|
1347
|
+
await data.respond(t("not_authorized"));
|
|
1227
1348
|
return;
|
|
1228
1349
|
}
|
|
1229
|
-
await data.respond("
|
|
1350
|
+
await data.respond(t("restart.graceful"));
|
|
1230
1351
|
process.kill(process.pid, "SIGUSR2");
|
|
1231
1352
|
}
|
|
1232
1353
|
else if (data.command === "compact") {
|
|
1233
|
-
const
|
|
1234
|
-
if (!
|
|
1235
|
-
await data.respond("
|
|
1354
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
1355
|
+
if (!name) {
|
|
1356
|
+
await data.respond(t("classic.no_agent"));
|
|
1236
1357
|
return;
|
|
1237
1358
|
}
|
|
1238
|
-
const result = await this.topicCommands.sendCompact(
|
|
1359
|
+
const result = await this.topicCommands.sendCompact(name);
|
|
1239
1360
|
await data.respond(result);
|
|
1240
1361
|
}
|
|
1241
1362
|
}, this.logger, `adapter[${adapterId}].slash_command`));
|
|
@@ -1253,7 +1374,7 @@ export class FleetManager {
|
|
|
1253
1374
|
}
|
|
1254
1375
|
}, this.logger, `adapter[${adapterId}].started`));
|
|
1255
1376
|
adapter.on("new_group_detected", safeHandler((data) => {
|
|
1256
|
-
const adminMsg =
|
|
1377
|
+
const adminMsg = t("alert.bot_added", data.groupTitle, data.groupId, data.source);
|
|
1257
1378
|
const generalId = this.findGeneralInstance(adapterId);
|
|
1258
1379
|
if (generalId)
|
|
1259
1380
|
this.notifyInstanceTopic(generalId, adminMsg);
|
|
@@ -1453,6 +1574,34 @@ export class FleetManager {
|
|
|
1453
1574
|
async handleInboundMessage(msg) {
|
|
1454
1575
|
const threadId = msg.threadId || undefined;
|
|
1455
1576
|
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");
|
|
1577
|
+
// Multi-adapter dedup: when several bots share a guild, each adapter fires
|
|
1578
|
+
// its own "message" event for the same underlying message. Process it once.
|
|
1579
|
+
// Routing (by topic/channel) and reply-adapter selection (by channel_id
|
|
1580
|
+
// binding) are adapter-independent, so it's safe to let whichever adapter
|
|
1581
|
+
// arrives first handle it.
|
|
1582
|
+
//
|
|
1583
|
+
// EXCEPTION — classic channels with same-channel multi-bot: two bots may own
|
|
1584
|
+
// separate agents in one channel, so each bot must process its OWN copy of
|
|
1585
|
+
// the message (the @mention filter downstream decides who actually forwards).
|
|
1586
|
+
// Key the dedup per-adapter there so a sibling bot's copy isn't dropped.
|
|
1587
|
+
if (msg.messageId) {
|
|
1588
|
+
const classicCid = msg.threadId || msg.chatId;
|
|
1589
|
+
const isClassicMsg = this.classicChannels?.hasChannel(classicCid) ?? false;
|
|
1590
|
+
const dedupKey = isClassicMsg
|
|
1591
|
+
? `${msg.source}:${msg.chatId}:${msg.messageId}:${msg.adapterId ?? ""}`
|
|
1592
|
+
: `${msg.source}:${msg.chatId}:${msg.messageId}`;
|
|
1593
|
+
if (this.recentMessageIds.has(dedupKey)) {
|
|
1594
|
+
this.logger.debug({ dedupKey, adapterId: msg.adapterId }, "Duplicate inbound across adapters — skipping");
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
this.recentMessageIds.add(dedupKey);
|
|
1598
|
+
if (this.recentMessageIds.size > 1000) {
|
|
1599
|
+
// Set preserves insertion order — drop the oldest key.
|
|
1600
|
+
const oldest = this.recentMessageIds.values().next().value;
|
|
1601
|
+
if (oldest !== undefined)
|
|
1602
|
+
this.recentMessageIds.delete(oldest);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1456
1605
|
// Bot messages: only allow in collab channels or TG classic with @mention
|
|
1457
1606
|
if (msg.isBotMessage) {
|
|
1458
1607
|
if (!threadId) {
|
|
@@ -1467,21 +1616,25 @@ export class FleetManager {
|
|
|
1467
1616
|
return;
|
|
1468
1617
|
// Fall through to TG classic handling below
|
|
1469
1618
|
}
|
|
1619
|
+
else if (this.classicChannels?.hasChannel(threadId)) {
|
|
1620
|
+
// Classic channel (per-bot): bot messages only when THIS bot owns an
|
|
1621
|
+
// agent here and collab is on for it.
|
|
1622
|
+
const classicName = this.classicChannels.getInstanceByChannel(threadId, msg.adapterId);
|
|
1623
|
+
if (!classicName)
|
|
1624
|
+
return;
|
|
1625
|
+
if (!this.classicChannels.isCollab(threadId, msg.adapterId))
|
|
1626
|
+
return;
|
|
1627
|
+
// Fall through to channel handling
|
|
1628
|
+
}
|
|
1470
1629
|
else {
|
|
1471
1630
|
const target = this.routing.resolve(threadId);
|
|
1472
1631
|
if (!target)
|
|
1473
1632
|
return;
|
|
1474
|
-
if
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
// Fleet topic: allow if collab enabled OR access mode is open
|
|
1480
|
-
const channelCfg = this.getChannelConfig(msg.adapterId);
|
|
1481
|
-
const isOpen = channelCfg?.access?.mode === "open";
|
|
1482
|
-
if (!isOpen && !this.collabInstances.has(target.name))
|
|
1483
|
-
return;
|
|
1484
|
-
}
|
|
1633
|
+
// Fleet topic: allow if collab enabled OR access mode is open
|
|
1634
|
+
const channelCfg = this.getChannelConfig(msg.adapterId);
|
|
1635
|
+
const isOpen = channelCfg?.access?.mode === "open";
|
|
1636
|
+
if (!isOpen && !this.collabInstances.has(target.name))
|
|
1637
|
+
return;
|
|
1485
1638
|
// Fall through to channel handling
|
|
1486
1639
|
}
|
|
1487
1640
|
}
|
|
@@ -1491,9 +1644,10 @@ export class FleetManager {
|
|
|
1491
1644
|
const adapterGroupId = String(this.getChannelConfig(msg.adapterId)?.group_id ?? "");
|
|
1492
1645
|
const isTelegramClassicCandidate = msg.source === "telegram" && msg.chatId !== adapterGroupId && !threadId;
|
|
1493
1646
|
if (!isTelegramClassicCandidate) {
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1647
|
+
// Classic channels are open to all; check per-bot ownership (or fleet topic).
|
|
1648
|
+
const isClassic = !!(threadId && this.classicChannels?.hasChannel(threadId));
|
|
1649
|
+
this.logger.info({ userId: msg.userId, threadId, isClassic }, "Access DENIED for non-allowed user");
|
|
1650
|
+
if (!isClassic)
|
|
1497
1651
|
return;
|
|
1498
1652
|
}
|
|
1499
1653
|
}
|
|
@@ -1538,9 +1692,9 @@ export class FleetManager {
|
|
|
1538
1692
|
if (!this.classicChannels.isUserAllowed(msg.userId)) {
|
|
1539
1693
|
const generalId = this.findGeneralInstance(msg.adapterId);
|
|
1540
1694
|
if (generalId) {
|
|
1541
|
-
this.notifyInstanceTopic(generalId,
|
|
1695
|
+
this.notifyInstanceTopic(generalId, t("alert.unauth_user_private", msg.username, msg.userId, msg.source));
|
|
1542
1696
|
}
|
|
1543
|
-
await msgAdapter?.sendText(chatId, "
|
|
1697
|
+
await msgAdapter?.sendText(chatId, t("classic.not_allowed_user"));
|
|
1544
1698
|
return;
|
|
1545
1699
|
}
|
|
1546
1700
|
}
|
|
@@ -1548,136 +1702,135 @@ export class FleetManager {
|
|
|
1548
1702
|
if (!this.classicChannels.isGroupAllowed(chatId)) {
|
|
1549
1703
|
// Notify admin about new group wanting access
|
|
1550
1704
|
const groupTitle = msg.chatTitle || chatId;
|
|
1551
|
-
const adminMsg =
|
|
1705
|
+
const adminMsg = t("alert.new_group", groupTitle, chatId, msg.username, msg.userId, msg.source);
|
|
1552
1706
|
const generalId = this.findGeneralInstance(msg.adapterId);
|
|
1553
1707
|
if (generalId) {
|
|
1554
1708
|
this.notifyInstanceTopic(generalId, adminMsg);
|
|
1555
1709
|
}
|
|
1556
|
-
await msgAdapter?.sendText(chatId, "
|
|
1710
|
+
await msgAdapter?.sendText(chatId, t("classic.access_requested"));
|
|
1557
1711
|
return;
|
|
1558
1712
|
}
|
|
1559
1713
|
if (!this.classicChannels.isAdmin(msg.userId)) {
|
|
1560
|
-
await msgAdapter?.sendText(chatId, "
|
|
1714
|
+
await msgAdapter?.sendText(chatId, t("classic.admin_only_start"));
|
|
1561
1715
|
const generalId = this.findGeneralInstance(msg.adapterId);
|
|
1562
1716
|
if (generalId) {
|
|
1563
|
-
this.notifyInstanceTopic(generalId,
|
|
1717
|
+
this.notifyInstanceTopic(generalId, t("alert.start_not_admin", msg.username, msg.userId, msg.source, chatId));
|
|
1564
1718
|
}
|
|
1565
1719
|
return;
|
|
1566
1720
|
}
|
|
1567
1721
|
}
|
|
1568
1722
|
const channelName = msg.username || chatId;
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
this.bindInstanceAdapter(classicInstanceName(sanitizeInstanceName(channelName || chatId), chatId), msg.adapterId, true);
|
|
1723
|
+
// handleClassicStart binds the instance to this adapter authoritatively.
|
|
1724
|
+
const reply = await this.handleClassicStart(chatId, channelName, msg.userId, undefined, msg.adapterId);
|
|
1572
1725
|
await msgAdapter?.sendText(chatId, reply);
|
|
1573
1726
|
return;
|
|
1574
1727
|
}
|
|
1575
1728
|
// Handle /stop command
|
|
1576
1729
|
if (text === "/stop" || text.startsWith("/stop ")) {
|
|
1577
1730
|
if (!this.classicChannels.isAdmin(msg.userId)) {
|
|
1578
|
-
await msgAdapter?.sendText(chatId, "
|
|
1731
|
+
await msgAdapter?.sendText(chatId, t("classic.admin_only_stop"));
|
|
1579
1732
|
const generalId = this.findGeneralInstance(msg.adapterId);
|
|
1580
1733
|
if (generalId) {
|
|
1581
|
-
this.notifyInstanceTopic(generalId,
|
|
1734
|
+
this.notifyInstanceTopic(generalId, t("alert.stop_not_admin", msg.username, msg.userId, msg.source, chatId));
|
|
1582
1735
|
}
|
|
1583
1736
|
return;
|
|
1584
1737
|
}
|
|
1585
|
-
const reply = await this.handleClassicStop(chatId);
|
|
1738
|
+
const reply = await this.handleClassicStop(chatId, msg.adapterId);
|
|
1586
1739
|
await msgAdapter?.sendText(chatId, reply);
|
|
1587
1740
|
return;
|
|
1588
1741
|
}
|
|
1589
1742
|
// Handle /compact command (admin only)
|
|
1590
1743
|
if (text === "/compact" || text.startsWith("/compact@")) {
|
|
1591
1744
|
if (!this.classicChannels.isAdmin(msg.userId)) {
|
|
1592
|
-
await msgAdapter?.sendText(chatId, "
|
|
1745
|
+
await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/compact"));
|
|
1593
1746
|
return;
|
|
1594
1747
|
}
|
|
1595
|
-
const
|
|
1596
|
-
if (!
|
|
1597
|
-
await msgAdapter?.sendText(chatId, "
|
|
1748
|
+
const compactName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
1749
|
+
if (!compactName) {
|
|
1750
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
1598
1751
|
return;
|
|
1599
1752
|
}
|
|
1600
|
-
const result = await this.topicCommands.sendCompact(
|
|
1753
|
+
const result = await this.topicCommands.sendCompact(compactName);
|
|
1601
1754
|
await msgAdapter?.sendText(chatId, result);
|
|
1602
1755
|
return;
|
|
1603
1756
|
}
|
|
1604
1757
|
// Handle /cancel command
|
|
1605
1758
|
if (text === "/cancel" || text.startsWith("/cancel@")) {
|
|
1606
|
-
const
|
|
1607
|
-
if (!
|
|
1608
|
-
await msgAdapter?.sendText(chatId, "
|
|
1759
|
+
const cancelName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
1760
|
+
if (!cancelName) {
|
|
1761
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
1609
1762
|
return;
|
|
1610
1763
|
}
|
|
1611
|
-
const ok = this.cancelInstance(
|
|
1612
|
-
await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${
|
|
1764
|
+
const ok = this.cancelInstance(cancelName);
|
|
1765
|
+
await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelName}。` : `❌ ${cancelName} 未在執行。`);
|
|
1613
1766
|
return;
|
|
1614
1767
|
}
|
|
1615
1768
|
// Handle /ctx command
|
|
1616
1769
|
if (text === "/ctx" || text.startsWith("/ctx@")) {
|
|
1617
|
-
const
|
|
1618
|
-
if (!
|
|
1619
|
-
await msgAdapter?.sendText(chatId, "
|
|
1770
|
+
const ctxName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
1771
|
+
if (!ctxName) {
|
|
1772
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
1620
1773
|
return;
|
|
1621
1774
|
}
|
|
1622
|
-
const reply = await this.topicCommands.getCtxText(
|
|
1775
|
+
const reply = await this.topicCommands.getCtxText(ctxName);
|
|
1623
1776
|
await msgAdapter?.sendText(chatId, reply);
|
|
1624
1777
|
return;
|
|
1625
1778
|
}
|
|
1626
1779
|
// Handle /save command (admin only)
|
|
1627
1780
|
if (text === "/save" || text.startsWith("/save ") || text.startsWith("/save@")) {
|
|
1628
1781
|
if (!this.classicChannels.isAdmin(msg.userId)) {
|
|
1629
|
-
await msgAdapter?.sendText(chatId, "
|
|
1782
|
+
await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/save"));
|
|
1630
1783
|
return;
|
|
1631
1784
|
}
|
|
1632
|
-
const
|
|
1633
|
-
if (!
|
|
1634
|
-
await msgAdapter?.sendText(chatId, "
|
|
1785
|
+
const saveName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
1786
|
+
if (!saveName) {
|
|
1787
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
1635
1788
|
return;
|
|
1636
1789
|
}
|
|
1637
1790
|
const filename = parseSaveFilename(text);
|
|
1638
1791
|
if (!filename) {
|
|
1639
|
-
await msgAdapter?.sendText(chatId, "
|
|
1792
|
+
await msgAdapter?.sendText(chatId, t("save.usage"));
|
|
1640
1793
|
return;
|
|
1641
1794
|
}
|
|
1642
1795
|
if (!SAVE_FILENAME_RE.test(filename)) {
|
|
1643
|
-
await msgAdapter?.sendText(chatId, "
|
|
1796
|
+
await msgAdapter?.sendText(chatId, t("filename.invalid"));
|
|
1644
1797
|
return;
|
|
1645
1798
|
}
|
|
1646
|
-
const backend = this.classicChannels.getBackendByInstance(
|
|
1799
|
+
const backend = this.classicChannels.getBackendByInstance(saveName, this.fleetConfig?.defaults?.backend);
|
|
1647
1800
|
const cmd = saveCommandForBackend(backend, filename);
|
|
1648
1801
|
if (!cmd) {
|
|
1649
1802
|
await msgAdapter?.sendText(chatId, SAVE_UNSUPPORTED_MSG);
|
|
1650
1803
|
return;
|
|
1651
1804
|
}
|
|
1652
|
-
this.pasteRawToClassicInstance(
|
|
1653
|
-
await msgAdapter?.sendText(chatId,
|
|
1805
|
+
this.pasteRawToClassicInstance(saveName, cmd);
|
|
1806
|
+
await msgAdapter?.sendText(chatId, t("save.sent", cmd, saveName));
|
|
1654
1807
|
return;
|
|
1655
1808
|
}
|
|
1656
|
-
// Route to classic channel if
|
|
1657
|
-
const
|
|
1658
|
-
if (
|
|
1809
|
+
// Route to classic channel if this bot has an agent here (per-bot).
|
|
1810
|
+
const classicName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
1811
|
+
if (classicName) {
|
|
1659
1812
|
if (msg.adapterId)
|
|
1660
|
-
this.bindInstanceAdapter(
|
|
1813
|
+
this.bindInstanceAdapter(classicName, msg.adapterId, true);
|
|
1661
1814
|
// TG ClassicBot: group requires @mention, private chat forwards directly.
|
|
1662
1815
|
if (!isPrivateChat && !isBotMentioned) {
|
|
1663
1816
|
// No trigger: save attachments + react, log, but don't forward to agent
|
|
1664
1817
|
const syntheticMsg = { ...msg, threadId: chatId, text: rawText.startsWith("/") ? "" : rawText };
|
|
1665
|
-
await this.handleClassicChannelMessage(
|
|
1818
|
+
await this.handleClassicChannelMessage(classicName, syntheticMsg);
|
|
1666
1819
|
return;
|
|
1667
1820
|
}
|
|
1668
1821
|
// Strip @bot from text and forward as /chat
|
|
1669
1822
|
const cleanText = botUser ? text.replace(new RegExp(`@${botUser}`, "gi"), "").trim() : text;
|
|
1670
1823
|
if (cleanText.startsWith("/raw") && !this.classicChannels.isAdmin(msg.userId)) {
|
|
1671
|
-
await msgAdapter?.sendText(chatId, "
|
|
1824
|
+
await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/raw"));
|
|
1672
1825
|
return;
|
|
1673
1826
|
}
|
|
1674
1827
|
const syntheticMsg = { ...msg, threadId: chatId, text: `/chat ${cleanText}` };
|
|
1675
|
-
await this.handleClassicChannelMessage(
|
|
1828
|
+
await this.handleClassicChannelMessage(classicName, syntheticMsg);
|
|
1676
1829
|
return;
|
|
1677
1830
|
}
|
|
1678
1831
|
// Handle @bot without active agent
|
|
1679
1832
|
if (isBotMentioned) {
|
|
1680
|
-
await msgAdapter?.sendText(chatId, "
|
|
1833
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
1681
1834
|
return;
|
|
1682
1835
|
}
|
|
1683
1836
|
// Unregistered private chat: ignore (don't fall through to General)
|
|
@@ -1697,9 +1850,12 @@ export class FleetManager {
|
|
|
1697
1850
|
if (msg.adapterId)
|
|
1698
1851
|
this.bindInstanceAdapter(generalInstance, msg.adapterId, true);
|
|
1699
1852
|
const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
1700
|
-
// React immediately — before any other API calls
|
|
1853
|
+
// React immediately — before any other API calls. Use the adapter BOUND to
|
|
1854
|
+
// the instance (not whichever same-guild bot received the event first) so
|
|
1855
|
+
// exactly the owning bot reacts — no duplicate 👀 from a sibling bot.
|
|
1701
1856
|
if (msg.chatId && msg.messageId) {
|
|
1702
|
-
|
|
1857
|
+
const reactAdapter = this.getAdapterForInstance(generalInstance) ?? inboundAdapter;
|
|
1858
|
+
reactAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
|
|
1703
1859
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
1704
1860
|
}
|
|
1705
1861
|
this.warnIfRateLimited(generalInstance, msg);
|
|
@@ -1735,6 +1891,18 @@ export class FleetManager {
|
|
|
1735
1891
|
}
|
|
1736
1892
|
return;
|
|
1737
1893
|
}
|
|
1894
|
+
// Classic channels resolve per-bot (same-channel multi-bot) — a channel can
|
|
1895
|
+
// host two bots' agents. If this channel is classic but THIS bot has no
|
|
1896
|
+
// agent here, a sibling bot owns it; skip rather than misroute to it.
|
|
1897
|
+
if (this.classicChannels?.hasChannel(threadId)) {
|
|
1898
|
+
const classicName = this.classicChannels.getInstanceByChannel(threadId, msg.adapterId);
|
|
1899
|
+
if (!classicName)
|
|
1900
|
+
return;
|
|
1901
|
+
if (msg.adapterId)
|
|
1902
|
+
this.bindInstanceAdapter(classicName, msg.adapterId, true);
|
|
1903
|
+
await this.handleClassicChannelMessage(classicName, msg);
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1738
1906
|
const target = this.routing.resolve(threadId);
|
|
1739
1907
|
if (!target) {
|
|
1740
1908
|
// Only show unbound message for actual forum topics (same group, has threadId)
|
|
@@ -1766,9 +1934,12 @@ export class FleetManager {
|
|
|
1766
1934
|
if (msg.adapterId)
|
|
1767
1935
|
this.bindInstanceAdapter(instanceName, msg.adapterId, true);
|
|
1768
1936
|
const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
1769
|
-
// React immediately — before any other Discord API calls
|
|
1937
|
+
// React immediately — before any other Discord API calls. Use the adapter
|
|
1938
|
+
// BOUND to the instance (not whichever same-guild bot received the event
|
|
1939
|
+
// first) so exactly the owning bot reacts — no duplicate 👀 from a sibling.
|
|
1770
1940
|
if (msg.chatId && msg.messageId) {
|
|
1771
|
-
|
|
1941
|
+
const reactAdapter = this.getAdapterForInstance(instanceName) ?? inboundAdapter;
|
|
1942
|
+
reactAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
|
|
1772
1943
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
1773
1944
|
}
|
|
1774
1945
|
// These may hit Discord API (topic icon, archive) — do after react
|
|
@@ -1893,7 +2064,7 @@ export class FleetManager {
|
|
|
1893
2064
|
ts: new Date().toISOString(),
|
|
1894
2065
|
});
|
|
1895
2066
|
// Log bot reply to classic instance chat-log
|
|
1896
|
-
const isClassic =
|
|
2067
|
+
const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
|
|
1897
2068
|
if (isClassic) {
|
|
1898
2069
|
ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
|
|
1899
2070
|
}
|
|
@@ -1953,7 +2124,7 @@ export class FleetManager {
|
|
|
1953
2124
|
five_hour_pct: rl.five_hour_pct,
|
|
1954
2125
|
});
|
|
1955
2126
|
this.webhookEmitter?.emit("schedule_deferred", target, { schedule_id: id, label, five_hour_pct: rl.five_hour_pct });
|
|
1956
|
-
this.notifyInstanceTopic(target,
|
|
2127
|
+
this.notifyInstanceTopic(target, t("schedule.deferred", label ?? id, rl.five_hour_pct));
|
|
1957
2128
|
this.logger.info({ target, scheduleId: id, rateLimitPct: rl.five_hour_pct }, "Schedule deferred due to rate limit");
|
|
1958
2129
|
return;
|
|
1959
2130
|
}
|
|
@@ -2440,6 +2611,8 @@ export class FleetManager {
|
|
|
2440
2611
|
// Preserve all optional user-configured fields so saveFleetConfig() never silently drops them
|
|
2441
2612
|
if (inst.general_topic)
|
|
2442
2613
|
serialized.general_topic = true;
|
|
2614
|
+
if (inst.display_name)
|
|
2615
|
+
serialized.display_name = inst.display_name;
|
|
2443
2616
|
if (inst.channel_id)
|
|
2444
2617
|
serialized.channel_id = inst.channel_id;
|
|
2445
2618
|
if (inst.description)
|
|
@@ -2512,15 +2685,17 @@ export class FleetManager {
|
|
|
2512
2685
|
startStatuslineWatcher(name) {
|
|
2513
2686
|
this.statuslineWatcher.watch(name);
|
|
2514
2687
|
}
|
|
2515
|
-
reactMessageStatus(chatId, messageId, emoji) {
|
|
2516
|
-
//
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2688
|
+
reactMessageStatus(instanceName, chatId, messageId, emoji) {
|
|
2689
|
+
// React via the adapter BOUND to this instance — NOT the first discord world.
|
|
2690
|
+
// Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
|
|
2691
|
+
// (bound bot) and the delivery/confirm reactions (some other bot) come from
|
|
2692
|
+
// different bots, leaving a duplicate 👀 that never turns into ✅.
|
|
2693
|
+
const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
2694
|
+
// Status reactions are Discord-only (TG/others use the inbound react path).
|
|
2695
|
+
if (!adapter || adapter.type !== "discord")
|
|
2696
|
+
return;
|
|
2697
|
+
adapter.react(chatId, messageId, emoji)
|
|
2698
|
+
.catch(e => this.logger.debug({ err: e.message }, "Message status react failed"));
|
|
2524
2699
|
}
|
|
2525
2700
|
// ── Model failover ──────────────────────────────────────────────────────
|
|
2526
2701
|
static FAILOVER_TRIGGER_PCT = 90;
|
|
@@ -2569,7 +2744,7 @@ export class FleetManager {
|
|
|
2569
2744
|
this.collabInstances.add(instanceName);
|
|
2570
2745
|
return true;
|
|
2571
2746
|
}
|
|
2572
|
-
notifyInstanceTopic(instanceName, text) {
|
|
2747
|
+
notifyInstanceTopic(instanceName, text, extraOpts) {
|
|
2573
2748
|
const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
2574
2749
|
if (!adapter)
|
|
2575
2750
|
return;
|
|
@@ -2578,21 +2753,20 @@ export class FleetManager {
|
|
|
2578
2753
|
// Fleet topic instance
|
|
2579
2754
|
const threadId = this.fleetConfig?.instances[instanceName]?.topic_id;
|
|
2580
2755
|
if (threadId != null && groupId) {
|
|
2581
|
-
adapter.sendText(String(groupId), text, { threadId: String(threadId) })
|
|
2756
|
+
adapter.sendText(String(groupId), text, { threadId: String(threadId), ...extraOpts })
|
|
2582
2757
|
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send instance topic notification"));
|
|
2583
2758
|
return;
|
|
2584
2759
|
}
|
|
2585
|
-
// Classic instance: find
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
}
|
|
2760
|
+
// Classic instance: find its channelId from the classic manager
|
|
2761
|
+
const classicChatId = this.classicChannels?.getChannelIdByInstance(instanceName);
|
|
2762
|
+
if (classicChatId) {
|
|
2763
|
+
adapter.sendText(classicChatId, text, extraOpts)
|
|
2764
|
+
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send classic notification"));
|
|
2765
|
+
return;
|
|
2592
2766
|
}
|
|
2593
2767
|
// Fallback: send to group without threadId
|
|
2594
2768
|
if (groupId) {
|
|
2595
|
-
adapter.sendText(String(groupId), text)
|
|
2769
|
+
adapter.sendText(String(groupId), text, extraOpts)
|
|
2596
2770
|
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
|
|
2597
2771
|
}
|
|
2598
2772
|
}
|
|
@@ -2605,19 +2779,23 @@ export class FleetManager {
|
|
|
2605
2779
|
* Picks the backend-appropriate command (kiro → /chat save, claude → /export);
|
|
2606
2780
|
* unsupported backends get a clear error. Routes via classic paste or fleet IPC.
|
|
2607
2781
|
*/
|
|
2608
|
-
async handleSlashSave(data) {
|
|
2782
|
+
async handleSlashSave(data, adapterId) {
|
|
2609
2783
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
2610
|
-
await data.respond("
|
|
2784
|
+
await data.respond(t("admin.required"));
|
|
2611
2785
|
return;
|
|
2612
2786
|
}
|
|
2613
|
-
|
|
2787
|
+
// Classic resolves per-bot (same-channel multi-bot); otherwise a fleet topic.
|
|
2788
|
+
const classicName = this.classicChannels.getInstanceByChannel(data.channelId, adapterId);
|
|
2789
|
+
const target = classicName
|
|
2790
|
+
? { kind: "classic", name: classicName }
|
|
2791
|
+
: this.routing.resolve(data.channelId);
|
|
2614
2792
|
if (!target) {
|
|
2615
|
-
await data.respond("
|
|
2793
|
+
await data.respond(t("classic.no_agent_start"));
|
|
2616
2794
|
return;
|
|
2617
2795
|
}
|
|
2618
2796
|
const filename = data.options?.filename ?? "";
|
|
2619
2797
|
if (!SAVE_FILENAME_RE.test(filename)) {
|
|
2620
|
-
await data.respond("
|
|
2798
|
+
await data.respond(t("filename.invalid"));
|
|
2621
2799
|
return;
|
|
2622
2800
|
}
|
|
2623
2801
|
const backend = target.kind === "classic"
|
|
@@ -2636,7 +2814,7 @@ export class FleetManager {
|
|
|
2636
2814
|
else {
|
|
2637
2815
|
this.instanceIpcClients.get(target.name)?.send({ type: "raw_paste", content: cmd });
|
|
2638
2816
|
}
|
|
2639
|
-
await data.respond(
|
|
2817
|
+
await data.respond(t("save.sent", cmd, target.name));
|
|
2640
2818
|
}
|
|
2641
2819
|
/** Whether the instance currently has at least one live cancel button. */
|
|
2642
2820
|
hasCancelButton(instanceName) {
|
|
@@ -2665,13 +2843,8 @@ export class FleetManager {
|
|
|
2665
2843
|
threadId = String(topicId);
|
|
2666
2844
|
}
|
|
2667
2845
|
else {
|
|
2668
|
-
// Classic instance:
|
|
2669
|
-
|
|
2670
|
-
if (target.kind === "classic" && target.name === instanceName) {
|
|
2671
|
-
chatId = cid;
|
|
2672
|
-
break;
|
|
2673
|
-
}
|
|
2674
|
-
}
|
|
2846
|
+
// Classic instance: channelId from the classic manager.
|
|
2847
|
+
chatId = this.classicChannels?.getChannelIdByInstance(instanceName);
|
|
2675
2848
|
// General / flat fallback: post to the group (no thread).
|
|
2676
2849
|
if (!chatId && groupId)
|
|
2677
2850
|
chatId = String(groupId);
|
|
@@ -2683,7 +2856,7 @@ export class FleetManager {
|
|
|
2683
2856
|
type: "cancel",
|
|
2684
2857
|
instanceName,
|
|
2685
2858
|
message: "👀 處理中…",
|
|
2686
|
-
choices: [{ id: `cancel:${instanceName}`, label: "
|
|
2859
|
+
choices: [{ id: `cancel:${instanceName}`, label: t("cancel.button") }],
|
|
2687
2860
|
}, threadId ? { threadId } : undefined);
|
|
2688
2861
|
// A concurrent sendCancelButton for the same instance may have posted its
|
|
2689
2862
|
// own button while we awaited notifyAlert. Retire any other buttons for
|
|
@@ -3154,7 +3327,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
3154
3327
|
async handleClassicChannelMessage(instanceName, msg) {
|
|
3155
3328
|
const text = msg.text ?? "";
|
|
3156
3329
|
const channelId = msg.threadId ?? msg.chatId;
|
|
3157
|
-
const isCollabMode = this.classicChannels?.isCollab(channelId) ?? false;
|
|
3330
|
+
const isCollabMode = this.classicChannels?.isCollab(channelId, msg.adapterId) ?? false;
|
|
3158
3331
|
// Handle /ctx in classic mode — always, regardless of collab mode
|
|
3159
3332
|
if (text === "/ctx" || text.startsWith("/ctx@")) {
|
|
3160
3333
|
const reply = await this.topicCommands.getCtxText(instanceName);
|
|
@@ -3181,8 +3354,15 @@ When users create specialized instances, suggest these configurations:
|
|
|
3181
3354
|
: "");
|
|
3182
3355
|
ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText);
|
|
3183
3356
|
this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message");
|
|
3184
|
-
// Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here
|
|
3185
|
-
|
|
3357
|
+
// Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here.
|
|
3358
|
+
// Each bot matches ONLY its own id. A secondary bot must NOT fall back to the
|
|
3359
|
+
// process-wide botUserId (the primary's) — otherwise, in a same-channel
|
|
3360
|
+
// multi-bot setup, an @mention of the primary would also match the secondary
|
|
3361
|
+
// and BOTH bots would react 👀 and forward. Only the primary adapter may use
|
|
3362
|
+
// the fallback.
|
|
3363
|
+
const mentionWorld = this.worlds.get(msg.adapterId ?? "");
|
|
3364
|
+
const isPrimaryAdapter = !mentionWorld || mentionWorld.adapter === this.adapter;
|
|
3365
|
+
const adapterBotUserId = mentionWorld?.botUserId ?? (isPrimaryAdapter ? this.botUserId : undefined);
|
|
3186
3366
|
const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
|
|
3187
3367
|
const isMentioned = mentionTag && text.includes(mentionTag);
|
|
3188
3368
|
if (!isMentioned) {
|
|
@@ -3360,7 +3540,11 @@ When users create specialized instances, suggest these configurations:
|
|
|
3360
3540
|
}
|
|
3361
3541
|
/** Forward a message to a classic channel instance with chat log context */
|
|
3362
3542
|
async forwardToClassicInstance(instanceName, text, msg, extraMeta) {
|
|
3363
|
-
|
|
3543
|
+
// Resolve the channel/adapter from the instance itself so per-channel context
|
|
3544
|
+
// config is correct even for a same-channel second bot.
|
|
3545
|
+
const ctxAdapterId = this.classicChannels?.getAdapterIdByInstance(instanceName);
|
|
3546
|
+
const ctxChannelId = this.classicChannels?.getChannelIdByInstance(instanceName) ?? msg.chatId;
|
|
3547
|
+
const contextLines = this.classicChannels?.getContextLines(ctxChannelId, ctxAdapterId) ?? 5;
|
|
3364
3548
|
const logContext = this.getRecentChatLog(instanceName, contextLines);
|
|
3365
3549
|
const fullText = logContext
|
|
3366
3550
|
? `[Chat log for context]\n${logContext}\n\n[User message]\n${text}`
|
|
@@ -3446,44 +3630,51 @@ When users create specialized instances, suggest these configurations:
|
|
|
3446
3630
|
await this.startInstance(instanceName, config, topicMode);
|
|
3447
3631
|
}
|
|
3448
3632
|
/** Handle /start slash command — register classic channel */
|
|
3449
|
-
async handleClassicStart(channelId, channelName, userId, guildId) {
|
|
3633
|
+
async handleClassicStart(channelId, channelName, userId, guildId, adapterId) {
|
|
3450
3634
|
if (!this.classicChannels)
|
|
3451
3635
|
return "Classic channel manager not initialized.";
|
|
3452
3636
|
if (guildId && !this.classicChannels.isGuildAllowed(guildId)) {
|
|
3453
3637
|
const generalId = this.findGeneralInstance();
|
|
3454
3638
|
if (generalId) {
|
|
3455
|
-
this.notifyInstanceTopic(generalId,
|
|
3639
|
+
this.notifyInstanceTopic(generalId, t("alert.unauth_guild", guildId, userId));
|
|
3456
3640
|
}
|
|
3457
|
-
return "
|
|
3641
|
+
return t("classic.not_authorized_guild");
|
|
3458
3642
|
}
|
|
3459
|
-
|
|
3460
|
-
|
|
3643
|
+
// Per-bot check: a second bot may /start in the same channel (own agent).
|
|
3644
|
+
if (this.classicChannels.isClassicChannel(channelId, adapterId))
|
|
3645
|
+
return t("classic.already_active");
|
|
3646
|
+
// Classic no longer lives in the routing engine, so this only guards against
|
|
3647
|
+
// a fleet topic-mode instance colliding with the channel.
|
|
3461
3648
|
if (this.routing.resolve(channelId))
|
|
3462
|
-
return "
|
|
3463
|
-
const instanceName =
|
|
3464
|
-
this.classicChannels.register(channelId, instanceName, channelName || channelId, userId);
|
|
3465
|
-
|
|
3466
|
-
|
|
3649
|
+
return t("classic.topic_bound");
|
|
3650
|
+
const instanceName = this.classicChannels.deriveInstanceName(channelName || channelId, channelId, adapterId);
|
|
3651
|
+
this.classicChannels.register(channelId, adapterId, instanceName, channelName || channelId, userId);
|
|
3652
|
+
// Bind this classic instance to the bot that started it (authoritative), so
|
|
3653
|
+
// replies/cancel go out through that bot even though every same-guild bot
|
|
3654
|
+
// also sees the channel's messages.
|
|
3655
|
+
if (adapterId)
|
|
3656
|
+
this.bindInstanceAdapter(instanceName, adapterId);
|
|
3657
|
+
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));
|
|
3467
3658
|
this.reregisterClassicChannels();
|
|
3468
3659
|
// Auto-enable collab for Discord classic channels (TG uses @mention directly without collab mode)
|
|
3469
|
-
if (guildId && !this.classicChannels.isCollab(channelId)) {
|
|
3470
|
-
this.classicChannels.toggleCollab(channelId);
|
|
3660
|
+
if (guildId && !this.classicChannels.isCollab(channelId, adapterId)) {
|
|
3661
|
+
this.classicChannels.toggleCollab(channelId, adapterId);
|
|
3471
3662
|
}
|
|
3472
|
-
this.logger.info({ channelId, instanceName, userId }, "Classic channel started");
|
|
3473
|
-
return
|
|
3663
|
+
this.logger.info({ channelId, adapterId, instanceName, userId }, "Classic channel started");
|
|
3664
|
+
return t("classic.started");
|
|
3474
3665
|
}
|
|
3475
3666
|
/** Handle /stop slash command — unregister classic channel */
|
|
3476
|
-
async handleClassicStop(channelId) {
|
|
3667
|
+
async handleClassicStop(channelId, adapterId) {
|
|
3477
3668
|
if (!this.classicChannels)
|
|
3478
3669
|
return "Classic channel manager not initialized.";
|
|
3479
|
-
const ch = this.classicChannels.unregister(channelId);
|
|
3670
|
+
const ch = this.classicChannels.unregister(channelId, adapterId);
|
|
3480
3671
|
if (!ch)
|
|
3481
|
-
return "
|
|
3482
|
-
this.
|
|
3672
|
+
return t("classic.no_agent");
|
|
3673
|
+
this.instanceWorldBinding.delete(ch.instanceName);
|
|
3483
3674
|
await this.stopInstance(ch.instanceName).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance"));
|
|
3484
3675
|
this.reregisterClassicChannels();
|
|
3485
|
-
this.logger.info({ channelId, instanceName: ch.instanceName }, "Classic channel stopped");
|
|
3486
|
-
return
|
|
3676
|
+
this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
|
|
3677
|
+
return t("classic.stopped");
|
|
3487
3678
|
}
|
|
3488
3679
|
async stopAll() {
|
|
3489
3680
|
this.ipcStoppingInstances.add("__fleet_stopping__");
|
|
@@ -3626,7 +3817,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
3626
3817
|
this.logger.info(`Full restart: waiting for ${instanceNames.length} instances to idle...`);
|
|
3627
3818
|
const groupId = this.fleetConfig?.channel?.group_id;
|
|
3628
3819
|
if (groupId && this.adapter) {
|
|
3629
|
-
await this.adapter.sendText(String(groupId),
|
|
3820
|
+
await this.adapter.sendText(String(groupId), t("restart.full_initiated"))
|
|
3630
3821
|
.catch(e => this.logger.warn({ err: e }, "Failed to post full restart notification"));
|
|
3631
3822
|
}
|
|
3632
3823
|
// Wait for idle with 5-minute timeout
|
|
@@ -3737,7 +3928,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
3737
3928
|
const generalThreadId = generalName ? this.fleetConfig?.instances[generalName]?.topic_id : undefined;
|
|
3738
3929
|
const notifyOpts = { threadId: generalThreadId != null ? String(generalThreadId) : undefined };
|
|
3739
3930
|
if (groupId && this.adapter) {
|
|
3740
|
-
await this.adapter.sendText(String(groupId),
|
|
3931
|
+
await this.adapter.sendText(String(groupId), t("restart.graceful_initiated"), notifyOpts)
|
|
3741
3932
|
.catch(e => this.logger.warn({ err: e }, "Failed to post restart notification"));
|
|
3742
3933
|
}
|
|
3743
3934
|
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
@@ -3811,7 +4002,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
3811
4002
|
let idx = 0;
|
|
3812
4003
|
while (idx < channels.length) {
|
|
3813
4004
|
const batch = channels.slice(idx, idx + concurrency);
|
|
3814
|
-
await Promise.allSettled(batch.map(ch => this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend)).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance"))));
|
|
4005
|
+
await Promise.allSettled(batch.map(ch => this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend), this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model)).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance"))));
|
|
3815
4006
|
idx += concurrency;
|
|
3816
4007
|
}
|
|
3817
4008
|
}
|
|
@@ -3828,8 +4019,8 @@ When users create specialized instances, suggest these configurations:
|
|
|
3828
4019
|
const _require2 = createRequire(import.meta.url);
|
|
3829
4020
|
const agendVersion2 = _require2("../package.json").version ?? "unknown";
|
|
3830
4021
|
const restartText = failedNames.length === 0
|
|
3831
|
-
?
|
|
3832
|
-
:
|
|
4022
|
+
? t("fleet.ready", started, total, agendVersion2)
|
|
4023
|
+
: t("fleet.ready_with_failed", started, total, agendVersion2, failedNames.join(", "));
|
|
3833
4024
|
await this.adapter.sendText(String(groupId), restartText, notifyOpts)
|
|
3834
4025
|
.catch(e => this.logger.warn({ err: e }, "Failed to post restart completion notification"));
|
|
3835
4026
|
// Notify each instance's channel — staggered to avoid rate limit storm
|
|
@@ -3894,7 +4085,10 @@ When users create specialized instances, suggest these configurations:
|
|
|
3894
4085
|
if (target && this.semverGt(target, currentVersion)) {
|
|
3895
4086
|
const generalId = this.findGeneralInstance();
|
|
3896
4087
|
if (generalId) {
|
|
3897
|
-
|
|
4088
|
+
// Beta builds have no per-tag release page → link to the latest release.
|
|
4089
|
+
const releaseUrl = target.includes("-") ? "https://github.com/songsid/AgEnD/releases/latest" : `https://github.com/songsid/AgEnD/releases/tag/v${target}`;
|
|
4090
|
+
// Suppress the (large, ugly) GitHub release link preview on Telegram.
|
|
4091
|
+
this.notifyInstanceTopic(generalId, t("update.available", `v${target}`) + ` (current: v${currentVersion})\n📄 ${releaseUrl}`, { disablePreview: true });
|
|
3898
4092
|
}
|
|
3899
4093
|
}
|
|
3900
4094
|
}
|
|
@@ -4058,6 +4252,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
4058
4252
|
res.writeHead(200);
|
|
4059
4253
|
res.end(JSON.stringify({
|
|
4060
4254
|
...sysInfo,
|
|
4255
|
+
version: this.currentVersion,
|
|
4061
4256
|
instances: enriched,
|
|
4062
4257
|
}));
|
|
4063
4258
|
return;
|
|
@@ -4164,6 +4359,8 @@ When users create specialized instances, suggest these configurations:
|
|
|
4164
4359
|
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
4165
4360
|
if (handleViewRequest(req, res, url, this))
|
|
4166
4361
|
return;
|
|
4362
|
+
if (handleSettingsRequest(req, res, url, this))
|
|
4363
|
+
return;
|
|
4167
4364
|
if (handleWebRequest(req, res, url, this))
|
|
4168
4365
|
return;
|
|
4169
4366
|
res.writeHead(404);
|