@songsid/agend 2.1.6-beta.6 → 2.1.6-beta.8
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/backend/claude-code.d.ts +25 -0
- package/dist/backend/claude-code.js +103 -0
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/types.d.ts +27 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +14 -0
- package/dist/channel/adapters/discord.js +59 -1
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +6 -0
- package/dist/channel/adapters/telegram.js +36 -0
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/tool-router.js +20 -1
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +18 -0
- package/dist/config-validator.js +3 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/connection-secrets.d.ts +96 -0
- package/dist/connection-secrets.js +25 -0
- package/dist/connection-secrets.js.map +1 -0
- package/dist/daemon.d.ts +38 -0
- package/dist/daemon.js +246 -80
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-manager.d.ts +171 -0
- package/dist/fleet-manager.js +954 -5
- package/dist/fleet-manager.js.map +1 -1
- package/dist/outbound-schemas.js +1 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/provider-probe.d.ts +3 -0
- package/dist/provider-probe.js +12 -2
- package/dist/provider-probe.js.map +1 -1
- package/dist/provider-secret-registry.d.ts +98 -0
- package/dist/provider-secret-registry.js +334 -0
- package/dist/provider-secret-registry.js.map +1 -0
- package/dist/quickstart-api.d.ts +1 -1
- package/dist/quickstart-api.js +14 -4
- package/dist/quickstart-api.js.map +1 -1
- package/dist/secret-store.d.ts +25 -0
- package/dist/secret-store.js +165 -0
- package/dist/secret-store.js.map +1 -0
- package/dist/settings-api.d.ts +101 -0
- package/dist/settings-api.js +404 -4
- package/dist/settings-api.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/ui/settings.html +171 -6
- package/dist/usage/providers.d.ts +8 -6
- package/dist/usage/providers.js +83 -9
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.d.ts +12 -0
- package/dist/usage/usage-api.js +55 -0
- package/dist/usage/usage-api.js.map +1 -1
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -44,7 +44,7 @@ import { StatuslineWatcher } from "./statusline-watcher.js";
|
|
|
44
44
|
import { outboundHandlers } from "./outbound-handlers.js";
|
|
45
45
|
import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
|
|
46
46
|
import { handleViewRequest, isViewPath } from "./view-api.js";
|
|
47
|
-
import { handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
|
|
47
|
+
import { formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
|
|
48
48
|
import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES, checkAuthStatus } from "./login-flows.js";
|
|
49
49
|
import { LoginSession } from "./login-manager.js";
|
|
50
50
|
import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS, announcePostLoginRecovery } from "./login-controller.js";
|
|
@@ -67,6 +67,10 @@ import { mayUseTool, resolveToolSet, toolForIpcType, toolRefusedMessage, } from
|
|
|
67
67
|
import { decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
|
|
68
68
|
import { fleetLevelDifferences, fleetLevelSignature } from "./fleet-level-config.js";
|
|
69
69
|
import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
|
|
70
|
+
import { SecretStore } from "./secret-store.js";
|
|
71
|
+
import { opaqueId, safeSecretError, SECRET_CHALLENGE_TTL_MS, } from "./connection-secrets.js";
|
|
72
|
+
import { verifyDiscordToken, verifyTelegramToken } from "./provider-probe.js";
|
|
73
|
+
import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isReservedProviderEnvKey, verifyProviderSecret, } from "./provider-secret-registry.js";
|
|
70
74
|
/** A self-restart is a whole service restart; 120s is the apply budget, not this. */
|
|
71
75
|
const SELF_RESTART_DEADLINE_MS = 300_000;
|
|
72
76
|
import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
|
|
@@ -436,6 +440,40 @@ export class FleetManager {
|
|
|
436
440
|
adapterRestarting = new Set();
|
|
437
441
|
// Adapter isolation: track state per adapter for retry + visibility
|
|
438
442
|
adapterState = new Map();
|
|
443
|
+
/** Web Settings secret rotation is deliberately separate from reconnect
|
|
444
|
+
* recovery: a rotation must build a fresh provider client with the new token,
|
|
445
|
+
* and stale callbacks from the old client must not win. */
|
|
446
|
+
connectionSecretChallenges = new Map();
|
|
447
|
+
connectionSecretChallengesByKey = new Map();
|
|
448
|
+
connectionSecretJobs = new Map();
|
|
449
|
+
connectionSecretJobSession = new Map();
|
|
450
|
+
connectionSecretInFlight = new Map();
|
|
451
|
+
connectionSecretGenerations = new Map();
|
|
452
|
+
/** Local epoch that fences a challenge across adapter replacement and
|
|
453
|
+
* provider reconnect generations. The adapter's own generation can reset
|
|
454
|
+
* when a new adapter object is constructed, so keep an independent epoch. */
|
|
455
|
+
connectionSecretAdapterRefs = new Map();
|
|
456
|
+
connectionSecretHealthGenerations = new Map();
|
|
457
|
+
/** Generic API-key verifier/apply state. The challenge scope contains the
|
|
458
|
+
* resolved spec/env key, so a request can never retarget another provider. */
|
|
459
|
+
providerSecretChallenges = new Map();
|
|
460
|
+
providerSecretChallengesByKey = new Map();
|
|
461
|
+
providerSecretJobs = new Map();
|
|
462
|
+
providerSecretJobSession = new Map();
|
|
463
|
+
providerSecretInFlight = new Map();
|
|
464
|
+
providerSecretGenerations = new Map();
|
|
465
|
+
/** Test seam only; production always uses the fixed HTTPS client. */
|
|
466
|
+
providerSecretHttpClient;
|
|
467
|
+
/** Code-owned activation hooks; never populated from a request. */
|
|
468
|
+
providerSecretReloadHooks = new Map();
|
|
469
|
+
/** In-memory snapshots for narrow hot consumers (currently Groq voice). */
|
|
470
|
+
providerSecretHotSnapshots = new Map();
|
|
471
|
+
/** Web Settings connection-binding step-up challenges and apply jobs. */
|
|
472
|
+
connectionBindingChallenges = new Map();
|
|
473
|
+
connectionBindingChallengesByKey = new Map();
|
|
474
|
+
connectionBindingJobs = new Map();
|
|
475
|
+
connectionBindingJobSession = new Map();
|
|
476
|
+
connectionBindingInFlight = new Map();
|
|
439
477
|
collabInstances = new Set();
|
|
440
478
|
// Health endpoint
|
|
441
479
|
healthServer = null;
|
|
@@ -449,6 +487,9 @@ export class FleetManager {
|
|
|
449
487
|
fullRestartLauncher = launchFullRestartHelper;
|
|
450
488
|
eventLogPruneTimer = null;
|
|
451
489
|
logRotateTimer = null;
|
|
490
|
+
discordPresenceTimer = null;
|
|
491
|
+
discordPresenceInFlight = null;
|
|
492
|
+
static DISCORD_PRESENCE_REFRESH_MS = 15 * 60_000;
|
|
452
493
|
/** Days of event/activity history to keep. */
|
|
453
494
|
static EVENT_LOG_RETENTION_DAYS = 30;
|
|
454
495
|
watchdogTimer = null;
|
|
@@ -772,11 +813,30 @@ export class FleetManager {
|
|
|
772
813
|
if (this.rawFleetDocument.errors.length > 0) {
|
|
773
814
|
throw new Error(`Invalid fleet.yaml: ${this.rawFleetDocument.errors[0].message}`);
|
|
774
815
|
}
|
|
775
|
-
|
|
776
|
-
|
|
816
|
+
const raw = loadRawFleetConfig(configPath);
|
|
817
|
+
const loaded = loadFleetConfig(configPath);
|
|
818
|
+
this.assertProviderSecretEnvKeys(loaded);
|
|
819
|
+
this.rawFleetConfig = raw;
|
|
820
|
+
this.fleetConfig = loaded;
|
|
777
821
|
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
778
822
|
return this.fleetConfig;
|
|
779
823
|
}
|
|
824
|
+
/**
|
|
825
|
+
* A channel's configurable bot_token_env is an env-key writer too. Refuse
|
|
826
|
+
* an overlap with a registry API key (or a process-reserved key) before the
|
|
827
|
+
* config becomes live; otherwise a Discord token could be written into
|
|
828
|
+
* GROQ_API_KEY by a perfectly valid-looking rotation request.
|
|
829
|
+
*/
|
|
830
|
+
assertProviderSecretEnvKeys(config) {
|
|
831
|
+
const registryKeys = providerRegistryEnvKeys();
|
|
832
|
+
const channels = config.channels ?? (config.channel ? [config.channel] : []);
|
|
833
|
+
for (const channel of channels) {
|
|
834
|
+
const key = channel.bot_token_env;
|
|
835
|
+
if (registryKeys.has(key) || isReservedProviderEnvKey(key)) {
|
|
836
|
+
throw new Error(`bot_token_env ${key} conflicts with a protected provider secret key`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
780
840
|
/** User-authored fleet.yaml, before defaults are merged into instances. */
|
|
781
841
|
getRawFleetConfig() {
|
|
782
842
|
return structuredClone(this.rawFleetConfig);
|
|
@@ -2832,6 +2892,7 @@ export class FleetManager {
|
|
|
2832
2892
|
}
|
|
2833
2893
|
if (topicMode && (fleet.channel || fleet.channels?.length)) {
|
|
2834
2894
|
await adapterStartup;
|
|
2895
|
+
this.startDiscordUsagePresence();
|
|
2835
2896
|
// Bind every fleet instance deterministically. Explicit channel_id wins;
|
|
2836
2897
|
// otherwise channels[0] is authoritative. Do not infer identity from
|
|
2837
2898
|
// concurrent adapter startup or whichever bot receives a message first.
|
|
@@ -2980,6 +3041,48 @@ export class FleetManager {
|
|
|
2980
3041
|
this.logger.warn({ health }, "Fleet started with problems — see /health");
|
|
2981
3042
|
}
|
|
2982
3043
|
}
|
|
3044
|
+
/** Keep Discord profile activity aligned with the same cached usage source as /usage. */
|
|
3045
|
+
startDiscordUsagePresence() {
|
|
3046
|
+
if (this.discordPresenceTimer)
|
|
3047
|
+
clearInterval(this.discordPresenceTimer);
|
|
3048
|
+
void this.refreshDiscordUsagePresence();
|
|
3049
|
+
this.discordPresenceTimer = setInterval(() => {
|
|
3050
|
+
void this.refreshDiscordUsagePresence();
|
|
3051
|
+
}, FleetManager.DISCORD_PRESENCE_REFRESH_MS);
|
|
3052
|
+
this.discordPresenceTimer.unref?.();
|
|
3053
|
+
}
|
|
3054
|
+
refreshDiscordUsagePresence() {
|
|
3055
|
+
if (this.discordPresenceInFlight)
|
|
3056
|
+
return this.discordPresenceInFlight;
|
|
3057
|
+
const run = (async () => {
|
|
3058
|
+
const targets = [...this.adapters.values()]
|
|
3059
|
+
.filter(adapter => adapter.type === "discord" && typeof adapter.setActivity === "function");
|
|
3060
|
+
if (targets.length === 0)
|
|
3061
|
+
return;
|
|
3062
|
+
try {
|
|
3063
|
+
const payload = await getUsageSnapshot(false, this.getActiveUsageProviderIds());
|
|
3064
|
+
const text = formatDiscordUsageActivity(payload);
|
|
3065
|
+
for (const adapter of targets) {
|
|
3066
|
+
try {
|
|
3067
|
+
adapter.setActivity?.(text);
|
|
3068
|
+
}
|
|
3069
|
+
catch {
|
|
3070
|
+
// Presence is cosmetic; a failed update must not affect delivery.
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
catch {
|
|
3075
|
+
// Usage providers are best-effort and may be offline. Keep the last
|
|
3076
|
+
// activity rather than replacing it with an untruthful blank state.
|
|
3077
|
+
this.logger.debug("Discord usage presence refresh skipped");
|
|
3078
|
+
}
|
|
3079
|
+
})();
|
|
3080
|
+
this.discordPresenceInFlight = run.finally(() => {
|
|
3081
|
+
if (this.discordPresenceInFlight === run)
|
|
3082
|
+
this.discordPresenceInFlight = null;
|
|
3083
|
+
});
|
|
3084
|
+
return this.discordPresenceInFlight;
|
|
3085
|
+
}
|
|
2983
3086
|
/**
|
|
2984
3087
|
* Delete inbox files older than retentionDays (by mtime). Cleans the shared
|
|
2985
3088
|
* inbox (`<dataDir>/inbox`) and every workspace inbox
|
|
@@ -3097,6 +3200,11 @@ export class FleetManager {
|
|
|
3097
3200
|
}
|
|
3098
3201
|
bindAdapterHealth(adapter, adapterId) {
|
|
3099
3202
|
adapter.on("gateway_health", (snapshot) => {
|
|
3203
|
+
// A token rotation tears down the old EventEmitter before constructing
|
|
3204
|
+
// the replacement. A late health frame from that old client must never
|
|
3205
|
+
// make a failed/new-generation adapter look connected.
|
|
3206
|
+
if (this.adapters.get(adapterId) !== adapter)
|
|
3207
|
+
return;
|
|
3100
3208
|
const previous = this.adapterState.get(adapterId);
|
|
3101
3209
|
const status = snapshot.status === "connected" ? "connected"
|
|
3102
3210
|
: snapshot.status === "stopped" ? "failed"
|
|
@@ -3177,7 +3285,7 @@ export class FleetManager {
|
|
|
3177
3285
|
};
|
|
3178
3286
|
}
|
|
3179
3287
|
/** Start the primary adapter (backward-compatible, sets this.adapter) */
|
|
3180
|
-
async startSingleAdapter(fleet, channelConfig) {
|
|
3288
|
+
async startSingleAdapter(fleet, channelConfig, onStarted) {
|
|
3181
3289
|
const botToken = process.env[channelConfig.bot_token_env];
|
|
3182
3290
|
if (!botToken) {
|
|
3183
3291
|
this.logger.warn({ env: channelConfig.bot_token_env }, "Bot token env not set, skipping shared adapter");
|
|
@@ -3203,13 +3311,20 @@ export class FleetManager {
|
|
|
3203
3311
|
this.worlds.set(adapterId, world);
|
|
3204
3312
|
this.adapters.set(adapterId, adapter);
|
|
3205
3313
|
this.bindAdapterHealth(adapter, adapterId);
|
|
3314
|
+
const isCurrentAdapter = () => this.adapters.get(adapterId) === adapter;
|
|
3206
3315
|
this.adapter.on("message", safeHandler(async (msg) => {
|
|
3316
|
+
if (!isCurrentAdapter())
|
|
3317
|
+
return;
|
|
3207
3318
|
await this.handleInboundMessage(msg);
|
|
3208
3319
|
}, this.logger, "adapter.message"));
|
|
3209
3320
|
this.adapter.on("reaction", safeHandler(async (r) => {
|
|
3321
|
+
if (!isCurrentAdapter())
|
|
3322
|
+
return;
|
|
3210
3323
|
await this.handleInboundReaction(r);
|
|
3211
3324
|
}, this.logger, "adapter.reaction"));
|
|
3212
3325
|
this.adapter.on("callback_query", safeHandler(async (data) => {
|
|
3326
|
+
if (!isCurrentAdapter())
|
|
3327
|
+
return;
|
|
3213
3328
|
if (await this.handleTipDismiss(data, adapterId, this.adapter ?? undefined))
|
|
3214
3329
|
return;
|
|
3215
3330
|
if (await this.handleTipUnlock(data, adapterId, this.adapter ?? undefined))
|
|
@@ -3250,6 +3365,8 @@ export class FleetManager {
|
|
|
3250
3365
|
this.bindTopicClosedHandler(adapter, adapterId, "adapter.topic_closed");
|
|
3251
3366
|
// Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
|
|
3252
3367
|
this.adapter.on("slash_command", safeHandler(async (data) => {
|
|
3368
|
+
if (!isCurrentAdapter())
|
|
3369
|
+
return;
|
|
3253
3370
|
if (data.command === "start") {
|
|
3254
3371
|
await this.handleClassicStartSlash(data, adapterId);
|
|
3255
3372
|
}
|
|
@@ -3529,6 +3646,8 @@ export class FleetManager {
|
|
|
3529
3646
|
// Non-blocking: /model & status views read the cache; never delays startup.
|
|
3530
3647
|
this.probeCliEnvs();
|
|
3531
3648
|
this.adapter.on("started", safeHandler((username, userId) => {
|
|
3649
|
+
if (!isCurrentAdapter())
|
|
3650
|
+
return;
|
|
3532
3651
|
this.logger.info(`Bot @${username} polling started. Ensure no other service is polling this bot token.`);
|
|
3533
3652
|
// Concurrent startup can insert a secondary world first. Update the
|
|
3534
3653
|
// configured primary world, not Map insertion order.
|
|
@@ -3540,6 +3659,7 @@ export class FleetManager {
|
|
|
3540
3659
|
}
|
|
3541
3660
|
if (userId)
|
|
3542
3661
|
this.botUserId = userId;
|
|
3662
|
+
onStarted?.();
|
|
3543
3663
|
}, this.logger, "adapter.started"));
|
|
3544
3664
|
this.adapter.on("polling_conflict", safeHandler(({ attempt, delay }) => {
|
|
3545
3665
|
this.logger.warn(`409 Conflict (attempt ${attempt}), retry in ${delay / 1000}s`);
|
|
@@ -3548,10 +3668,14 @@ export class FleetManager {
|
|
|
3548
3668
|
this.logger.warn({ err: err instanceof Error ? err.message : String(err) }, "Adapter handler error");
|
|
3549
3669
|
}, this.logger, "adapter.handler_error"));
|
|
3550
3670
|
this.adapter.on("error", (err) => {
|
|
3671
|
+
if (!isCurrentAdapter())
|
|
3672
|
+
return;
|
|
3551
3673
|
this.logger.error({ err }, "Primary adapter fatal error");
|
|
3552
3674
|
this.restartAdapter(this.adapter, adapterId).catch(() => { });
|
|
3553
3675
|
});
|
|
3554
3676
|
this.adapter.on("new_group_detected", safeHandler(async (data) => {
|
|
3677
|
+
if (!isCurrentAdapter())
|
|
3678
|
+
return;
|
|
3555
3679
|
const adminMsg = t("alert.bot_added", data.groupTitle, data.groupId, data.source);
|
|
3556
3680
|
const generalId = this.findGeneralInstance();
|
|
3557
3681
|
// No user to promote: the bot was just added, nobody has run /start yet.
|
|
@@ -3563,6 +3687,9 @@ export class FleetManager {
|
|
|
3563
3687
|
if (fleet.channel?.group_id) {
|
|
3564
3688
|
this.adapter.setChatId(String(fleet.channel.group_id));
|
|
3565
3689
|
}
|
|
3690
|
+
if (this.discordPresenceTimer && this.adapter.type === "discord") {
|
|
3691
|
+
void this.refreshDiscordUsagePresence();
|
|
3692
|
+
}
|
|
3566
3693
|
this.startTopicCleanupPoller();
|
|
3567
3694
|
// Prune stale external sessions every 5 minutes
|
|
3568
3695
|
this.sessionPruneTimer = setInterval(() => {
|
|
@@ -3570,7 +3697,7 @@ export class FleetManager {
|
|
|
3570
3697
|
}, 5 * 60 * 1000);
|
|
3571
3698
|
}
|
|
3572
3699
|
/** Start an additional (non-primary) adapter */
|
|
3573
|
-
async startAdditionalAdapter(channelConfig, registerCommands = true) {
|
|
3700
|
+
async startAdditionalAdapter(channelConfig, registerCommands = true, onStarted) {
|
|
3574
3701
|
const adapterId = channelConfig.id ?? channelConfig.type;
|
|
3575
3702
|
const botToken = process.env[channelConfig.bot_token_env];
|
|
3576
3703
|
if (!botToken) {
|
|
@@ -3595,14 +3722,21 @@ export class FleetManager {
|
|
|
3595
3722
|
this.worlds.set(adapterId, world);
|
|
3596
3723
|
this.adapters.set(adapterId, adapter);
|
|
3597
3724
|
this.bindAdapterHealth(adapter, adapterId);
|
|
3725
|
+
const isCurrentAdapter = () => this.adapters.get(adapterId) === adapter;
|
|
3598
3726
|
// Wire up event handlers (same as primary, routes through shared handleInboundMessage)
|
|
3599
3727
|
adapter.on("message", safeHandler(async (msg) => {
|
|
3728
|
+
if (!isCurrentAdapter())
|
|
3729
|
+
return;
|
|
3600
3730
|
await this.handleInboundMessage(msg);
|
|
3601
3731
|
}, this.logger, `adapter[${adapterId}].message`));
|
|
3602
3732
|
adapter.on("reaction", safeHandler(async (r) => {
|
|
3733
|
+
if (!isCurrentAdapter())
|
|
3734
|
+
return;
|
|
3603
3735
|
await this.handleInboundReaction(r);
|
|
3604
3736
|
}, this.logger, `adapter[${adapterId}].reaction`));
|
|
3605
3737
|
adapter.on("callback_query", safeHandler(async (data) => {
|
|
3738
|
+
if (!isCurrentAdapter())
|
|
3739
|
+
return;
|
|
3606
3740
|
if (await this.handleTipDismiss(data, adapterId, adapter))
|
|
3607
3741
|
return;
|
|
3608
3742
|
if (await this.handleTipUnlock(data, adapterId, adapter))
|
|
@@ -3643,6 +3777,8 @@ export class FleetManager {
|
|
|
3643
3777
|
this.bindTopicClosedHandler(adapter, adapterId, `adapter[${adapterId}].topic_closed`);
|
|
3644
3778
|
// Slash commands: classic bot + admin commands
|
|
3645
3779
|
adapter.on("slash_command", safeHandler(async (data) => {
|
|
3780
|
+
if (!isCurrentAdapter())
|
|
3781
|
+
return;
|
|
3646
3782
|
if (data.command === "start") {
|
|
3647
3783
|
await this.handleClassicStartSlash(data, adapterId);
|
|
3648
3784
|
}
|
|
@@ -3866,6 +4002,8 @@ export class FleetManager {
|
|
|
3866
4002
|
}
|
|
3867
4003
|
}, this.logger, `adapter[${adapterId}].slash_command`));
|
|
3868
4004
|
adapter.on("started", safeHandler((username, userId) => {
|
|
4005
|
+
if (!isCurrentAdapter())
|
|
4006
|
+
return;
|
|
3869
4007
|
this.logger.info(`[${adapterId}] Bot @${username} polling started.`);
|
|
3870
4008
|
const world = this.worlds.get(adapterId);
|
|
3871
4009
|
if (world) {
|
|
@@ -3873,14 +4011,19 @@ export class FleetManager {
|
|
|
3873
4011
|
if (userId)
|
|
3874
4012
|
world.botUserId = userId;
|
|
3875
4013
|
}
|
|
4014
|
+
onStarted?.();
|
|
3876
4015
|
}, this.logger, `adapter[${adapterId}].started`));
|
|
3877
4016
|
adapter.on("new_group_detected", safeHandler(async (data) => {
|
|
4017
|
+
if (!isCurrentAdapter())
|
|
4018
|
+
return;
|
|
3878
4019
|
const adminMsg = t("alert.bot_added", data.groupTitle, data.groupId, data.source);
|
|
3879
4020
|
const generalId = this.findGeneralInstance(adapterId);
|
|
3880
4021
|
if (generalId)
|
|
3881
4022
|
await this.promptClassicApproval({ generalName: generalId, message: adminMsg, groupId: data.groupId, scope: data.source === "telegram" ? "group" : "guild" });
|
|
3882
4023
|
}, this.logger, `adapter[${adapterId}].new_group_detected`));
|
|
3883
4024
|
adapter.on("error", (err) => {
|
|
4025
|
+
if (!isCurrentAdapter())
|
|
4026
|
+
return;
|
|
3884
4027
|
this.logger.error({ err, adapterId }, "Additional adapter fatal error");
|
|
3885
4028
|
this.restartAdapter(adapter, adapterId).catch(() => { });
|
|
3886
4029
|
});
|
|
@@ -3889,6 +4032,9 @@ export class FleetManager {
|
|
|
3889
4032
|
if (channelConfig.group_id) {
|
|
3890
4033
|
adapter.setChatId(String(channelConfig.group_id));
|
|
3891
4034
|
}
|
|
4035
|
+
if (this.discordPresenceTimer && adapter.type === "discord") {
|
|
4036
|
+
void this.refreshDiscordUsagePresence();
|
|
4037
|
+
}
|
|
3892
4038
|
this.logger.info({ adapterId, type: channelConfig.type }, "Additional adapter started");
|
|
3893
4039
|
}
|
|
3894
4040
|
/** Connect IPC to a single instance with all handlers */
|
|
@@ -4131,6 +4277,9 @@ export class FleetManager {
|
|
|
4131
4277
|
// watchdog/manual/error triggers must converge here instead of stop/start.
|
|
4132
4278
|
await adapter.reconnectGateway(previous?.lastError ?? "fleet adapter restart");
|
|
4133
4279
|
this.adapterState.set(id, { status: "connected", retryCount: 0 });
|
|
4280
|
+
if (this.discordPresenceTimer && adapter.type === "discord") {
|
|
4281
|
+
void this.refreshDiscordUsagePresence();
|
|
4282
|
+
}
|
|
4134
4283
|
this.logger.info({ id }, "Adapter gateway rebuilt successfully");
|
|
4135
4284
|
}
|
|
4136
4285
|
catch (err) {
|
|
@@ -4156,6 +4305,9 @@ export class FleetManager {
|
|
|
4156
4305
|
await adapter.start();
|
|
4157
4306
|
this.logger.info({ id, attempt }, "Adapter restarted successfully");
|
|
4158
4307
|
this.adapterState.set(id, { status: "connected", retryCount: 0 });
|
|
4308
|
+
if (this.discordPresenceTimer && adapter.type === "discord") {
|
|
4309
|
+
void this.refreshDiscordUsagePresence();
|
|
4310
|
+
}
|
|
4159
4311
|
return;
|
|
4160
4312
|
}
|
|
4161
4313
|
catch (err) {
|
|
@@ -10692,6 +10844,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10692
10844
|
clearInterval(this.logRotateTimer);
|
|
10693
10845
|
this.logRotateTimer = null;
|
|
10694
10846
|
}
|
|
10847
|
+
if (this.discordPresenceTimer) {
|
|
10848
|
+
clearInterval(this.discordPresenceTimer);
|
|
10849
|
+
this.discordPresenceTimer = null;
|
|
10850
|
+
}
|
|
10695
10851
|
// Cancel-button timers were never cleared here. The idle-check interval is not
|
|
10696
10852
|
// unref'd, so it held the event loop open past shutdown and kept retrying
|
|
10697
10853
|
// deletes against an adapter that was already gone.
|
|
@@ -11325,6 +11481,799 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11325
11481
|
get applyJobs() {
|
|
11326
11482
|
return (this.applyJobStoreCache ??= new ApplyJobStore(this.dataDir, Date.now, this.logger));
|
|
11327
11483
|
}
|
|
11484
|
+
/** The only channel metadata exposed to the Settings secret UI. */
|
|
11485
|
+
listSecureConnections() {
|
|
11486
|
+
const channels = this.fleetConfig?.channels
|
|
11487
|
+
?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
11488
|
+
return channels.map((channel, index) => {
|
|
11489
|
+
const id = channel.id ?? channel.type ?? `channel-${index}`;
|
|
11490
|
+
const world = this.worlds.get(id);
|
|
11491
|
+
const state = this.adapterState.get(id);
|
|
11492
|
+
return {
|
|
11493
|
+
id,
|
|
11494
|
+
type: channel.type,
|
|
11495
|
+
token_env: channel.bot_token_env,
|
|
11496
|
+
token_present: !!process.env[channel.bot_token_env],
|
|
11497
|
+
group_id: channel.group_id != null ? String(channel.group_id) : null,
|
|
11498
|
+
general_channel_id: channel.options?.general_channel_id != null
|
|
11499
|
+
? String(channel.options.general_channel_id)
|
|
11500
|
+
: null,
|
|
11501
|
+
status: state?.status ?? (world ? "starting" : "stopped"),
|
|
11502
|
+
...(world ? { identity: { id: world.botUserId ?? null, username: world.botUsername ?? null } } : {}),
|
|
11503
|
+
};
|
|
11504
|
+
});
|
|
11505
|
+
}
|
|
11506
|
+
secureConnectionChannel(connectionId) {
|
|
11507
|
+
const channels = this.fleetConfig?.channels
|
|
11508
|
+
?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
11509
|
+
const matches = channels.filter((channel, index) => (channel.id ?? channel.type ?? `channel-${index}`) === connectionId);
|
|
11510
|
+
// Ambiguous fallback IDs (for example two unlabelled Discord channels)
|
|
11511
|
+
// must fail closed rather than rotating the first matching token.
|
|
11512
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
11513
|
+
}
|
|
11514
|
+
secureConnectionGeneration(connectionId) {
|
|
11515
|
+
const adapter = this.adapters.get(connectionId);
|
|
11516
|
+
const healthGeneration = adapter?.getHealthSnapshot?.().generation ?? 0;
|
|
11517
|
+
let generation = this.connectionSecretGenerations.get(connectionId) ?? 0;
|
|
11518
|
+
const previousAdapter = this.connectionSecretAdapterRefs.get(connectionId);
|
|
11519
|
+
const previousHealthGeneration = this.connectionSecretHealthGenerations.get(connectionId);
|
|
11520
|
+
if (this.connectionSecretAdapterRefs.has(connectionId) && previousAdapter !== adapter)
|
|
11521
|
+
generation++;
|
|
11522
|
+
if (this.connectionSecretHealthGenerations.has(connectionId) && previousHealthGeneration !== healthGeneration)
|
|
11523
|
+
generation++;
|
|
11524
|
+
this.connectionSecretAdapterRefs.set(connectionId, adapter);
|
|
11525
|
+
this.connectionSecretHealthGenerations.set(connectionId, healthGeneration);
|
|
11526
|
+
this.connectionSecretGenerations.set(connectionId, generation);
|
|
11527
|
+
return generation;
|
|
11528
|
+
}
|
|
11529
|
+
/** Provider API-key rows exposed to Settings (never the env key or secret). */
|
|
11530
|
+
providerSecretsEnabled() {
|
|
11531
|
+
return this.fleetConfig?.web?.provider_secrets === true;
|
|
11532
|
+
}
|
|
11533
|
+
listProviderSecrets() {
|
|
11534
|
+
return PROVIDER_SECRET_SPECS.map(spec => ({
|
|
11535
|
+
id: spec.id,
|
|
11536
|
+
display_name: spec.displayName,
|
|
11537
|
+
kind: spec.kind,
|
|
11538
|
+
token_present: !!process.env[spec.envKey],
|
|
11539
|
+
verifier: spec.verifier ? "available" : "unsupported",
|
|
11540
|
+
activation: spec.activation,
|
|
11541
|
+
stale_consumers: this.providerSecretStaleConsumers(spec.envKey),
|
|
11542
|
+
}));
|
|
11543
|
+
}
|
|
11544
|
+
providerSecretStaleConsumers(envKey) {
|
|
11545
|
+
// A child inherits the manager's environment at spawn. We cannot inspect
|
|
11546
|
+
// a child process's private environment safely, so report the conservative
|
|
11547
|
+
// set of already-running children; the UI can then say "restart these"
|
|
11548
|
+
// rather than claiming an existing process reloaded.
|
|
11549
|
+
if (envKey === "GROQ_API_KEY")
|
|
11550
|
+
return [];
|
|
11551
|
+
return [...this.children.keys()].sort();
|
|
11552
|
+
}
|
|
11553
|
+
providerSecretGeneration(envKey) {
|
|
11554
|
+
return this.providerSecretGenerations.get(envKey) ?? 0;
|
|
11555
|
+
}
|
|
11556
|
+
providerSecretEnvAllowed(envKey) {
|
|
11557
|
+
const configured = new Set((this.fleetConfig?.channels
|
|
11558
|
+
?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []))
|
|
11559
|
+
.map(channel => channel.bot_token_env));
|
|
11560
|
+
return !configured.has(envKey) && providerRegistryEnvKeys().has(envKey);
|
|
11561
|
+
}
|
|
11562
|
+
async verifyProviderSecret(input) {
|
|
11563
|
+
const spec = providerSecretSpec(input.specId);
|
|
11564
|
+
if (!spec || !this.providerSecretEnvAllowed(spec.envKey)) {
|
|
11565
|
+
return { ok: false, status: "invalid", error: "provider secret is not configured" };
|
|
11566
|
+
}
|
|
11567
|
+
if (!spec.verifier)
|
|
11568
|
+
return { ok: false, status: "unsupported_verifier", error: "this provider has no supported verifier" };
|
|
11569
|
+
if (!input.secret || input.secret.length > 4096 || /[\r\n\0]/.test(input.secret) || /[^\x20-\x7e]/.test(input.secret)) {
|
|
11570
|
+
return { ok: false, status: "invalid", error: "secret is invalid" };
|
|
11571
|
+
}
|
|
11572
|
+
const challengeKey = `${input.sessionBinding}:api_key:${spec.id}:${spec.envKey}:${input.idempotencyKey}`;
|
|
11573
|
+
const existingId = this.providerSecretChallengesByKey.get(challengeKey);
|
|
11574
|
+
const existing = existingId ? this.providerSecretChallenges.get(existingId) : undefined;
|
|
11575
|
+
if (existing && existing.expiresAt > Date.now()) {
|
|
11576
|
+
return { ok: true, verification_id: existing.id, expires_at: existing.expiresAt, spec_id: spec.id, activation: spec.activation };
|
|
11577
|
+
}
|
|
11578
|
+
if (existingId)
|
|
11579
|
+
this.providerSecretChallengesByKey.delete(challengeKey);
|
|
11580
|
+
const result = await verifyProviderSecret(spec, input.secret, this.providerSecretHttpClient);
|
|
11581
|
+
if (!result.ok) {
|
|
11582
|
+
// Do not log provider detail: the HTTP verifier already redacted it and
|
|
11583
|
+
// this endpoint has no need to disclose whether a key was close to valid.
|
|
11584
|
+
this.logger.warn({ specId: spec.id, status: result.status }, "Provider API-key verification failed");
|
|
11585
|
+
return { ok: false, status: result.status, error: result.status === "unsupported_verifier" ? "this provider has no supported verifier" : "provider rejected or unavailable" };
|
|
11586
|
+
}
|
|
11587
|
+
const expiresAt = Date.now() + SECRET_CHALLENGE_TTL_MS;
|
|
11588
|
+
const challenge = {
|
|
11589
|
+
id: opaqueId("provider_verify"),
|
|
11590
|
+
specId: spec.id,
|
|
11591
|
+
envKey: spec.envKey,
|
|
11592
|
+
kind: "api_key",
|
|
11593
|
+
sessionBinding: input.sessionBinding,
|
|
11594
|
+
generation: this.providerSecretGeneration(spec.envKey),
|
|
11595
|
+
operation: "provider-secret.apply",
|
|
11596
|
+
idempotencyKey: input.idempotencyKey,
|
|
11597
|
+
expiresAt,
|
|
11598
|
+
secret: input.secret,
|
|
11599
|
+
};
|
|
11600
|
+
this.providerSecretChallenges.set(challenge.id, challenge);
|
|
11601
|
+
this.providerSecretChallengesByKey.set(challengeKey, challenge.id);
|
|
11602
|
+
const expiryTimer = setTimeout(() => {
|
|
11603
|
+
if (this.providerSecretChallenges.get(challenge.id) !== challenge)
|
|
11604
|
+
return;
|
|
11605
|
+
this.providerSecretChallenges.delete(challenge.id);
|
|
11606
|
+
if (this.providerSecretChallengesByKey.get(challengeKey) === challenge.id)
|
|
11607
|
+
this.providerSecretChallengesByKey.delete(challengeKey);
|
|
11608
|
+
}, SECRET_CHALLENGE_TTL_MS);
|
|
11609
|
+
expiryTimer.unref?.();
|
|
11610
|
+
return { ok: true, verification_id: challenge.id, expires_at: expiresAt, spec_id: spec.id, activation: spec.activation };
|
|
11611
|
+
}
|
|
11612
|
+
/** Naming aliases used by integrations that call this an API-key operation. */
|
|
11613
|
+
verifyProviderApiKey(input) {
|
|
11614
|
+
return this.verifyProviderSecret(input);
|
|
11615
|
+
}
|
|
11616
|
+
startProviderSecretApply(input) {
|
|
11617
|
+
for (const [jobId, job] of this.providerSecretJobs) {
|
|
11618
|
+
if (job.specId === input.specId && job.idempotencyKey === input.idempotencyKey
|
|
11619
|
+
&& this.providerSecretJobSession.get(jobId) === input.sessionBinding)
|
|
11620
|
+
return { job, reused: true };
|
|
11621
|
+
}
|
|
11622
|
+
const challenge = this.providerSecretChallenges.get(input.verificationId);
|
|
11623
|
+
const spec = providerSecretSpec(input.specId);
|
|
11624
|
+
if (!challenge || challenge.expiresAt <= Date.now()) {
|
|
11625
|
+
if (challenge)
|
|
11626
|
+
this.providerSecretChallenges.delete(input.verificationId);
|
|
11627
|
+
return { error: "verification expired; verify the secret again" };
|
|
11628
|
+
}
|
|
11629
|
+
if (!spec || challenge.specId !== spec.id || challenge.envKey !== spec.envKey || challenge.kind !== "api_key"
|
|
11630
|
+
|| challenge.sessionBinding !== input.sessionBinding || challenge.operation !== "provider-secret.apply"
|
|
11631
|
+
|| challenge.idempotencyKey !== input.idempotencyKey || challenge.generation !== this.providerSecretGeneration(challenge.envKey)) {
|
|
11632
|
+
return { error: "verification does not match this provider or session" };
|
|
11633
|
+
}
|
|
11634
|
+
const existingId = this.providerSecretInFlight.get(challenge.envKey);
|
|
11635
|
+
if (existingId) {
|
|
11636
|
+
const existing = this.providerSecretJobs.get(existingId) ?? null;
|
|
11637
|
+
if (existing?.idempotencyKey === input.idempotencyKey)
|
|
11638
|
+
return { job: existing, reused: true };
|
|
11639
|
+
return { busy: existing };
|
|
11640
|
+
}
|
|
11641
|
+
this.providerSecretChallenges.delete(input.verificationId);
|
|
11642
|
+
this.providerSecretChallengesByKey.delete(`${input.sessionBinding}:api_key:${spec.id}:${spec.envKey}:${input.idempotencyKey}`);
|
|
11643
|
+
const job = {
|
|
11644
|
+
id: opaqueId("provider_apply"), specId: spec.id, envKey: spec.envKey,
|
|
11645
|
+
idempotencyKey: input.idempotencyKey, result: "applying", status: "running", startedAt: Date.now(),
|
|
11646
|
+
stale_consumers: this.providerSecretStaleConsumers(spec.envKey),
|
|
11647
|
+
};
|
|
11648
|
+
this.providerSecretJobs.set(job.id, job);
|
|
11649
|
+
this.providerSecretJobSession.set(job.id, input.sessionBinding);
|
|
11650
|
+
this.providerSecretInFlight.set(spec.envKey, job.id);
|
|
11651
|
+
queueMicrotask(() => void this.runProviderSecretApply(job, challenge.secret));
|
|
11652
|
+
return { job, reused: false };
|
|
11653
|
+
}
|
|
11654
|
+
startProviderApiKeyApply(input) {
|
|
11655
|
+
return this.startProviderSecretApply(input);
|
|
11656
|
+
}
|
|
11657
|
+
getProviderSecretApply(jobId, sessionBinding) {
|
|
11658
|
+
if (this.providerSecretJobSession.get(jobId) !== sessionBinding)
|
|
11659
|
+
return null;
|
|
11660
|
+
return this.providerSecretJobs.get(jobId) ?? null;
|
|
11661
|
+
}
|
|
11662
|
+
getProviderApiKeyApply(jobId, sessionBinding) {
|
|
11663
|
+
return this.getProviderSecretApply(jobId, sessionBinding);
|
|
11664
|
+
}
|
|
11665
|
+
async runProviderSecretApply(job, secret) {
|
|
11666
|
+
const spec = providerSecretSpec(job.specId);
|
|
11667
|
+
const allowed = new Set([
|
|
11668
|
+
...PROVIDER_SECRET_SPECS.map(item => item.envKey),
|
|
11669
|
+
...(this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).map(channel => channel.bot_token_env),
|
|
11670
|
+
]);
|
|
11671
|
+
let store = null;
|
|
11672
|
+
let before = null;
|
|
11673
|
+
const previousProcessValue = process.env[job.envKey];
|
|
11674
|
+
let wrote = false;
|
|
11675
|
+
try {
|
|
11676
|
+
if (!spec || !this.providerSecretEnvAllowed(job.envKey))
|
|
11677
|
+
throw new Error("provider secret is not configured");
|
|
11678
|
+
// Construct inside the transaction: symlink/permission refusal must
|
|
11679
|
+
// settle the job as a safe failure, not escape the queued microtask.
|
|
11680
|
+
store = new SecretStore(join(this.dataDir, ".env"), allowed);
|
|
11681
|
+
before = store.write(job.envKey, secret);
|
|
11682
|
+
wrote = true;
|
|
11683
|
+
process.env[job.envKey] = secret;
|
|
11684
|
+
if (spec.activation === "reload_hook" && spec.reloadHookId) {
|
|
11685
|
+
await this.runProviderSecretReloadHook(spec.reloadHookId, secret, previousProcessValue);
|
|
11686
|
+
job.result = "reloaded";
|
|
11687
|
+
}
|
|
11688
|
+
else {
|
|
11689
|
+
job.result = "applied_next_use";
|
|
11690
|
+
}
|
|
11691
|
+
this.providerSecretGenerations.set(job.envKey, this.providerSecretGeneration(job.envKey) + 1);
|
|
11692
|
+
job.status = "done";
|
|
11693
|
+
job.finishedAt = Date.now();
|
|
11694
|
+
}
|
|
11695
|
+
catch (err) {
|
|
11696
|
+
const safe = safeSecretError(err, secret);
|
|
11697
|
+
this.logger.warn({ specId: job.specId, reason: safe }, "Provider API-key apply failed");
|
|
11698
|
+
try {
|
|
11699
|
+
if (wrote && before && store)
|
|
11700
|
+
store.restore(before);
|
|
11701
|
+
if (previousProcessValue === undefined)
|
|
11702
|
+
delete process.env[job.envKey];
|
|
11703
|
+
else
|
|
11704
|
+
process.env[job.envKey] = previousProcessValue;
|
|
11705
|
+
// SecretStore.write is itself transactional; when it fails before a
|
|
11706
|
+
// snapshot is returned there is no new value to roll back. Report the
|
|
11707
|
+
// truthful no-op rather than claiming rollback_failed.
|
|
11708
|
+
job.result = "rolled_back";
|
|
11709
|
+
if (!wrote || !before)
|
|
11710
|
+
job.error = "provider secret was not applied";
|
|
11711
|
+
}
|
|
11712
|
+
catch (rollbackErr) {
|
|
11713
|
+
this.logger.error({ specId: job.specId, reason: safeSecretError(rollbackErr, secret, previousProcessValue ? [previousProcessValue] : []) }, "Provider API-key rollback failed");
|
|
11714
|
+
job.result = "rollback_failed";
|
|
11715
|
+
job.error = "provider secret rollback failed; operator attention required";
|
|
11716
|
+
}
|
|
11717
|
+
job.status = "done";
|
|
11718
|
+
job.finishedAt = Date.now();
|
|
11719
|
+
}
|
|
11720
|
+
finally {
|
|
11721
|
+
this.providerSecretInFlight.delete(job.envKey);
|
|
11722
|
+
secret = "";
|
|
11723
|
+
}
|
|
11724
|
+
}
|
|
11725
|
+
/** Groq is currently read from process.env per voice request, so the hook is
|
|
11726
|
+
* intentionally a no-op. Keeping it as a named code-owned hook makes the
|
|
11727
|
+
* hot activation contract explicit and gives tests a failure seam; no generic
|
|
11728
|
+
* SIGHUP or caller-provided hook is ever executed. */
|
|
11729
|
+
async runProviderSecretReloadHook(hookId, _next, _previous) {
|
|
11730
|
+
if (hookId !== "groq.voice")
|
|
11731
|
+
throw new Error("unknown provider secret reload hook");
|
|
11732
|
+
const before = this.providerSecretHotSnapshots.get(hookId);
|
|
11733
|
+
this.providerSecretHotSnapshots.set(hookId, _next);
|
|
11734
|
+
const hook = this.providerSecretReloadHooks.get(hookId);
|
|
11735
|
+
try {
|
|
11736
|
+
if (hook)
|
|
11737
|
+
await hook(_next, before);
|
|
11738
|
+
}
|
|
11739
|
+
catch (err) {
|
|
11740
|
+
if (before === undefined)
|
|
11741
|
+
this.providerSecretHotSnapshots.delete(hookId);
|
|
11742
|
+
else
|
|
11743
|
+
this.providerSecretHotSnapshots.set(hookId, before);
|
|
11744
|
+
throw err;
|
|
11745
|
+
}
|
|
11746
|
+
}
|
|
11747
|
+
normalizeConnectionBinding(input) {
|
|
11748
|
+
// IDs arrive from JSON and may be Discord snowflakes. Do not accept a
|
|
11749
|
+
// number here: JSON.parse may already have rounded it before verification.
|
|
11750
|
+
if (typeof input.group_id !== "string")
|
|
11751
|
+
return null;
|
|
11752
|
+
const groupId = input.group_id.trim();
|
|
11753
|
+
if (!groupId || groupId.length > 128 || /[\r\n\0]/.test(groupId))
|
|
11754
|
+
return null;
|
|
11755
|
+
let general;
|
|
11756
|
+
if (input.general_channel_id === null || input.general_channel_id === undefined || input.general_channel_id === "") {
|
|
11757
|
+
general = input.general_channel_id === null ? null : undefined;
|
|
11758
|
+
}
|
|
11759
|
+
else if (typeof input.general_channel_id === "string") {
|
|
11760
|
+
general = input.general_channel_id.trim();
|
|
11761
|
+
if (!general || general.length > 128 || /[\r\n\0]/.test(general))
|
|
11762
|
+
return null;
|
|
11763
|
+
}
|
|
11764
|
+
else {
|
|
11765
|
+
return null;
|
|
11766
|
+
}
|
|
11767
|
+
return general === undefined ? { group_id: groupId } : { group_id: groupId, general_channel_id: general };
|
|
11768
|
+
}
|
|
11769
|
+
connectionBindingChannelConfig(channel, binding) {
|
|
11770
|
+
const candidate = structuredClone(channel);
|
|
11771
|
+
// IDs are intentionally normalized to strings at this boundary. Discord
|
|
11772
|
+
// snowflakes must never become YAML numbers (precision loss is silent).
|
|
11773
|
+
candidate.group_id = String(binding.group_id);
|
|
11774
|
+
if (binding.general_channel_id !== undefined) {
|
|
11775
|
+
const options = { ...(candidate.options ?? {}) };
|
|
11776
|
+
if (binding.general_channel_id === null)
|
|
11777
|
+
delete options.general_channel_id;
|
|
11778
|
+
else
|
|
11779
|
+
options.general_channel_id = String(binding.general_channel_id);
|
|
11780
|
+
if (Object.keys(options).length === 0)
|
|
11781
|
+
delete candidate.options;
|
|
11782
|
+
else
|
|
11783
|
+
candidate.options = options;
|
|
11784
|
+
}
|
|
11785
|
+
return candidate;
|
|
11786
|
+
}
|
|
11787
|
+
/** Verify a prospective group/guild binding without mutating fleet state. */
|
|
11788
|
+
async verifyConnectionBinding(input) {
|
|
11789
|
+
const channel = this.secureConnectionChannel(input.connectionId);
|
|
11790
|
+
const binding = this.normalizeConnectionBinding(input.binding);
|
|
11791
|
+
const adapter = this.adapters.get(input.connectionId);
|
|
11792
|
+
if (!channel || !binding || !adapter?.verifyBinding) {
|
|
11793
|
+
return { ok: false, error: "connection binding is unsupported or invalid" };
|
|
11794
|
+
}
|
|
11795
|
+
const key = `${input.sessionBinding}:${input.connectionId}:${input.idempotencyKey}`;
|
|
11796
|
+
const existingId = this.connectionBindingChallengesByKey.get(key);
|
|
11797
|
+
const existing = existingId ? this.connectionBindingChallenges.get(existingId) : undefined;
|
|
11798
|
+
if (existing && existing.expiresAt > Date.now()) {
|
|
11799
|
+
return { ok: true, verification_id: existing.id, expires_at: existing.expiresAt, binding: existing.binding, probe: existing.probe };
|
|
11800
|
+
}
|
|
11801
|
+
if (existingId)
|
|
11802
|
+
this.connectionBindingChallengesByKey.delete(key);
|
|
11803
|
+
const beforeGeneration = this.secureConnectionGeneration(input.connectionId);
|
|
11804
|
+
let probe;
|
|
11805
|
+
try {
|
|
11806
|
+
probe = await adapter.verifyBinding(binding.group_id, binding.general_channel_id ?? undefined);
|
|
11807
|
+
}
|
|
11808
|
+
catch (err) {
|
|
11809
|
+
this.logger.warn({ connectionId: input.connectionId, reason: safeSecretError(err) }, "Settings connection binding verification failed");
|
|
11810
|
+
return { ok: false, error: "binding verification failed" };
|
|
11811
|
+
}
|
|
11812
|
+
const afterGeneration = this.secureConnectionGeneration(input.connectionId);
|
|
11813
|
+
if (beforeGeneration !== afterGeneration || this.adapters.get(input.connectionId) !== adapter) {
|
|
11814
|
+
return { ok: false, error: "connection changed while binding was verified" };
|
|
11815
|
+
}
|
|
11816
|
+
if (probe.group_id !== binding.group_id || !probe.can_view || !probe.can_send) {
|
|
11817
|
+
return { ok: false, error: "provider did not confirm the requested binding" };
|
|
11818
|
+
}
|
|
11819
|
+
const expiresAt = Date.now() + SECRET_CHALLENGE_TTL_MS;
|
|
11820
|
+
const challenge = {
|
|
11821
|
+
id: opaqueId("binding_verify"),
|
|
11822
|
+
connectionId: input.connectionId,
|
|
11823
|
+
sessionBinding: input.sessionBinding,
|
|
11824
|
+
generation: afterGeneration,
|
|
11825
|
+
operation: "binding.apply",
|
|
11826
|
+
idempotencyKey: input.idempotencyKey,
|
|
11827
|
+
expiresAt,
|
|
11828
|
+
binding,
|
|
11829
|
+
probe,
|
|
11830
|
+
};
|
|
11831
|
+
this.connectionBindingChallenges.set(challenge.id, challenge);
|
|
11832
|
+
this.connectionBindingChallengesByKey.set(key, challenge.id);
|
|
11833
|
+
const expiryTimer = setTimeout(() => {
|
|
11834
|
+
if (this.connectionBindingChallenges.get(challenge.id) !== challenge)
|
|
11835
|
+
return;
|
|
11836
|
+
this.connectionBindingChallenges.delete(challenge.id);
|
|
11837
|
+
if (this.connectionBindingChallengesByKey.get(key) === challenge.id)
|
|
11838
|
+
this.connectionBindingChallengesByKey.delete(key);
|
|
11839
|
+
}, SECRET_CHALLENGE_TTL_MS);
|
|
11840
|
+
expiryTimer.unref?.();
|
|
11841
|
+
return { ok: true, verification_id: challenge.id, expires_at: expiresAt, binding, probe };
|
|
11842
|
+
}
|
|
11843
|
+
startConnectionBindingApply(input) {
|
|
11844
|
+
for (const [jobId, job] of this.connectionBindingJobs) {
|
|
11845
|
+
if (job.connectionId === input.connectionId && job.idempotencyKey === input.idempotencyKey
|
|
11846
|
+
&& this.connectionBindingJobSession.get(jobId) === input.sessionBinding)
|
|
11847
|
+
return { job, reused: true };
|
|
11848
|
+
}
|
|
11849
|
+
const challenge = this.connectionBindingChallenges.get(input.verificationId);
|
|
11850
|
+
if (!challenge || challenge.expiresAt <= Date.now()) {
|
|
11851
|
+
this.connectionBindingChallenges.delete(input.verificationId);
|
|
11852
|
+
return { error: "binding verification expired; verify the binding again" };
|
|
11853
|
+
}
|
|
11854
|
+
if (challenge.connectionId !== input.connectionId || challenge.sessionBinding !== input.sessionBinding
|
|
11855
|
+
|| challenge.operation !== "binding.apply" || challenge.idempotencyKey !== input.idempotencyKey
|
|
11856
|
+
|| challenge.generation !== this.secureConnectionGeneration(input.connectionId)) {
|
|
11857
|
+
return { error: "binding verification does not match this connection or session" };
|
|
11858
|
+
}
|
|
11859
|
+
const existingId = this.connectionBindingInFlight.get(input.connectionId);
|
|
11860
|
+
if (existingId) {
|
|
11861
|
+
const existing = this.connectionBindingJobs.get(existingId) ?? null;
|
|
11862
|
+
if (existing?.idempotencyKey === input.idempotencyKey)
|
|
11863
|
+
return { job: existing, reused: true };
|
|
11864
|
+
return { busy: existing };
|
|
11865
|
+
}
|
|
11866
|
+
this.connectionBindingChallenges.delete(input.verificationId);
|
|
11867
|
+
this.connectionBindingChallengesByKey.delete(`${input.sessionBinding}:${input.connectionId}:${input.idempotencyKey}`);
|
|
11868
|
+
const job = {
|
|
11869
|
+
id: opaqueId("binding_apply"), connectionId: input.connectionId, idempotencyKey: input.idempotencyKey,
|
|
11870
|
+
result: "applying", status: "running", startedAt: Date.now(),
|
|
11871
|
+
};
|
|
11872
|
+
this.connectionBindingJobs.set(job.id, job);
|
|
11873
|
+
this.connectionBindingJobSession.set(job.id, input.sessionBinding);
|
|
11874
|
+
this.connectionBindingInFlight.set(input.connectionId, job.id);
|
|
11875
|
+
queueMicrotask(() => void this.runConnectionBindingApply(job, challenge.binding));
|
|
11876
|
+
return { job, reused: false };
|
|
11877
|
+
}
|
|
11878
|
+
getConnectionBindingApply(jobId, sessionBinding) {
|
|
11879
|
+
if (this.connectionBindingJobSession.get(jobId) !== sessionBinding)
|
|
11880
|
+
return null;
|
|
11881
|
+
return this.connectionBindingJobs.get(jobId) ?? null;
|
|
11882
|
+
}
|
|
11883
|
+
async runConnectionBindingApply(job, binding) {
|
|
11884
|
+
try {
|
|
11885
|
+
await this.rebuildAdapterForBinding(job.connectionId, binding);
|
|
11886
|
+
job.result = "applied";
|
|
11887
|
+
}
|
|
11888
|
+
catch (err) {
|
|
11889
|
+
const reason = safeSecretError(err);
|
|
11890
|
+
job.result = /rollback failed/i.test(reason) ? "rollback_failed" : "rolled_back";
|
|
11891
|
+
job.error = job.result === "rollback_failed"
|
|
11892
|
+
? "binding rollback failed; adapter requires operator attention"
|
|
11893
|
+
: "binding was not applied; previous binding was restored";
|
|
11894
|
+
this.logger.warn({ connectionId: job.connectionId, reason: safeSecretError(err) }, "Settings connection binding apply failed");
|
|
11895
|
+
}
|
|
11896
|
+
finally {
|
|
11897
|
+
job.status = "done";
|
|
11898
|
+
job.finishedAt = Date.now();
|
|
11899
|
+
this.connectionBindingInFlight.delete(job.connectionId);
|
|
11900
|
+
}
|
|
11901
|
+
}
|
|
11902
|
+
/** Stop, rebuild and wait for a new adapter before committing YAML binding. */
|
|
11903
|
+
async rebuildAdapterForBinding(connectionId, binding) {
|
|
11904
|
+
const channel = this.secureConnectionChannel(connectionId);
|
|
11905
|
+
if (!channel || !this.fleetConfig)
|
|
11906
|
+
throw new Error("connection not found");
|
|
11907
|
+
const candidate = this.connectionBindingChannelConfig(channel, binding);
|
|
11908
|
+
const oldAdapter = this.adapters.get(connectionId);
|
|
11909
|
+
const oldWorld = this.worlds.get(connectionId);
|
|
11910
|
+
const oldPrimary = this.adapter;
|
|
11911
|
+
const oldAccess = this.accessManager;
|
|
11912
|
+
const oldState = this.adapterState.get(connectionId);
|
|
11913
|
+
const oldChannel = structuredClone(channel);
|
|
11914
|
+
const primary = this.getPrimaryAdapterId() === connectionId;
|
|
11915
|
+
if (primary && this.sessionPruneTimer) {
|
|
11916
|
+
clearInterval(this.sessionPruneTimer);
|
|
11917
|
+
this.sessionPruneTimer = null;
|
|
11918
|
+
}
|
|
11919
|
+
let fresh;
|
|
11920
|
+
let persistedBinding = false;
|
|
11921
|
+
try {
|
|
11922
|
+
this.adapterState.set(connectionId, { status: "retrying", retryCount: oldState?.retryCount ?? 0 });
|
|
11923
|
+
if (oldAdapter) {
|
|
11924
|
+
oldAdapter.removeAllListeners();
|
|
11925
|
+
await oldAdapter.stop().catch(() => { });
|
|
11926
|
+
if (this.adapters.get(connectionId) === oldAdapter)
|
|
11927
|
+
this.adapters.delete(connectionId);
|
|
11928
|
+
if (this.worlds.get(connectionId)?.adapter === oldAdapter)
|
|
11929
|
+
this.worlds.delete(connectionId);
|
|
11930
|
+
if (primary && this.adapter === oldAdapter)
|
|
11931
|
+
this.adapter = null;
|
|
11932
|
+
}
|
|
11933
|
+
let startedResolve = null;
|
|
11934
|
+
const started = new Promise(resolve => { startedResolve = resolve; });
|
|
11935
|
+
const onStarted = () => { startedResolve?.(); };
|
|
11936
|
+
if (primary)
|
|
11937
|
+
await this.startSingleAdapter(this.fleetConfig, candidate, onStarted);
|
|
11938
|
+
else
|
|
11939
|
+
await this.startAdditionalAdapter(candidate, true, onStarted);
|
|
11940
|
+
fresh = this.adapters.get(connectionId);
|
|
11941
|
+
if (!fresh)
|
|
11942
|
+
throw new Error("new adapter did not start");
|
|
11943
|
+
const deadline = Date.now() + 15_000;
|
|
11944
|
+
if (!fresh.getHealthSnapshot) {
|
|
11945
|
+
let timer;
|
|
11946
|
+
const timeout = new Promise((_, reject) => {
|
|
11947
|
+
timer = setTimeout(() => reject(new Error("new adapter did not become ready")), Math.max(1, deadline - Date.now()));
|
|
11948
|
+
timer.unref?.();
|
|
11949
|
+
});
|
|
11950
|
+
try {
|
|
11951
|
+
await Promise.race([started, timeout]);
|
|
11952
|
+
}
|
|
11953
|
+
finally {
|
|
11954
|
+
if (timer)
|
|
11955
|
+
clearTimeout(timer);
|
|
11956
|
+
}
|
|
11957
|
+
}
|
|
11958
|
+
else {
|
|
11959
|
+
while (Date.now() < deadline) {
|
|
11960
|
+
const health = fresh.getHealthSnapshot?.();
|
|
11961
|
+
if (health?.status === "connected" || this.adapterState.get(connectionId)?.status === "connected")
|
|
11962
|
+
break;
|
|
11963
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
11964
|
+
}
|
|
11965
|
+
const health = fresh.getHealthSnapshot?.();
|
|
11966
|
+
if (health && health.status !== "connected" && this.adapterState.get(connectionId)?.status !== "connected") {
|
|
11967
|
+
throw new Error("new adapter did not become connected");
|
|
11968
|
+
}
|
|
11969
|
+
}
|
|
11970
|
+
if (fresh.setChatId)
|
|
11971
|
+
fresh.setChatId(String(candidate.group_id));
|
|
11972
|
+
// Commit only after the replacement adapter is ready. No allowlist,
|
|
11973
|
+
// topic, instance or schedule fields are touched here.
|
|
11974
|
+
channel.group_id = String(binding.group_id);
|
|
11975
|
+
if (binding.general_channel_id !== undefined) {
|
|
11976
|
+
const options = { ...(channel.options ?? {}) };
|
|
11977
|
+
if (binding.general_channel_id === null)
|
|
11978
|
+
delete options.general_channel_id;
|
|
11979
|
+
else
|
|
11980
|
+
options.general_channel_id = String(binding.general_channel_id);
|
|
11981
|
+
if (Object.keys(options).length === 0)
|
|
11982
|
+
delete channel.options;
|
|
11983
|
+
else
|
|
11984
|
+
channel.options = options;
|
|
11985
|
+
}
|
|
11986
|
+
this.saveFleetConfig();
|
|
11987
|
+
persistedBinding = true;
|
|
11988
|
+
this.routing.rebuild(this.fleetConfig);
|
|
11989
|
+
this.reregisterClassicChannels();
|
|
11990
|
+
this.adapterState.set(connectionId, { status: "connected", retryCount: 0 });
|
|
11991
|
+
}
|
|
11992
|
+
catch (err) {
|
|
11993
|
+
if (fresh && fresh !== oldAdapter)
|
|
11994
|
+
await fresh.stop().catch(() => { });
|
|
11995
|
+
// Restore only the binding object in memory; unrelated connection and
|
|
11996
|
+
// instance state remains exactly as it was before the attempt.
|
|
11997
|
+
for (const key of Object.keys(channel)) {
|
|
11998
|
+
if (!(key in oldChannel))
|
|
11999
|
+
delete channel[key];
|
|
12000
|
+
}
|
|
12001
|
+
Object.assign(channel, oldChannel);
|
|
12002
|
+
this.adapters.delete(connectionId);
|
|
12003
|
+
this.worlds.delete(connectionId);
|
|
12004
|
+
this.adapterState.delete(connectionId);
|
|
12005
|
+
if (oldAdapter) {
|
|
12006
|
+
try {
|
|
12007
|
+
const onStarted = () => { };
|
|
12008
|
+
if (primary)
|
|
12009
|
+
await this.startSingleAdapter(this.fleetConfig, oldChannel, onStarted);
|
|
12010
|
+
else
|
|
12011
|
+
await this.startAdditionalAdapter(oldChannel, true, onStarted);
|
|
12012
|
+
this.adapterState.set(connectionId, oldState ?? { status: "connected", retryCount: 0 });
|
|
12013
|
+
}
|
|
12014
|
+
catch (restoreErr) {
|
|
12015
|
+
throw new Error(`binding rollback failed: ${safeSecretError(restoreErr)}`);
|
|
12016
|
+
}
|
|
12017
|
+
}
|
|
12018
|
+
else {
|
|
12019
|
+
if (primary)
|
|
12020
|
+
this.adapter = oldPrimary;
|
|
12021
|
+
if (oldWorld)
|
|
12022
|
+
this.worlds.set(connectionId, oldWorld);
|
|
12023
|
+
if (oldAdapter)
|
|
12024
|
+
this.adapters.set(connectionId, oldAdapter);
|
|
12025
|
+
this.accessManager = oldAccess;
|
|
12026
|
+
}
|
|
12027
|
+
// The binding is committed to YAML before routing is rebuilt. If the
|
|
12028
|
+
// post-commit rebuild fails, restore the durable document as well as the
|
|
12029
|
+
// in-memory channel; otherwise a reload would resurrect the failed
|
|
12030
|
+
// binding that the running fleet just rolled back.
|
|
12031
|
+
if (persistedBinding)
|
|
12032
|
+
this.saveFleetConfig();
|
|
12033
|
+
this.routing.rebuild(this.fleetConfig);
|
|
12034
|
+
this.reregisterClassicChannels();
|
|
12035
|
+
throw err;
|
|
12036
|
+
}
|
|
12037
|
+
}
|
|
12038
|
+
async verifyConnectionSecret(input) {
|
|
12039
|
+
const channel = this.secureConnectionChannel(input.connectionId);
|
|
12040
|
+
if (!channel || (channel.type !== "discord" && channel.type !== "telegram")) {
|
|
12041
|
+
return { ok: false, error: "connection not found or unsupported" };
|
|
12042
|
+
}
|
|
12043
|
+
if (!input.secret || input.secret.length > 4096 || /[\r\n\0]/.test(input.secret)) {
|
|
12044
|
+
return { ok: false, error: "secret is invalid" };
|
|
12045
|
+
}
|
|
12046
|
+
const challengeKey = `${input.sessionBinding}:${input.connectionId}:${input.idempotencyKey}`;
|
|
12047
|
+
const existingId = this.connectionSecretChallengesByKey.get(challengeKey);
|
|
12048
|
+
const existing = existingId ? this.connectionSecretChallenges.get(existingId) : undefined;
|
|
12049
|
+
if (existing && existing.expiresAt > Date.now()) {
|
|
12050
|
+
return { ok: true, verification_id: existing.id, expires_at: existing.expiresAt };
|
|
12051
|
+
}
|
|
12052
|
+
if (existingId)
|
|
12053
|
+
this.connectionSecretChallengesByKey.delete(challengeKey);
|
|
12054
|
+
// Fixed provider endpoints only. Never use a user-supplied URL and never
|
|
12055
|
+
// call Telegram getUpdates (the running adapter owns that long poll).
|
|
12056
|
+
const identity = channel.type === "discord"
|
|
12057
|
+
? await verifyDiscordToken(input.secret)
|
|
12058
|
+
: await verifyTelegramToken(input.secret);
|
|
12059
|
+
if (!identity.valid) {
|
|
12060
|
+
this.logger.warn({ connectionId: input.connectionId, provider: channel.type }, "Settings connection secret verification failed");
|
|
12061
|
+
return { ok: false, error: "provider rejected the secret" };
|
|
12062
|
+
}
|
|
12063
|
+
const expiresAt = Date.now() + SECRET_CHALLENGE_TTL_MS;
|
|
12064
|
+
const challenge = {
|
|
12065
|
+
id: opaqueId("verify"),
|
|
12066
|
+
connectionId: input.connectionId,
|
|
12067
|
+
sessionBinding: input.sessionBinding,
|
|
12068
|
+
generation: this.secureConnectionGeneration(input.connectionId),
|
|
12069
|
+
operation: "secret.apply",
|
|
12070
|
+
idempotencyKey: input.idempotencyKey,
|
|
12071
|
+
expiresAt,
|
|
12072
|
+
secret: input.secret,
|
|
12073
|
+
};
|
|
12074
|
+
this.connectionSecretChallenges.set(challenge.id, challenge);
|
|
12075
|
+
this.connectionSecretChallengesByKey.set(challengeKey, challenge.id);
|
|
12076
|
+
const expiryTimer = setTimeout(() => {
|
|
12077
|
+
if (this.connectionSecretChallenges.get(challenge.id) !== challenge)
|
|
12078
|
+
return;
|
|
12079
|
+
this.connectionSecretChallenges.delete(challenge.id);
|
|
12080
|
+
if (this.connectionSecretChallengesByKey.get(challengeKey) === challenge.id) {
|
|
12081
|
+
this.connectionSecretChallengesByKey.delete(challengeKey);
|
|
12082
|
+
}
|
|
12083
|
+
}, SECRET_CHALLENGE_TTL_MS);
|
|
12084
|
+
expiryTimer.unref?.();
|
|
12085
|
+
return {
|
|
12086
|
+
ok: true,
|
|
12087
|
+
verification_id: challenge.id,
|
|
12088
|
+
expires_at: expiresAt,
|
|
12089
|
+
identity: { id: identity.id, username: identity.username },
|
|
12090
|
+
};
|
|
12091
|
+
}
|
|
12092
|
+
startConnectionSecretApply(input) {
|
|
12093
|
+
for (const [jobId, job] of this.connectionSecretJobs) {
|
|
12094
|
+
if (job.connectionId === input.connectionId
|
|
12095
|
+
&& job.idempotencyKey === input.idempotencyKey
|
|
12096
|
+
&& this.connectionSecretJobSession.get(jobId) === input.sessionBinding) {
|
|
12097
|
+
return { job, reused: true };
|
|
12098
|
+
}
|
|
12099
|
+
}
|
|
12100
|
+
const challenge = this.connectionSecretChallenges.get(input.verificationId);
|
|
12101
|
+
if (!challenge || challenge.expiresAt <= Date.now()) {
|
|
12102
|
+
this.connectionSecretChallenges.delete(input.verificationId);
|
|
12103
|
+
return { error: "verification expired; verify the secret again" };
|
|
12104
|
+
}
|
|
12105
|
+
if (challenge.connectionId !== input.connectionId
|
|
12106
|
+
|| challenge.sessionBinding !== input.sessionBinding
|
|
12107
|
+
|| challenge.operation !== "secret.apply"
|
|
12108
|
+
|| challenge.idempotencyKey !== input.idempotencyKey
|
|
12109
|
+
|| challenge.generation !== this.secureConnectionGeneration(input.connectionId)) {
|
|
12110
|
+
return { error: "verification does not match this connection or session" };
|
|
12111
|
+
}
|
|
12112
|
+
const existingId = this.connectionSecretInFlight.get(input.connectionId);
|
|
12113
|
+
if (existingId) {
|
|
12114
|
+
const existing = this.connectionSecretJobs.get(existingId) ?? null;
|
|
12115
|
+
if (existing?.idempotencyKey === input.idempotencyKey)
|
|
12116
|
+
return { job: existing, reused: true };
|
|
12117
|
+
return { busy: existing };
|
|
12118
|
+
}
|
|
12119
|
+
// Consume the challenge before scheduling work. A lost HTTP response can
|
|
12120
|
+
// retry with the same idempotency key and rejoin the job, but a second
|
|
12121
|
+
// request cannot replay the secret into a second adapter.
|
|
12122
|
+
this.connectionSecretChallenges.delete(input.verificationId);
|
|
12123
|
+
this.connectionSecretChallengesByKey.delete(`${input.sessionBinding}:${input.connectionId}:${input.idempotencyKey}`);
|
|
12124
|
+
const job = {
|
|
12125
|
+
id: opaqueId("secret_apply"),
|
|
12126
|
+
connectionId: input.connectionId,
|
|
12127
|
+
idempotencyKey: input.idempotencyKey,
|
|
12128
|
+
result: "applying",
|
|
12129
|
+
status: "running",
|
|
12130
|
+
startedAt: Date.now(),
|
|
12131
|
+
};
|
|
12132
|
+
this.connectionSecretJobs.set(job.id, job);
|
|
12133
|
+
this.connectionSecretJobSession.set(job.id, input.sessionBinding);
|
|
12134
|
+
this.connectionSecretInFlight.set(input.connectionId, job.id);
|
|
12135
|
+
queueMicrotask(() => void this.runConnectionSecretApply(job, challenge.secret));
|
|
12136
|
+
return { job, reused: false };
|
|
12137
|
+
}
|
|
12138
|
+
getConnectionSecretApply(jobId, sessionBinding) {
|
|
12139
|
+
if (this.connectionSecretJobSession.get(jobId) !== sessionBinding)
|
|
12140
|
+
return null;
|
|
12141
|
+
return this.connectionSecretJobs.get(jobId) ?? null;
|
|
12142
|
+
}
|
|
12143
|
+
async runConnectionSecretApply(job, secret) {
|
|
12144
|
+
const channel = this.secureConnectionChannel(job.connectionId);
|
|
12145
|
+
const envKey = channel?.bot_token_env;
|
|
12146
|
+
const allowed = new Set((this.fleetConfig?.channels
|
|
12147
|
+
?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []))
|
|
12148
|
+
.map(item => item.bot_token_env));
|
|
12149
|
+
let before = null;
|
|
12150
|
+
let oldToken;
|
|
12151
|
+
let replaced = false;
|
|
12152
|
+
try {
|
|
12153
|
+
if (!channel || !envKey || !allowed.has(envKey))
|
|
12154
|
+
throw new Error("connection is not configured for secret rotation");
|
|
12155
|
+
const owners = (this.fleetConfig?.channels
|
|
12156
|
+
?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []))
|
|
12157
|
+
.filter(item => item.bot_token_env === envKey);
|
|
12158
|
+
if (owners.length !== 1)
|
|
12159
|
+
throw new Error("secret key is shared by multiple connections");
|
|
12160
|
+
const store = new SecretStore(join(this.dataDir, ".env"), allowed);
|
|
12161
|
+
before = store.write(envKey, secret);
|
|
12162
|
+
replaced = true;
|
|
12163
|
+
oldToken = process.env[envKey];
|
|
12164
|
+
process.env[envKey] = secret;
|
|
12165
|
+
const generation = this.secureConnectionGeneration(job.connectionId) + 1;
|
|
12166
|
+
this.connectionSecretGenerations.set(job.connectionId, generation);
|
|
12167
|
+
const applied = await this.rebuildAdapterForSecret(job.connectionId, channel);
|
|
12168
|
+
if (!applied) {
|
|
12169
|
+
job.result = "restart_required";
|
|
12170
|
+
job.status = "done";
|
|
12171
|
+
job.finishedAt = Date.now();
|
|
12172
|
+
return;
|
|
12173
|
+
}
|
|
12174
|
+
job.result = "applied";
|
|
12175
|
+
job.status = "done";
|
|
12176
|
+
job.finishedAt = Date.now();
|
|
12177
|
+
}
|
|
12178
|
+
catch (err) {
|
|
12179
|
+
const message = safeSecretError(err, secret);
|
|
12180
|
+
this.logger.warn({ connectionId: job.connectionId, reason: message }, "Settings connection secret apply failed");
|
|
12181
|
+
if (!replaced || !before || !envKey) {
|
|
12182
|
+
job.result = "rollback_failed";
|
|
12183
|
+
job.error = "secret was not applied";
|
|
12184
|
+
}
|
|
12185
|
+
else {
|
|
12186
|
+
try {
|
|
12187
|
+
const store = new SecretStore(join(this.dataDir, ".env"), new Set([envKey]));
|
|
12188
|
+
store.restore(before);
|
|
12189
|
+
if (oldToken === undefined)
|
|
12190
|
+
delete process.env[envKey];
|
|
12191
|
+
else
|
|
12192
|
+
process.env[envKey] = oldToken;
|
|
12193
|
+
// Build a fresh adapter from the restored token. If the old adapter
|
|
12194
|
+
// was stopped already, this is the only safe way to return to the
|
|
12195
|
+
// previous runtime without claiming a disk-only rollback succeeded.
|
|
12196
|
+
const restored = await this.rebuildAdapterForSecret(job.connectionId, channel, true);
|
|
12197
|
+
if (!restored)
|
|
12198
|
+
throw new Error("adapter rollback did not become connected");
|
|
12199
|
+
job.result = "rolled_back";
|
|
12200
|
+
}
|
|
12201
|
+
catch (rollbackErr) {
|
|
12202
|
+
this.logger.error({ connectionId: job.connectionId, reason: safeSecretError(rollbackErr, oldToken, [secret]) }, "Settings connection secret rollback failed");
|
|
12203
|
+
job.result = "rollback_failed";
|
|
12204
|
+
job.error = "secret rollback failed; adapter is disabled";
|
|
12205
|
+
}
|
|
12206
|
+
}
|
|
12207
|
+
job.status = "done";
|
|
12208
|
+
job.finishedAt = Date.now();
|
|
12209
|
+
}
|
|
12210
|
+
finally {
|
|
12211
|
+
this.connectionSecretInFlight.delete(job.connectionId);
|
|
12212
|
+
// Do not retain the token after the apply (success or rollback).
|
|
12213
|
+
secret = "";
|
|
12214
|
+
}
|
|
12215
|
+
}
|
|
12216
|
+
/** Stop the old provider client and construct a new one from process.env. */
|
|
12217
|
+
async rebuildAdapterForSecret(connectionId, channel, force = false) {
|
|
12218
|
+
const old = this.adapters.get(connectionId);
|
|
12219
|
+
if (!old && !force)
|
|
12220
|
+
return false; // The secret is valid on disk; the next start adopts it.
|
|
12221
|
+
const primary = this.getPrimaryAdapterId() === connectionId;
|
|
12222
|
+
const previousState = this.adapterState.get(connectionId);
|
|
12223
|
+
this.adapterState.set(connectionId, { status: "retrying", retryCount: previousState?.retryCount ?? 0 });
|
|
12224
|
+
if (primary && this.sessionPruneTimer) {
|
|
12225
|
+
clearInterval(this.sessionPruneTimer);
|
|
12226
|
+
this.sessionPruneTimer = null;
|
|
12227
|
+
}
|
|
12228
|
+
if (old) {
|
|
12229
|
+
old.removeAllListeners();
|
|
12230
|
+
await old.stop().catch(() => { });
|
|
12231
|
+
if (this.adapters.get(connectionId) === old)
|
|
12232
|
+
this.adapters.delete(connectionId);
|
|
12233
|
+
if (this.worlds.get(connectionId)?.adapter === old)
|
|
12234
|
+
this.worlds.delete(connectionId);
|
|
12235
|
+
if (primary && this.adapter === old)
|
|
12236
|
+
this.adapter = null;
|
|
12237
|
+
}
|
|
12238
|
+
let startedResolve = null;
|
|
12239
|
+
const started = new Promise(resolve => { startedResolve = resolve; });
|
|
12240
|
+
const onStarted = () => { startedResolve?.(); };
|
|
12241
|
+
if (primary)
|
|
12242
|
+
await this.startSingleAdapter(this.fleetConfig, channel, onStarted);
|
|
12243
|
+
else
|
|
12244
|
+
await this.startAdditionalAdapter(channel, true, onStarted);
|
|
12245
|
+
const fresh = this.adapters.get(connectionId);
|
|
12246
|
+
if (!fresh)
|
|
12247
|
+
throw new Error("new adapter did not start");
|
|
12248
|
+
const deadline = Date.now() + 15_000;
|
|
12249
|
+
// Telegram has no gateway health snapshot. Its start() method launches the
|
|
12250
|
+
// grammY polling loop in the background, so completion of start() is not a
|
|
12251
|
+
// connected signal. The adapter's `started` event is emitted only after the
|
|
12252
|
+
// first provider getMe succeeds; require that event before claiming apply.
|
|
12253
|
+
if (!fresh.getHealthSnapshot) {
|
|
12254
|
+
let timer;
|
|
12255
|
+
const timeout = new Promise((_, reject) => {
|
|
12256
|
+
timer = setTimeout(() => reject(new Error("new adapter did not emit started before deadline")), Math.max(1, deadline - Date.now()));
|
|
12257
|
+
timer.unref?.();
|
|
12258
|
+
});
|
|
12259
|
+
try {
|
|
12260
|
+
await Promise.race([started, timeout]);
|
|
12261
|
+
}
|
|
12262
|
+
finally {
|
|
12263
|
+
if (timer)
|
|
12264
|
+
clearTimeout(timer);
|
|
12265
|
+
}
|
|
12266
|
+
this.adapterState.set(connectionId, { status: "connected", retryCount: 0 });
|
|
12267
|
+
return true;
|
|
12268
|
+
}
|
|
12269
|
+
while (Date.now() < deadline) {
|
|
12270
|
+
const health = fresh.getHealthSnapshot?.();
|
|
12271
|
+
if (health?.status === "connected" || this.adapterState.get(connectionId)?.status === "connected")
|
|
12272
|
+
return true;
|
|
12273
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
12274
|
+
}
|
|
12275
|
+
throw new Error("new adapter did not become connected");
|
|
12276
|
+
}
|
|
11328
12277
|
/**
|
|
11329
12278
|
* What a reconcile is about to do, per target.
|
|
11330
12279
|
*
|