@songsid/agend 2.0.11-beta.9 → 2.0.11
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.js +21 -16
- 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/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 +70 -21
- 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 +21 -5
- package/dist/fleet-manager.js +412 -258
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +1 -1
- package/dist/instance-lifecycle.js +12 -11
- 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 +222 -9
- package/dist/quickstart.js.map +1 -1
- package/dist/scheduler/scheduler.js +8 -2
- package/dist/scheduler/scheduler.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 -15
- 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 +271 -43
- package/dist/view-api.js +86 -37
- package/dist/view-api.js.map +1 -1
- package/package.json +1 -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) {
|
|
@@ -179,27 +183,51 @@ export class FleetManager {
|
|
|
179
183
|
}
|
|
180
184
|
return this.routing.map;
|
|
181
185
|
}
|
|
182
|
-
/**
|
|
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
|
+
*/
|
|
183
194
|
reregisterClassicChannels() {
|
|
184
195
|
if (!this.classicChannels)
|
|
185
196
|
return;
|
|
186
197
|
const channels = this.classicChannels.getAll();
|
|
187
|
-
for (const ch of channels) {
|
|
188
|
-
this.routing.register(ch.channelId, { kind: "classic", name: ch.instanceName });
|
|
189
|
-
}
|
|
190
198
|
// Always update adapter openChannels (including empty — clears stale entries on /stop)
|
|
191
|
-
for (const [, w] of this.worlds) {
|
|
199
|
+
for (const [adapterId, w] of this.worlds) {
|
|
192
200
|
if (typeof w.adapter?.setOpenChannels === "function") {
|
|
193
|
-
|
|
201
|
+
const owned = channels.filter(ch => ch.adapterId === adapterId).map(ch => ch.channelId);
|
|
202
|
+
w.adapter.setOpenChannels(owned);
|
|
194
203
|
}
|
|
195
204
|
}
|
|
196
205
|
if (channels.length > 0) {
|
|
197
|
-
this.logger.info({ count: channels.length }, "
|
|
206
|
+
this.logger.info({ count: channels.length }, "Refreshed classic channel open-lists");
|
|
198
207
|
}
|
|
199
208
|
}
|
|
200
209
|
getInstanceDir(name) {
|
|
201
210
|
return join(this.dataDir, "instances", name);
|
|
202
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
|
+
}
|
|
203
231
|
/** Get the adapter bound to an instance, falling back to primary adapter */
|
|
204
232
|
getAdapterForInstance(name) {
|
|
205
233
|
const worldId = this.instanceWorldBinding.get(name);
|
|
@@ -237,6 +265,12 @@ export class FleetManager {
|
|
|
237
265
|
const cfg = this.fleetConfig?.instances[name];
|
|
238
266
|
if (fromInbound && (cfg?.general_topic || cfg?.channel_id))
|
|
239
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))
|
|
273
|
+
return;
|
|
240
274
|
this.instanceWorldBinding.set(name, adapterId);
|
|
241
275
|
}
|
|
242
276
|
getInstanceStatus(name) {
|
|
@@ -364,6 +398,7 @@ export class FleetManager {
|
|
|
364
398
|
const { rotateLogIfNeeded } = await import("./logger.js");
|
|
365
399
|
rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
|
|
366
400
|
const fleet = this.loadConfig(configPath);
|
|
401
|
+
setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
|
|
367
402
|
const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
|
|
368
403
|
// Set tmux socket isolation for custom AGEND_HOME
|
|
369
404
|
const { getTmuxSocketName: getSocket } = await import("./paths.js");
|
|
@@ -405,10 +440,19 @@ export class FleetManager {
|
|
|
405
440
|
const pidPath = join(this.dataDir, "fleet.pid");
|
|
406
441
|
writeFileSync(pidPath, String(process.pid), "utf-8");
|
|
407
442
|
this.eventLog = new EventLog(join(this.dataDir, "events.db"));
|
|
408
|
-
// 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.
|
|
409
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).
|
|
410
453
|
for (const ch of this.classicChannels.getAll()) {
|
|
411
|
-
|
|
454
|
+
if (ch.adapterId)
|
|
455
|
+
this.instanceWorldBinding.set(ch.instanceName, ch.adapterId);
|
|
412
456
|
}
|
|
413
457
|
// Poll classicBot.yaml for external changes every 30s
|
|
414
458
|
this.classicReloadTimer = setInterval(async () => {
|
|
@@ -416,21 +460,27 @@ export class FleetManager {
|
|
|
416
460
|
if (!this.classicChannels)
|
|
417
461
|
return;
|
|
418
462
|
const fleetBackend = this.fleetConfig?.defaults?.backend;
|
|
463
|
+
const fleetModel = this.fleetConfig?.defaults?.model;
|
|
419
464
|
const oldBackends = new Map();
|
|
465
|
+
const oldModels = new Map();
|
|
420
466
|
for (const ch of this.classicChannels.getAll()) {
|
|
421
467
|
oldBackends.set(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend));
|
|
468
|
+
oldModels.set(ch.instanceName, this.classicChannels.getModel(ch.channelId, ch.adapterId, fleetModel));
|
|
422
469
|
}
|
|
423
470
|
if (!this.classicChannels.checkReload())
|
|
424
471
|
return;
|
|
425
472
|
this.reregisterClassicChannels();
|
|
426
473
|
for (const ch of this.classicChannels.getAll()) {
|
|
427
474
|
const newBackend = this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend);
|
|
428
|
-
|
|
429
|
-
|
|
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");
|
|
430
480
|
await this.stopInstance(ch.instanceName).catch(() => { });
|
|
431
481
|
// Small delay to let tmux window clean up
|
|
432
482
|
await new Promise(r => setTimeout(r, 2000));
|
|
433
|
-
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"));
|
|
434
484
|
}
|
|
435
485
|
}
|
|
436
486
|
}
|
|
@@ -450,11 +500,11 @@ export class FleetManager {
|
|
|
450
500
|
this.logger.info({ count: webhookConfigs.length }, "Webhook emitter initialized");
|
|
451
501
|
}
|
|
452
502
|
this.costGuard.on("warn", safeHandler((instance, totalCents, limitCents) => {
|
|
453
|
-
this.notifyInstanceTopic(instance,
|
|
503
|
+
this.notifyInstanceTopic(instance, t("cost.approaching", instance, formatCents(totalCents), formatCents(limitCents), Math.round(totalCents / limitCents * 100)));
|
|
454
504
|
this.webhookEmitter?.emit("cost_warning", instance, { cost_cents: totalCents, limit_cents: limitCents });
|
|
455
505
|
}, this.logger, "costGuard.warn"));
|
|
456
506
|
this.costGuard.on("limit", safeHandler(async (instance, totalCents, limitCents) => {
|
|
457
|
-
this.notifyInstanceTopic(instance,
|
|
507
|
+
this.notifyInstanceTopic(instance, t("cost.limit_reached", instance, formatCents(limitCents)));
|
|
458
508
|
this.eventLog?.insert(instance, "instance_paused", { reason: "cost_limit", cost_cents: totalCents });
|
|
459
509
|
this.webhookEmitter?.emit("cost_limit", instance, { cost_cents: totalCents, limit_cents: limitCents });
|
|
460
510
|
await this.stopInstance(instance);
|
|
@@ -486,8 +536,9 @@ export class FleetManager {
|
|
|
486
536
|
// Rotate classic channel chat logs daily (piggyback on daily summary timer)
|
|
487
537
|
this.classicChannels?.rotateLogs();
|
|
488
538
|
this.rotateInboxes();
|
|
489
|
-
// Auto-create general
|
|
539
|
+
// Auto-create/adopt a general dispatcher — ONLY for the primary adapter.
|
|
490
540
|
const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
|
|
541
|
+
const primaryAdapterId = channelConfigs[0] ? (channelConfigs[0].id ?? channelConfigs[0].type) : undefined;
|
|
491
542
|
const generalInstances = Object.entries(fleet.instances).filter(([, inst]) => inst.general_topic === true);
|
|
492
543
|
let generalsCreated = false;
|
|
493
544
|
// Collect unbound generals (no channel_id set) for auto-assignment
|
|
@@ -496,6 +547,14 @@ export class FleetManager {
|
|
|
496
547
|
const needsGeneral = [];
|
|
497
548
|
for (const ch of channelConfigs) {
|
|
498
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;
|
|
499
558
|
// Check if any general is explicitly bound to this adapter
|
|
500
559
|
if (generalInstances.some(([, inst]) => inst.channel_id === adapterId))
|
|
501
560
|
continue;
|
|
@@ -598,7 +657,7 @@ export class FleetManager {
|
|
|
598
657
|
if (this.adapter && topicId) {
|
|
599
658
|
const chatId = this.adapter.getChatId?.() ?? "";
|
|
600
659
|
if (chatId) {
|
|
601
|
-
this.adapter.sendText(chatId,
|
|
660
|
+
this.adapter.sendText(chatId, t("general.start_failed", name, errorMsg), { threadId: topicId }).catch(() => { });
|
|
602
661
|
}
|
|
603
662
|
}
|
|
604
663
|
}
|
|
@@ -638,6 +697,23 @@ export class FleetManager {
|
|
|
638
697
|
}
|
|
639
698
|
}
|
|
640
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();
|
|
641
717
|
// Auto-create topics AFTER adapter is ready (needs adapter.createTopic)
|
|
642
718
|
await this.topicCommands.autoCreateTopics();
|
|
643
719
|
const routeSummary = this.routing.rebuild(this.fleetConfig);
|
|
@@ -657,7 +733,7 @@ export class FleetManager {
|
|
|
657
733
|
let idx = 0;
|
|
658
734
|
while (idx < channels.length) {
|
|
659
735
|
const batch = channels.slice(idx, idx + concurrency);
|
|
660
|
-
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"))));
|
|
661
737
|
idx += concurrency;
|
|
662
738
|
}
|
|
663
739
|
}
|
|
@@ -676,8 +752,8 @@ export class FleetManager {
|
|
|
676
752
|
const agendVersion = _require("../package.json").version ?? "unknown";
|
|
677
753
|
if (this.adapter && fleet.channel?.group_id) {
|
|
678
754
|
const text = failedNames.length === 0
|
|
679
|
-
?
|
|
680
|
-
:
|
|
755
|
+
? t("fleet.ready", started, total, agendVersion)
|
|
756
|
+
: t("fleet.ready_with_failed", started, total, agendVersion, failedNames.join(", "));
|
|
681
757
|
this.adapter.sendText(String(fleet.channel.group_id), text, {
|
|
682
758
|
threadId: generalThreadId != null ? String(generalThreadId) : undefined,
|
|
683
759
|
}).catch(e => this.logger.warn({ err: e }, "Failed to send fleet start notification"));
|
|
@@ -761,15 +837,14 @@ export class FleetManager {
|
|
|
761
837
|
if (channelConfigs.length === 0)
|
|
762
838
|
return;
|
|
763
839
|
// Start primary adapter (first channel) — this.adapter for backward compat.
|
|
764
|
-
// The primary owns the guild's slash commands.
|
|
765
840
|
await this.startSingleAdapter(fleet, channelConfigs[0]);
|
|
766
|
-
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
//
|
|
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.
|
|
770
846
|
for (let i = 1; i < channelConfigs.length; i++) {
|
|
771
|
-
|
|
772
|
-
await this.startAdditionalAdapter(channelConfigs[i], !sameGuild);
|
|
847
|
+
await this.startAdditionalAdapter(channelConfigs[i]);
|
|
773
848
|
}
|
|
774
849
|
}
|
|
775
850
|
/** Start the primary adapter (backward-compatible, sets this.adapter) */
|
|
@@ -781,7 +856,7 @@ export class FleetManager {
|
|
|
781
856
|
}
|
|
782
857
|
const accessDir = join(this.dataDir, "access");
|
|
783
858
|
mkdirSync(accessDir, { recursive: true });
|
|
784
|
-
const accessManager = new AccessManager(channelConfig.access, join(accessDir, "access.json"));
|
|
859
|
+
const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, join(accessDir, "access.json"));
|
|
785
860
|
this.accessManager = accessManager;
|
|
786
861
|
const inboxDir = join(this.dataDir, "inbox");
|
|
787
862
|
mkdirSync(inboxDir, { recursive: true });
|
|
@@ -837,28 +912,28 @@ export class FleetManager {
|
|
|
837
912
|
// Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
|
|
838
913
|
this.adapter.on("slash_command", safeHandler(async (data) => {
|
|
839
914
|
if (data.command === "start") {
|
|
840
|
-
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);
|
|
841
916
|
await data.respond(reply);
|
|
842
917
|
}
|
|
843
918
|
else if (data.command === "stop") {
|
|
844
|
-
const reply = await this.handleClassicStop(data.channelId);
|
|
919
|
+
const reply = await this.handleClassicStop(data.channelId, adapterId);
|
|
845
920
|
await data.respond(reply);
|
|
846
921
|
}
|
|
847
922
|
else if (data.command === "chat") {
|
|
848
923
|
const text = data.text ?? "";
|
|
849
924
|
if (!text) {
|
|
850
|
-
await data.respond("
|
|
925
|
+
await data.respond(t("chat.usage"));
|
|
851
926
|
return;
|
|
852
927
|
}
|
|
853
|
-
const
|
|
854
|
-
if (!
|
|
855
|
-
await data.respond("
|
|
928
|
+
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
929
|
+
if (!name) {
|
|
930
|
+
await data.respond(t("classic.no_agent_start"));
|
|
856
931
|
return;
|
|
857
932
|
}
|
|
858
933
|
const replyMsgId = await data.respond("👀");
|
|
859
934
|
const username = data.username ?? data.userId;
|
|
860
|
-
ClassicChannelManager.logMessage(
|
|
861
|
-
await this.forwardToClassicInstance(
|
|
935
|
+
ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
|
|
936
|
+
await this.forwardToClassicInstance(name, text, {
|
|
862
937
|
chatId: data.channelId,
|
|
863
938
|
threadId: data.channelId,
|
|
864
939
|
messageId: replyMsgId ?? "",
|
|
@@ -869,86 +944,88 @@ export class FleetManager {
|
|
|
869
944
|
});
|
|
870
945
|
}
|
|
871
946
|
else if (data.command === "save") {
|
|
872
|
-
await this.handleSlashSave(data);
|
|
947
|
+
await this.handleSlashSave(data, adapterId);
|
|
873
948
|
}
|
|
874
949
|
else if (data.command === "load") {
|
|
875
950
|
// load is kiro-cli/classic only — no claude-code equivalent.
|
|
876
951
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
877
|
-
await data.respond("
|
|
952
|
+
await data.respond(t("admin.required"));
|
|
878
953
|
return;
|
|
879
954
|
}
|
|
880
|
-
const
|
|
881
|
-
if (!
|
|
882
|
-
await data.respond("
|
|
955
|
+
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
956
|
+
if (!name) {
|
|
957
|
+
await data.respond(t("classic.no_agent_start"));
|
|
883
958
|
return;
|
|
884
959
|
}
|
|
885
960
|
const filename = data.options?.filename;
|
|
886
961
|
if (!SAVE_FILENAME_RE.test(filename ?? "")) {
|
|
887
|
-
await data.respond("
|
|
962
|
+
await data.respond(t("filename.invalid"));
|
|
888
963
|
return;
|
|
889
964
|
}
|
|
890
|
-
this.pasteRawToClassicInstance(
|
|
891
|
-
await data.respond(
|
|
965
|
+
this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
|
|
966
|
+
await data.respond(t("save.sent", `/chat load ${filename}`, name));
|
|
892
967
|
}
|
|
893
968
|
else if (data.command === "compact") {
|
|
894
|
-
const
|
|
895
|
-
if (!
|
|
896
|
-
await data.respond("
|
|
969
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
970
|
+
if (!name) {
|
|
971
|
+
await data.respond(t("classic.no_agent"));
|
|
897
972
|
return;
|
|
898
973
|
}
|
|
899
|
-
const result = await this.topicCommands.sendCompact(
|
|
974
|
+
const result = await this.topicCommands.sendCompact(name);
|
|
900
975
|
await data.respond(result);
|
|
901
976
|
}
|
|
902
977
|
else if (data.command === "cancel") {
|
|
903
|
-
const
|
|
904
|
-
if (!
|
|
905
|
-
await data.respond("
|
|
978
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
979
|
+
if (!name) {
|
|
980
|
+
await data.respond(t("classic.no_agent"));
|
|
906
981
|
return;
|
|
907
982
|
}
|
|
908
|
-
const ok = this.cancelInstance(
|
|
909
|
-
await data.respond(ok ?
|
|
983
|
+
const ok = this.cancelInstance(name);
|
|
984
|
+
await data.respond(ok ? t("cancel.sent", name) : t("cancel.not_running", name));
|
|
910
985
|
}
|
|
911
986
|
else if (data.command === "ctx") {
|
|
912
|
-
const
|
|
913
|
-
if (!
|
|
914
|
-
await data.respond("
|
|
987
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
988
|
+
if (!name) {
|
|
989
|
+
await data.respond(t("classic.no_agent"));
|
|
915
990
|
return;
|
|
916
991
|
}
|
|
917
992
|
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
918
|
-
await data.respond(await this.topicCommands.getCtxText(
|
|
993
|
+
await data.respond(await this.topicCommands.getCtxText(name));
|
|
919
994
|
}
|
|
920
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.
|
|
921
998
|
const collabTarget = this.routing.resolve(data.channelId);
|
|
922
|
-
if (collabTarget
|
|
999
|
+
if (collabTarget) {
|
|
923
1000
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
924
1001
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
925
|
-
await data.respond("
|
|
1002
|
+
await data.respond(t("not_authorized"));
|
|
926
1003
|
return;
|
|
927
1004
|
}
|
|
928
1005
|
const isCollab = this.toggleFleetCollab(collabTarget.name);
|
|
929
|
-
await data.respond(isCollab ? "
|
|
1006
|
+
await data.respond(isCollab ? t("collab.on") : t("collab.off"));
|
|
930
1007
|
return;
|
|
931
1008
|
}
|
|
932
1009
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
933
|
-
await data.respond("
|
|
1010
|
+
await data.respond(t("admin.required"));
|
|
934
1011
|
return;
|
|
935
1012
|
}
|
|
936
|
-
if (!this.classicChannels.isClassicChannel(data.channelId)) {
|
|
937
|
-
await data.respond("
|
|
1013
|
+
if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
|
|
1014
|
+
await data.respond(t("classic.no_agent_start"));
|
|
938
1015
|
return;
|
|
939
1016
|
}
|
|
940
|
-
const newState = this.classicChannels.toggleCollab(data.channelId);
|
|
1017
|
+
const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
|
|
941
1018
|
await data.respond(newState
|
|
942
|
-
? "
|
|
943
|
-
: "
|
|
1019
|
+
? t("collab.on.classic")
|
|
1020
|
+
: t("collab.off.classic"));
|
|
944
1021
|
}
|
|
945
1022
|
else if (data.command === "update") {
|
|
946
1023
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
947
1024
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
948
|
-
await data.respond("
|
|
1025
|
+
await data.respond(t("not_authorized"));
|
|
949
1026
|
return;
|
|
950
1027
|
}
|
|
951
|
-
await data.respond("
|
|
1028
|
+
await data.respond(t("update.running"));
|
|
952
1029
|
const { spawn } = await import("node:child_process");
|
|
953
1030
|
const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
|
|
954
1031
|
const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
|
|
@@ -958,7 +1035,7 @@ export class FleetManager {
|
|
|
958
1035
|
else if (data.command === "doctor") {
|
|
959
1036
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
960
1037
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
961
|
-
await data.respond("
|
|
1038
|
+
await data.respond(t("not_authorized"));
|
|
962
1039
|
return;
|
|
963
1040
|
}
|
|
964
1041
|
try {
|
|
@@ -980,22 +1057,36 @@ export class FleetManager {
|
|
|
980
1057
|
else if (data.command === "sysinfo") {
|
|
981
1058
|
await data.respond(this.topicCommands.getSysInfoText());
|
|
982
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
|
+
}
|
|
983
1074
|
else if (data.command === "restart") {
|
|
984
1075
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
985
1076
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
986
|
-
await data.respond("
|
|
1077
|
+
await data.respond(t("not_authorized"));
|
|
987
1078
|
return;
|
|
988
1079
|
}
|
|
989
|
-
await data.respond("
|
|
1080
|
+
await data.respond(t("restart.graceful"));
|
|
990
1081
|
process.kill(process.pid, "SIGUSR2");
|
|
991
1082
|
}
|
|
992
1083
|
else if (data.command === "compact") {
|
|
993
|
-
const
|
|
994
|
-
if (!
|
|
995
|
-
await data.respond("
|
|
1084
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
1085
|
+
if (!name) {
|
|
1086
|
+
await data.respond(t("classic.no_agent"));
|
|
996
1087
|
return;
|
|
997
1088
|
}
|
|
998
|
-
const result = await this.topicCommands.sendCompact(
|
|
1089
|
+
const result = await this.topicCommands.sendCompact(name);
|
|
999
1090
|
await data.respond(result);
|
|
1000
1091
|
}
|
|
1001
1092
|
}, this.logger, "adapter.slash_command"));
|
|
@@ -1022,7 +1113,7 @@ export class FleetManager {
|
|
|
1022
1113
|
this.restartAdapter(this.adapter, "primary").catch(() => { });
|
|
1023
1114
|
});
|
|
1024
1115
|
this.adapter.on("new_group_detected", safeHandler((data) => {
|
|
1025
|
-
const adminMsg =
|
|
1116
|
+
const adminMsg = t("alert.bot_added", data.groupTitle, data.groupId, data.source);
|
|
1026
1117
|
const generalId = this.findGeneralInstance();
|
|
1027
1118
|
if (generalId)
|
|
1028
1119
|
this.notifyInstanceTopic(generalId, adminMsg);
|
|
@@ -1048,7 +1139,7 @@ export class FleetManager {
|
|
|
1048
1139
|
}
|
|
1049
1140
|
const accessDir = join(this.dataDir, "access");
|
|
1050
1141
|
mkdirSync(accessDir, { recursive: true });
|
|
1051
|
-
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`));
|
|
1052
1143
|
const inboxDir = join(this.dataDir, "inbox");
|
|
1053
1144
|
mkdirSync(inboxDir, { recursive: true });
|
|
1054
1145
|
const adapter = await createAdapter(channelConfig, {
|
|
@@ -1100,28 +1191,28 @@ export class FleetManager {
|
|
|
1100
1191
|
// Slash commands: classic bot + admin commands
|
|
1101
1192
|
adapter.on("slash_command", safeHandler(async (data) => {
|
|
1102
1193
|
if (data.command === "start") {
|
|
1103
|
-
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);
|
|
1104
1195
|
await data.respond(reply);
|
|
1105
1196
|
}
|
|
1106
1197
|
else if (data.command === "stop") {
|
|
1107
|
-
const reply = await this.handleClassicStop(data.channelId);
|
|
1198
|
+
const reply = await this.handleClassicStop(data.channelId, adapterId);
|
|
1108
1199
|
await data.respond(reply);
|
|
1109
1200
|
}
|
|
1110
1201
|
else if (data.command === "chat") {
|
|
1111
1202
|
const text = data.text ?? "";
|
|
1112
1203
|
if (!text) {
|
|
1113
|
-
await data.respond("
|
|
1204
|
+
await data.respond(t("chat.usage"));
|
|
1114
1205
|
return;
|
|
1115
1206
|
}
|
|
1116
|
-
const
|
|
1117
|
-
if (!
|
|
1118
|
-
await data.respond("
|
|
1207
|
+
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
1208
|
+
if (!name) {
|
|
1209
|
+
await data.respond(t("classic.no_agent_start"));
|
|
1119
1210
|
return;
|
|
1120
1211
|
}
|
|
1121
1212
|
const replyMsgId = await data.respond("👀");
|
|
1122
1213
|
const username = data.username ?? data.userId;
|
|
1123
|
-
ClassicChannelManager.logMessage(
|
|
1124
|
-
await this.forwardToClassicInstance(
|
|
1214
|
+
ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
|
|
1215
|
+
await this.forwardToClassicInstance(name, text, {
|
|
1125
1216
|
chatId: data.channelId,
|
|
1126
1217
|
threadId: data.channelId,
|
|
1127
1218
|
messageId: replyMsgId ?? "",
|
|
@@ -1132,86 +1223,79 @@ export class FleetManager {
|
|
|
1132
1223
|
});
|
|
1133
1224
|
}
|
|
1134
1225
|
else if (data.command === "save") {
|
|
1135
|
-
await this.handleSlashSave(data);
|
|
1226
|
+
await this.handleSlashSave(data, adapterId);
|
|
1136
1227
|
}
|
|
1137
1228
|
else if (data.command === "load") {
|
|
1138
1229
|
// load is kiro-cli/classic only — no claude-code equivalent.
|
|
1139
1230
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
1140
|
-
await data.respond("
|
|
1231
|
+
await data.respond(t("admin.required"));
|
|
1141
1232
|
return;
|
|
1142
1233
|
}
|
|
1143
|
-
const
|
|
1144
|
-
if (!
|
|
1145
|
-
await data.respond("
|
|
1234
|
+
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
1235
|
+
if (!name) {
|
|
1236
|
+
await data.respond(t("classic.no_agent_start"));
|
|
1146
1237
|
return;
|
|
1147
1238
|
}
|
|
1148
1239
|
const filename = data.options?.filename;
|
|
1149
1240
|
if (!SAVE_FILENAME_RE.test(filename ?? "")) {
|
|
1150
|
-
await data.respond("
|
|
1151
|
-
return;
|
|
1152
|
-
}
|
|
1153
|
-
this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
|
|
1154
|
-
await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
|
|
1155
|
-
}
|
|
1156
|
-
else if (data.command === "compact") {
|
|
1157
|
-
const target = this.routing.resolve(data.channelId);
|
|
1158
|
-
if (!target) {
|
|
1159
|
-
await data.respond("No active agent in this channel.");
|
|
1241
|
+
await data.respond(t("filename.invalid"));
|
|
1160
1242
|
return;
|
|
1161
1243
|
}
|
|
1162
|
-
|
|
1163
|
-
await data.respond(
|
|
1244
|
+
this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
|
|
1245
|
+
await data.respond(t("save.sent", `/chat load ${filename}`, name));
|
|
1164
1246
|
}
|
|
1165
1247
|
else if (data.command === "cancel") {
|
|
1166
|
-
const
|
|
1167
|
-
if (!
|
|
1168
|
-
await data.respond("
|
|
1248
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
1249
|
+
if (!name) {
|
|
1250
|
+
await data.respond(t("classic.no_agent"));
|
|
1169
1251
|
return;
|
|
1170
1252
|
}
|
|
1171
|
-
const ok = this.cancelInstance(
|
|
1172
|
-
await data.respond(ok ?
|
|
1253
|
+
const ok = this.cancelInstance(name);
|
|
1254
|
+
await data.respond(ok ? t("cancel.sent", name) : t("cancel.not_running", name));
|
|
1173
1255
|
}
|
|
1174
1256
|
else if (data.command === "ctx") {
|
|
1175
|
-
const
|
|
1176
|
-
if (!
|
|
1177
|
-
await data.respond("
|
|
1257
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
1258
|
+
if (!name) {
|
|
1259
|
+
await data.respond(t("classic.no_agent"));
|
|
1178
1260
|
return;
|
|
1179
1261
|
}
|
|
1180
1262
|
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
1181
|
-
await data.respond(await this.topicCommands.getCtxText(
|
|
1263
|
+
await data.respond(await this.topicCommands.getCtxText(name));
|
|
1182
1264
|
}
|
|
1183
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.
|
|
1184
1268
|
const collabTarget2 = this.routing.resolve(data.channelId);
|
|
1185
|
-
if (collabTarget2
|
|
1269
|
+
if (collabTarget2) {
|
|
1186
1270
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
1187
1271
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
1188
|
-
await data.respond("
|
|
1272
|
+
await data.respond(t("not_authorized"));
|
|
1189
1273
|
return;
|
|
1190
1274
|
}
|
|
1191
1275
|
const isCollab = this.toggleFleetCollab(collabTarget2.name);
|
|
1192
|
-
await data.respond(isCollab ? "
|
|
1276
|
+
await data.respond(isCollab ? t("collab.on") : t("collab.off"));
|
|
1193
1277
|
return;
|
|
1194
1278
|
}
|
|
1195
1279
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
1196
|
-
await data.respond("
|
|
1280
|
+
await data.respond(t("admin.required"));
|
|
1197
1281
|
return;
|
|
1198
1282
|
}
|
|
1199
|
-
if (!this.classicChannels.isClassicChannel(data.channelId)) {
|
|
1200
|
-
await data.respond("
|
|
1283
|
+
if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
|
|
1284
|
+
await data.respond(t("classic.no_agent_start"));
|
|
1201
1285
|
return;
|
|
1202
1286
|
}
|
|
1203
|
-
const newState = this.classicChannels.toggleCollab(data.channelId);
|
|
1287
|
+
const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
|
|
1204
1288
|
await data.respond(newState
|
|
1205
|
-
? "
|
|
1206
|
-
: "
|
|
1289
|
+
? t("collab.on.classic")
|
|
1290
|
+
: t("collab.off.classic"));
|
|
1207
1291
|
}
|
|
1208
1292
|
else if (data.command === "update") {
|
|
1209
1293
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
1210
1294
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
1211
|
-
await data.respond("
|
|
1295
|
+
await data.respond(t("not_authorized"));
|
|
1212
1296
|
return;
|
|
1213
1297
|
}
|
|
1214
|
-
await data.respond("
|
|
1298
|
+
await data.respond(t("update.running"));
|
|
1215
1299
|
const { spawn } = await import("node:child_process");
|
|
1216
1300
|
const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
|
|
1217
1301
|
const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
|
|
@@ -1221,7 +1305,7 @@ export class FleetManager {
|
|
|
1221
1305
|
else if (data.command === "doctor") {
|
|
1222
1306
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
1223
1307
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
1224
|
-
await data.respond("
|
|
1308
|
+
await data.respond(t("not_authorized"));
|
|
1225
1309
|
return;
|
|
1226
1310
|
}
|
|
1227
1311
|
try {
|
|
@@ -1243,22 +1327,36 @@ export class FleetManager {
|
|
|
1243
1327
|
else if (data.command === "sysinfo") {
|
|
1244
1328
|
await data.respond(this.topicCommands.getSysInfoText());
|
|
1245
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
|
+
}
|
|
1246
1344
|
else if (data.command === "restart") {
|
|
1247
1345
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
1248
1346
|
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
1249
|
-
await data.respond("
|
|
1347
|
+
await data.respond(t("not_authorized"));
|
|
1250
1348
|
return;
|
|
1251
1349
|
}
|
|
1252
|
-
await data.respond("
|
|
1350
|
+
await data.respond(t("restart.graceful"));
|
|
1253
1351
|
process.kill(process.pid, "SIGUSR2");
|
|
1254
1352
|
}
|
|
1255
1353
|
else if (data.command === "compact") {
|
|
1256
|
-
const
|
|
1257
|
-
if (!
|
|
1258
|
-
await data.respond("
|
|
1354
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
1355
|
+
if (!name) {
|
|
1356
|
+
await data.respond(t("classic.no_agent"));
|
|
1259
1357
|
return;
|
|
1260
1358
|
}
|
|
1261
|
-
const result = await this.topicCommands.sendCompact(
|
|
1359
|
+
const result = await this.topicCommands.sendCompact(name);
|
|
1262
1360
|
await data.respond(result);
|
|
1263
1361
|
}
|
|
1264
1362
|
}, this.logger, `adapter[${adapterId}].slash_command`));
|
|
@@ -1276,7 +1374,7 @@ export class FleetManager {
|
|
|
1276
1374
|
}
|
|
1277
1375
|
}, this.logger, `adapter[${adapterId}].started`));
|
|
1278
1376
|
adapter.on("new_group_detected", safeHandler((data) => {
|
|
1279
|
-
const adminMsg =
|
|
1377
|
+
const adminMsg = t("alert.bot_added", data.groupTitle, data.groupId, data.source);
|
|
1280
1378
|
const generalId = this.findGeneralInstance(adapterId);
|
|
1281
1379
|
if (generalId)
|
|
1282
1380
|
this.notifyInstanceTopic(generalId, adminMsg);
|
|
@@ -1481,8 +1579,17 @@ export class FleetManager {
|
|
|
1481
1579
|
// Routing (by topic/channel) and reply-adapter selection (by channel_id
|
|
1482
1580
|
// binding) are adapter-independent, so it's safe to let whichever adapter
|
|
1483
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.
|
|
1484
1587
|
if (msg.messageId) {
|
|
1485
|
-
const
|
|
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}`;
|
|
1486
1593
|
if (this.recentMessageIds.has(dedupKey)) {
|
|
1487
1594
|
this.logger.debug({ dedupKey, adapterId: msg.adapterId }, "Duplicate inbound across adapters — skipping");
|
|
1488
1595
|
return;
|
|
@@ -1509,21 +1616,25 @@ export class FleetManager {
|
|
|
1509
1616
|
return;
|
|
1510
1617
|
// Fall through to TG classic handling below
|
|
1511
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
|
+
}
|
|
1512
1629
|
else {
|
|
1513
1630
|
const target = this.routing.resolve(threadId);
|
|
1514
1631
|
if (!target)
|
|
1515
1632
|
return;
|
|
1516
|
-
if
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
// Fleet topic: allow if collab enabled OR access mode is open
|
|
1522
|
-
const channelCfg = this.getChannelConfig(msg.adapterId);
|
|
1523
|
-
const isOpen = channelCfg?.access?.mode === "open";
|
|
1524
|
-
if (!isOpen && !this.collabInstances.has(target.name))
|
|
1525
|
-
return;
|
|
1526
|
-
}
|
|
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;
|
|
1527
1638
|
// Fall through to channel handling
|
|
1528
1639
|
}
|
|
1529
1640
|
}
|
|
@@ -1533,9 +1644,10 @@ export class FleetManager {
|
|
|
1533
1644
|
const adapterGroupId = String(this.getChannelConfig(msg.adapterId)?.group_id ?? "");
|
|
1534
1645
|
const isTelegramClassicCandidate = msg.source === "telegram" && msg.chatId !== adapterGroupId && !threadId;
|
|
1535
1646
|
if (!isTelegramClassicCandidate) {
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
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)
|
|
1539
1651
|
return;
|
|
1540
1652
|
}
|
|
1541
1653
|
}
|
|
@@ -1580,9 +1692,9 @@ export class FleetManager {
|
|
|
1580
1692
|
if (!this.classicChannels.isUserAllowed(msg.userId)) {
|
|
1581
1693
|
const generalId = this.findGeneralInstance(msg.adapterId);
|
|
1582
1694
|
if (generalId) {
|
|
1583
|
-
this.notifyInstanceTopic(generalId,
|
|
1695
|
+
this.notifyInstanceTopic(generalId, t("alert.unauth_user_private", msg.username, msg.userId, msg.source));
|
|
1584
1696
|
}
|
|
1585
|
-
await msgAdapter?.sendText(chatId, "
|
|
1697
|
+
await msgAdapter?.sendText(chatId, t("classic.not_allowed_user"));
|
|
1586
1698
|
return;
|
|
1587
1699
|
}
|
|
1588
1700
|
}
|
|
@@ -1590,136 +1702,135 @@ export class FleetManager {
|
|
|
1590
1702
|
if (!this.classicChannels.isGroupAllowed(chatId)) {
|
|
1591
1703
|
// Notify admin about new group wanting access
|
|
1592
1704
|
const groupTitle = msg.chatTitle || chatId;
|
|
1593
|
-
const adminMsg =
|
|
1705
|
+
const adminMsg = t("alert.new_group", groupTitle, chatId, msg.username, msg.userId, msg.source);
|
|
1594
1706
|
const generalId = this.findGeneralInstance(msg.adapterId);
|
|
1595
1707
|
if (generalId) {
|
|
1596
1708
|
this.notifyInstanceTopic(generalId, adminMsg);
|
|
1597
1709
|
}
|
|
1598
|
-
await msgAdapter?.sendText(chatId, "
|
|
1710
|
+
await msgAdapter?.sendText(chatId, t("classic.access_requested"));
|
|
1599
1711
|
return;
|
|
1600
1712
|
}
|
|
1601
1713
|
if (!this.classicChannels.isAdmin(msg.userId)) {
|
|
1602
|
-
await msgAdapter?.sendText(chatId, "
|
|
1714
|
+
await msgAdapter?.sendText(chatId, t("classic.admin_only_start"));
|
|
1603
1715
|
const generalId = this.findGeneralInstance(msg.adapterId);
|
|
1604
1716
|
if (generalId) {
|
|
1605
|
-
this.notifyInstanceTopic(generalId,
|
|
1717
|
+
this.notifyInstanceTopic(generalId, t("alert.start_not_admin", msg.username, msg.userId, msg.source, chatId));
|
|
1606
1718
|
}
|
|
1607
1719
|
return;
|
|
1608
1720
|
}
|
|
1609
1721
|
}
|
|
1610
1722
|
const channelName = msg.username || chatId;
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
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);
|
|
1614
1725
|
await msgAdapter?.sendText(chatId, reply);
|
|
1615
1726
|
return;
|
|
1616
1727
|
}
|
|
1617
1728
|
// Handle /stop command
|
|
1618
1729
|
if (text === "/stop" || text.startsWith("/stop ")) {
|
|
1619
1730
|
if (!this.classicChannels.isAdmin(msg.userId)) {
|
|
1620
|
-
await msgAdapter?.sendText(chatId, "
|
|
1731
|
+
await msgAdapter?.sendText(chatId, t("classic.admin_only_stop"));
|
|
1621
1732
|
const generalId = this.findGeneralInstance(msg.adapterId);
|
|
1622
1733
|
if (generalId) {
|
|
1623
|
-
this.notifyInstanceTopic(generalId,
|
|
1734
|
+
this.notifyInstanceTopic(generalId, t("alert.stop_not_admin", msg.username, msg.userId, msg.source, chatId));
|
|
1624
1735
|
}
|
|
1625
1736
|
return;
|
|
1626
1737
|
}
|
|
1627
|
-
const reply = await this.handleClassicStop(chatId);
|
|
1738
|
+
const reply = await this.handleClassicStop(chatId, msg.adapterId);
|
|
1628
1739
|
await msgAdapter?.sendText(chatId, reply);
|
|
1629
1740
|
return;
|
|
1630
1741
|
}
|
|
1631
1742
|
// Handle /compact command (admin only)
|
|
1632
1743
|
if (text === "/compact" || text.startsWith("/compact@")) {
|
|
1633
1744
|
if (!this.classicChannels.isAdmin(msg.userId)) {
|
|
1634
|
-
await msgAdapter?.sendText(chatId, "
|
|
1745
|
+
await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/compact"));
|
|
1635
1746
|
return;
|
|
1636
1747
|
}
|
|
1637
|
-
const
|
|
1638
|
-
if (!
|
|
1639
|
-
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"));
|
|
1640
1751
|
return;
|
|
1641
1752
|
}
|
|
1642
|
-
const result = await this.topicCommands.sendCompact(
|
|
1753
|
+
const result = await this.topicCommands.sendCompact(compactName);
|
|
1643
1754
|
await msgAdapter?.sendText(chatId, result);
|
|
1644
1755
|
return;
|
|
1645
1756
|
}
|
|
1646
1757
|
// Handle /cancel command
|
|
1647
1758
|
if (text === "/cancel" || text.startsWith("/cancel@")) {
|
|
1648
|
-
const
|
|
1649
|
-
if (!
|
|
1650
|
-
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"));
|
|
1651
1762
|
return;
|
|
1652
1763
|
}
|
|
1653
|
-
const ok = this.cancelInstance(
|
|
1654
|
-
await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${
|
|
1764
|
+
const ok = this.cancelInstance(cancelName);
|
|
1765
|
+
await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelName}。` : `❌ ${cancelName} 未在執行。`);
|
|
1655
1766
|
return;
|
|
1656
1767
|
}
|
|
1657
1768
|
// Handle /ctx command
|
|
1658
1769
|
if (text === "/ctx" || text.startsWith("/ctx@")) {
|
|
1659
|
-
const
|
|
1660
|
-
if (!
|
|
1661
|
-
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"));
|
|
1662
1773
|
return;
|
|
1663
1774
|
}
|
|
1664
|
-
const reply = await this.topicCommands.getCtxText(
|
|
1775
|
+
const reply = await this.topicCommands.getCtxText(ctxName);
|
|
1665
1776
|
await msgAdapter?.sendText(chatId, reply);
|
|
1666
1777
|
return;
|
|
1667
1778
|
}
|
|
1668
1779
|
// Handle /save command (admin only)
|
|
1669
1780
|
if (text === "/save" || text.startsWith("/save ") || text.startsWith("/save@")) {
|
|
1670
1781
|
if (!this.classicChannels.isAdmin(msg.userId)) {
|
|
1671
|
-
await msgAdapter?.sendText(chatId, "
|
|
1782
|
+
await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/save"));
|
|
1672
1783
|
return;
|
|
1673
1784
|
}
|
|
1674
|
-
const
|
|
1675
|
-
if (!
|
|
1676
|
-
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"));
|
|
1677
1788
|
return;
|
|
1678
1789
|
}
|
|
1679
1790
|
const filename = parseSaveFilename(text);
|
|
1680
1791
|
if (!filename) {
|
|
1681
|
-
await msgAdapter?.sendText(chatId, "
|
|
1792
|
+
await msgAdapter?.sendText(chatId, t("save.usage"));
|
|
1682
1793
|
return;
|
|
1683
1794
|
}
|
|
1684
1795
|
if (!SAVE_FILENAME_RE.test(filename)) {
|
|
1685
|
-
await msgAdapter?.sendText(chatId, "
|
|
1796
|
+
await msgAdapter?.sendText(chatId, t("filename.invalid"));
|
|
1686
1797
|
return;
|
|
1687
1798
|
}
|
|
1688
|
-
const backend = this.classicChannels.getBackendByInstance(
|
|
1799
|
+
const backend = this.classicChannels.getBackendByInstance(saveName, this.fleetConfig?.defaults?.backend);
|
|
1689
1800
|
const cmd = saveCommandForBackend(backend, filename);
|
|
1690
1801
|
if (!cmd) {
|
|
1691
1802
|
await msgAdapter?.sendText(chatId, SAVE_UNSUPPORTED_MSG);
|
|
1692
1803
|
return;
|
|
1693
1804
|
}
|
|
1694
|
-
this.pasteRawToClassicInstance(
|
|
1695
|
-
await msgAdapter?.sendText(chatId,
|
|
1805
|
+
this.pasteRawToClassicInstance(saveName, cmd);
|
|
1806
|
+
await msgAdapter?.sendText(chatId, t("save.sent", cmd, saveName));
|
|
1696
1807
|
return;
|
|
1697
1808
|
}
|
|
1698
|
-
// Route to classic channel if
|
|
1699
|
-
const
|
|
1700
|
-
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) {
|
|
1701
1812
|
if (msg.adapterId)
|
|
1702
|
-
this.bindInstanceAdapter(
|
|
1813
|
+
this.bindInstanceAdapter(classicName, msg.adapterId, true);
|
|
1703
1814
|
// TG ClassicBot: group requires @mention, private chat forwards directly.
|
|
1704
1815
|
if (!isPrivateChat && !isBotMentioned) {
|
|
1705
1816
|
// No trigger: save attachments + react, log, but don't forward to agent
|
|
1706
1817
|
const syntheticMsg = { ...msg, threadId: chatId, text: rawText.startsWith("/") ? "" : rawText };
|
|
1707
|
-
await this.handleClassicChannelMessage(
|
|
1818
|
+
await this.handleClassicChannelMessage(classicName, syntheticMsg);
|
|
1708
1819
|
return;
|
|
1709
1820
|
}
|
|
1710
1821
|
// Strip @bot from text and forward as /chat
|
|
1711
1822
|
const cleanText = botUser ? text.replace(new RegExp(`@${botUser}`, "gi"), "").trim() : text;
|
|
1712
1823
|
if (cleanText.startsWith("/raw") && !this.classicChannels.isAdmin(msg.userId)) {
|
|
1713
|
-
await msgAdapter?.sendText(chatId, "
|
|
1824
|
+
await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/raw"));
|
|
1714
1825
|
return;
|
|
1715
1826
|
}
|
|
1716
1827
|
const syntheticMsg = { ...msg, threadId: chatId, text: `/chat ${cleanText}` };
|
|
1717
|
-
await this.handleClassicChannelMessage(
|
|
1828
|
+
await this.handleClassicChannelMessage(classicName, syntheticMsg);
|
|
1718
1829
|
return;
|
|
1719
1830
|
}
|
|
1720
1831
|
// Handle @bot without active agent
|
|
1721
1832
|
if (isBotMentioned) {
|
|
1722
|
-
await msgAdapter?.sendText(chatId, "
|
|
1833
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
1723
1834
|
return;
|
|
1724
1835
|
}
|
|
1725
1836
|
// Unregistered private chat: ignore (don't fall through to General)
|
|
@@ -1739,9 +1850,12 @@ export class FleetManager {
|
|
|
1739
1850
|
if (msg.adapterId)
|
|
1740
1851
|
this.bindInstanceAdapter(generalInstance, msg.adapterId, true);
|
|
1741
1852
|
const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
1742
|
-
// 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.
|
|
1743
1856
|
if (msg.chatId && msg.messageId) {
|
|
1744
|
-
|
|
1857
|
+
const reactAdapter = this.getAdapterForInstance(generalInstance) ?? inboundAdapter;
|
|
1858
|
+
reactAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
|
|
1745
1859
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
1746
1860
|
}
|
|
1747
1861
|
this.warnIfRateLimited(generalInstance, msg);
|
|
@@ -1777,6 +1891,18 @@ export class FleetManager {
|
|
|
1777
1891
|
}
|
|
1778
1892
|
return;
|
|
1779
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
|
+
}
|
|
1780
1906
|
const target = this.routing.resolve(threadId);
|
|
1781
1907
|
if (!target) {
|
|
1782
1908
|
// Only show unbound message for actual forum topics (same group, has threadId)
|
|
@@ -1808,9 +1934,12 @@ export class FleetManager {
|
|
|
1808
1934
|
if (msg.adapterId)
|
|
1809
1935
|
this.bindInstanceAdapter(instanceName, msg.adapterId, true);
|
|
1810
1936
|
const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
1811
|
-
// 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.
|
|
1812
1940
|
if (msg.chatId && msg.messageId) {
|
|
1813
|
-
|
|
1941
|
+
const reactAdapter = this.getAdapterForInstance(instanceName) ?? inboundAdapter;
|
|
1942
|
+
reactAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
|
|
1814
1943
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
1815
1944
|
}
|
|
1816
1945
|
// These may hit Discord API (topic icon, archive) — do after react
|
|
@@ -1935,7 +2064,7 @@ export class FleetManager {
|
|
|
1935
2064
|
ts: new Date().toISOString(),
|
|
1936
2065
|
});
|
|
1937
2066
|
// Log bot reply to classic instance chat-log
|
|
1938
|
-
const isClassic =
|
|
2067
|
+
const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
|
|
1939
2068
|
if (isClassic) {
|
|
1940
2069
|
ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
|
|
1941
2070
|
}
|
|
@@ -1995,7 +2124,7 @@ export class FleetManager {
|
|
|
1995
2124
|
five_hour_pct: rl.five_hour_pct,
|
|
1996
2125
|
});
|
|
1997
2126
|
this.webhookEmitter?.emit("schedule_deferred", target, { schedule_id: id, label, five_hour_pct: rl.five_hour_pct });
|
|
1998
|
-
this.notifyInstanceTopic(target,
|
|
2127
|
+
this.notifyInstanceTopic(target, t("schedule.deferred", label ?? id, rl.five_hour_pct));
|
|
1999
2128
|
this.logger.info({ target, scheduleId: id, rateLimitPct: rl.five_hour_pct }, "Schedule deferred due to rate limit");
|
|
2000
2129
|
return;
|
|
2001
2130
|
}
|
|
@@ -2482,6 +2611,8 @@ export class FleetManager {
|
|
|
2482
2611
|
// Preserve all optional user-configured fields so saveFleetConfig() never silently drops them
|
|
2483
2612
|
if (inst.general_topic)
|
|
2484
2613
|
serialized.general_topic = true;
|
|
2614
|
+
if (inst.display_name)
|
|
2615
|
+
serialized.display_name = inst.display_name;
|
|
2485
2616
|
if (inst.channel_id)
|
|
2486
2617
|
serialized.channel_id = inst.channel_id;
|
|
2487
2618
|
if (inst.description)
|
|
@@ -2554,15 +2685,17 @@ export class FleetManager {
|
|
|
2554
2685
|
startStatuslineWatcher(name) {
|
|
2555
2686
|
this.statuslineWatcher.watch(name);
|
|
2556
2687
|
}
|
|
2557
|
-
reactMessageStatus(chatId, messageId, emoji) {
|
|
2558
|
-
//
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
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"));
|
|
2566
2699
|
}
|
|
2567
2700
|
// ── Model failover ──────────────────────────────────────────────────────
|
|
2568
2701
|
static FAILOVER_TRIGGER_PCT = 90;
|
|
@@ -2611,7 +2744,7 @@ export class FleetManager {
|
|
|
2611
2744
|
this.collabInstances.add(instanceName);
|
|
2612
2745
|
return true;
|
|
2613
2746
|
}
|
|
2614
|
-
notifyInstanceTopic(instanceName, text) {
|
|
2747
|
+
notifyInstanceTopic(instanceName, text, extraOpts) {
|
|
2615
2748
|
const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
2616
2749
|
if (!adapter)
|
|
2617
2750
|
return;
|
|
@@ -2620,21 +2753,20 @@ export class FleetManager {
|
|
|
2620
2753
|
// Fleet topic instance
|
|
2621
2754
|
const threadId = this.fleetConfig?.instances[instanceName]?.topic_id;
|
|
2622
2755
|
if (threadId != null && groupId) {
|
|
2623
|
-
adapter.sendText(String(groupId), text, { threadId: String(threadId) })
|
|
2756
|
+
adapter.sendText(String(groupId), text, { threadId: String(threadId), ...extraOpts })
|
|
2624
2757
|
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send instance topic notification"));
|
|
2625
2758
|
return;
|
|
2626
2759
|
}
|
|
2627
|
-
// Classic instance: find
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
}
|
|
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;
|
|
2634
2766
|
}
|
|
2635
2767
|
// Fallback: send to group without threadId
|
|
2636
2768
|
if (groupId) {
|
|
2637
|
-
adapter.sendText(String(groupId), text)
|
|
2769
|
+
adapter.sendText(String(groupId), text, extraOpts)
|
|
2638
2770
|
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
|
|
2639
2771
|
}
|
|
2640
2772
|
}
|
|
@@ -2647,19 +2779,23 @@ export class FleetManager {
|
|
|
2647
2779
|
* Picks the backend-appropriate command (kiro → /chat save, claude → /export);
|
|
2648
2780
|
* unsupported backends get a clear error. Routes via classic paste or fleet IPC.
|
|
2649
2781
|
*/
|
|
2650
|
-
async handleSlashSave(data) {
|
|
2782
|
+
async handleSlashSave(data, adapterId) {
|
|
2651
2783
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
2652
|
-
await data.respond("
|
|
2784
|
+
await data.respond(t("admin.required"));
|
|
2653
2785
|
return;
|
|
2654
2786
|
}
|
|
2655
|
-
|
|
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);
|
|
2656
2792
|
if (!target) {
|
|
2657
|
-
await data.respond("
|
|
2793
|
+
await data.respond(t("classic.no_agent_start"));
|
|
2658
2794
|
return;
|
|
2659
2795
|
}
|
|
2660
2796
|
const filename = data.options?.filename ?? "";
|
|
2661
2797
|
if (!SAVE_FILENAME_RE.test(filename)) {
|
|
2662
|
-
await data.respond("
|
|
2798
|
+
await data.respond(t("filename.invalid"));
|
|
2663
2799
|
return;
|
|
2664
2800
|
}
|
|
2665
2801
|
const backend = target.kind === "classic"
|
|
@@ -2678,7 +2814,7 @@ export class FleetManager {
|
|
|
2678
2814
|
else {
|
|
2679
2815
|
this.instanceIpcClients.get(target.name)?.send({ type: "raw_paste", content: cmd });
|
|
2680
2816
|
}
|
|
2681
|
-
await data.respond(
|
|
2817
|
+
await data.respond(t("save.sent", cmd, target.name));
|
|
2682
2818
|
}
|
|
2683
2819
|
/** Whether the instance currently has at least one live cancel button. */
|
|
2684
2820
|
hasCancelButton(instanceName) {
|
|
@@ -2707,13 +2843,8 @@ export class FleetManager {
|
|
|
2707
2843
|
threadId = String(topicId);
|
|
2708
2844
|
}
|
|
2709
2845
|
else {
|
|
2710
|
-
// Classic instance:
|
|
2711
|
-
|
|
2712
|
-
if (target.kind === "classic" && target.name === instanceName) {
|
|
2713
|
-
chatId = cid;
|
|
2714
|
-
break;
|
|
2715
|
-
}
|
|
2716
|
-
}
|
|
2846
|
+
// Classic instance: channelId from the classic manager.
|
|
2847
|
+
chatId = this.classicChannels?.getChannelIdByInstance(instanceName);
|
|
2717
2848
|
// General / flat fallback: post to the group (no thread).
|
|
2718
2849
|
if (!chatId && groupId)
|
|
2719
2850
|
chatId = String(groupId);
|
|
@@ -2725,7 +2856,7 @@ export class FleetManager {
|
|
|
2725
2856
|
type: "cancel",
|
|
2726
2857
|
instanceName,
|
|
2727
2858
|
message: "👀 處理中…",
|
|
2728
|
-
choices: [{ id: `cancel:${instanceName}`, label: "
|
|
2859
|
+
choices: [{ id: `cancel:${instanceName}`, label: t("cancel.button") }],
|
|
2729
2860
|
}, threadId ? { threadId } : undefined);
|
|
2730
2861
|
// A concurrent sendCancelButton for the same instance may have posted its
|
|
2731
2862
|
// own button while we awaited notifyAlert. Retire any other buttons for
|
|
@@ -3196,7 +3327,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
3196
3327
|
async handleClassicChannelMessage(instanceName, msg) {
|
|
3197
3328
|
const text = msg.text ?? "";
|
|
3198
3329
|
const channelId = msg.threadId ?? msg.chatId;
|
|
3199
|
-
const isCollabMode = this.classicChannels?.isCollab(channelId) ?? false;
|
|
3330
|
+
const isCollabMode = this.classicChannels?.isCollab(channelId, msg.adapterId) ?? false;
|
|
3200
3331
|
// Handle /ctx in classic mode — always, regardless of collab mode
|
|
3201
3332
|
if (text === "/ctx" || text.startsWith("/ctx@")) {
|
|
3202
3333
|
const reply = await this.topicCommands.getCtxText(instanceName);
|
|
@@ -3223,8 +3354,15 @@ When users create specialized instances, suggest these configurations:
|
|
|
3223
3354
|
: "");
|
|
3224
3355
|
ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText);
|
|
3225
3356
|
this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message");
|
|
3226
|
-
// Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here
|
|
3227
|
-
|
|
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);
|
|
3228
3366
|
const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
|
|
3229
3367
|
const isMentioned = mentionTag && text.includes(mentionTag);
|
|
3230
3368
|
if (!isMentioned) {
|
|
@@ -3402,7 +3540,11 @@ When users create specialized instances, suggest these configurations:
|
|
|
3402
3540
|
}
|
|
3403
3541
|
/** Forward a message to a classic channel instance with chat log context */
|
|
3404
3542
|
async forwardToClassicInstance(instanceName, text, msg, extraMeta) {
|
|
3405
|
-
|
|
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;
|
|
3406
3548
|
const logContext = this.getRecentChatLog(instanceName, contextLines);
|
|
3407
3549
|
const fullText = logContext
|
|
3408
3550
|
? `[Chat log for context]\n${logContext}\n\n[User message]\n${text}`
|
|
@@ -3488,44 +3630,51 @@ When users create specialized instances, suggest these configurations:
|
|
|
3488
3630
|
await this.startInstance(instanceName, config, topicMode);
|
|
3489
3631
|
}
|
|
3490
3632
|
/** Handle /start slash command — register classic channel */
|
|
3491
|
-
async handleClassicStart(channelId, channelName, userId, guildId) {
|
|
3633
|
+
async handleClassicStart(channelId, channelName, userId, guildId, adapterId) {
|
|
3492
3634
|
if (!this.classicChannels)
|
|
3493
3635
|
return "Classic channel manager not initialized.";
|
|
3494
3636
|
if (guildId && !this.classicChannels.isGuildAllowed(guildId)) {
|
|
3495
3637
|
const generalId = this.findGeneralInstance();
|
|
3496
3638
|
if (generalId) {
|
|
3497
|
-
this.notifyInstanceTopic(generalId,
|
|
3639
|
+
this.notifyInstanceTopic(generalId, t("alert.unauth_guild", guildId, userId));
|
|
3498
3640
|
}
|
|
3499
|
-
return "
|
|
3641
|
+
return t("classic.not_authorized_guild");
|
|
3500
3642
|
}
|
|
3501
|
-
|
|
3502
|
-
|
|
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.
|
|
3503
3648
|
if (this.routing.resolve(channelId))
|
|
3504
|
-
return "
|
|
3505
|
-
const instanceName =
|
|
3506
|
-
this.classicChannels.register(channelId, instanceName, channelName || channelId, userId);
|
|
3507
|
-
|
|
3508
|
-
|
|
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));
|
|
3509
3658
|
this.reregisterClassicChannels();
|
|
3510
3659
|
// Auto-enable collab for Discord classic channels (TG uses @mention directly without collab mode)
|
|
3511
|
-
if (guildId && !this.classicChannels.isCollab(channelId)) {
|
|
3512
|
-
this.classicChannels.toggleCollab(channelId);
|
|
3660
|
+
if (guildId && !this.classicChannels.isCollab(channelId, adapterId)) {
|
|
3661
|
+
this.classicChannels.toggleCollab(channelId, adapterId);
|
|
3513
3662
|
}
|
|
3514
|
-
this.logger.info({ channelId, instanceName, userId }, "Classic channel started");
|
|
3515
|
-
return
|
|
3663
|
+
this.logger.info({ channelId, adapterId, instanceName, userId }, "Classic channel started");
|
|
3664
|
+
return t("classic.started");
|
|
3516
3665
|
}
|
|
3517
3666
|
/** Handle /stop slash command — unregister classic channel */
|
|
3518
|
-
async handleClassicStop(channelId) {
|
|
3667
|
+
async handleClassicStop(channelId, adapterId) {
|
|
3519
3668
|
if (!this.classicChannels)
|
|
3520
3669
|
return "Classic channel manager not initialized.";
|
|
3521
|
-
const ch = this.classicChannels.unregister(channelId);
|
|
3670
|
+
const ch = this.classicChannels.unregister(channelId, adapterId);
|
|
3522
3671
|
if (!ch)
|
|
3523
|
-
return "
|
|
3524
|
-
this.
|
|
3672
|
+
return t("classic.no_agent");
|
|
3673
|
+
this.instanceWorldBinding.delete(ch.instanceName);
|
|
3525
3674
|
await this.stopInstance(ch.instanceName).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance"));
|
|
3526
3675
|
this.reregisterClassicChannels();
|
|
3527
|
-
this.logger.info({ channelId, instanceName: ch.instanceName }, "Classic channel stopped");
|
|
3528
|
-
return
|
|
3676
|
+
this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
|
|
3677
|
+
return t("classic.stopped");
|
|
3529
3678
|
}
|
|
3530
3679
|
async stopAll() {
|
|
3531
3680
|
this.ipcStoppingInstances.add("__fleet_stopping__");
|
|
@@ -3668,7 +3817,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
3668
3817
|
this.logger.info(`Full restart: waiting for ${instanceNames.length} instances to idle...`);
|
|
3669
3818
|
const groupId = this.fleetConfig?.channel?.group_id;
|
|
3670
3819
|
if (groupId && this.adapter) {
|
|
3671
|
-
await this.adapter.sendText(String(groupId),
|
|
3820
|
+
await this.adapter.sendText(String(groupId), t("restart.full_initiated"))
|
|
3672
3821
|
.catch(e => this.logger.warn({ err: e }, "Failed to post full restart notification"));
|
|
3673
3822
|
}
|
|
3674
3823
|
// Wait for idle with 5-minute timeout
|
|
@@ -3779,7 +3928,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
3779
3928
|
const generalThreadId = generalName ? this.fleetConfig?.instances[generalName]?.topic_id : undefined;
|
|
3780
3929
|
const notifyOpts = { threadId: generalThreadId != null ? String(generalThreadId) : undefined };
|
|
3781
3930
|
if (groupId && this.adapter) {
|
|
3782
|
-
await this.adapter.sendText(String(groupId),
|
|
3931
|
+
await this.adapter.sendText(String(groupId), t("restart.graceful_initiated"), notifyOpts)
|
|
3783
3932
|
.catch(e => this.logger.warn({ err: e }, "Failed to post restart notification"));
|
|
3784
3933
|
}
|
|
3785
3934
|
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
@@ -3853,7 +4002,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
3853
4002
|
let idx = 0;
|
|
3854
4003
|
while (idx < channels.length) {
|
|
3855
4004
|
const batch = channels.slice(idx, idx + concurrency);
|
|
3856
|
-
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"))));
|
|
3857
4006
|
idx += concurrency;
|
|
3858
4007
|
}
|
|
3859
4008
|
}
|
|
@@ -3870,8 +4019,8 @@ When users create specialized instances, suggest these configurations:
|
|
|
3870
4019
|
const _require2 = createRequire(import.meta.url);
|
|
3871
4020
|
const agendVersion2 = _require2("../package.json").version ?? "unknown";
|
|
3872
4021
|
const restartText = failedNames.length === 0
|
|
3873
|
-
?
|
|
3874
|
-
:
|
|
4022
|
+
? t("fleet.ready", started, total, agendVersion2)
|
|
4023
|
+
: t("fleet.ready_with_failed", started, total, agendVersion2, failedNames.join(", "));
|
|
3875
4024
|
await this.adapter.sendText(String(groupId), restartText, notifyOpts)
|
|
3876
4025
|
.catch(e => this.logger.warn({ err: e }, "Failed to post restart completion notification"));
|
|
3877
4026
|
// Notify each instance's channel — staggered to avoid rate limit storm
|
|
@@ -3936,7 +4085,9 @@ When users create specialized instances, suggest these configurations:
|
|
|
3936
4085
|
if (target && this.semverGt(target, currentVersion)) {
|
|
3937
4086
|
const generalId = this.findGeneralInstance();
|
|
3938
4087
|
if (generalId) {
|
|
3939
|
-
|
|
4088
|
+
// No release URL — Discord's SuppressEmbeds proved unreliable and the
|
|
4089
|
+
// link preview looked bad. Version + /update instruction is enough.
|
|
4090
|
+
this.notifyInstanceTopic(generalId, t("update.available", `v${target}`) + ` (current: v${currentVersion})`);
|
|
3940
4091
|
}
|
|
3941
4092
|
}
|
|
3942
4093
|
}
|
|
@@ -4100,6 +4251,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
4100
4251
|
res.writeHead(200);
|
|
4101
4252
|
res.end(JSON.stringify({
|
|
4102
4253
|
...sysInfo,
|
|
4254
|
+
version: this.currentVersion,
|
|
4103
4255
|
instances: enriched,
|
|
4104
4256
|
}));
|
|
4105
4257
|
return;
|
|
@@ -4206,6 +4358,8 @@ When users create specialized instances, suggest these configurations:
|
|
|
4206
4358
|
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
4207
4359
|
if (handleViewRequest(req, res, url, this))
|
|
4208
4360
|
return;
|
|
4361
|
+
if (handleSettingsRequest(req, res, url, this))
|
|
4362
|
+
return;
|
|
4209
4363
|
if (handleWebRequest(req, res, url, this))
|
|
4210
4364
|
return;
|
|
4211
4365
|
res.writeHead(404);
|