@songsid/agend 2.1.10-beta.4 → 2.1.10-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,7 @@ import { Worker } from "node:worker_threads";
7
7
  import { join, dirname, basename, delimiter, isAbsolute } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
10
+ import { fleetLabel } from "./fleet-label.js";
10
11
  import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, updateProgressOperation, } from "./update-marker.js";
11
12
  import { formatUpdateProgress } from "./update-progress.js";
12
13
  import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
@@ -206,6 +207,11 @@ const IGNORED_REACTION_EMOJIS = new Set(["📷"]);
206
207
  */
207
208
  const IPC_RECONNECT_GRACE_MS = 30_000;
208
209
  const IPC_RECONNECT_POLL_MS = 250;
210
+ /** The prefix of a button's callback data, for logs (never the nonce). */
211
+ function callbackPrefix(callbackData) {
212
+ const colon = callbackData.indexOf(":");
213
+ return colon === -1 ? "(none)" : callbackData.slice(0, colon + 1);
214
+ }
209
215
  const CLASSIC_BACKEND_SELECTION_TIMEOUT_MS = 60_000;
210
216
  const CLASSIC_BACKEND_CALLBACK_PREFIX = "classic-backend:";
211
217
  const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
@@ -217,11 +223,12 @@ const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
217
223
  const TIP_DISMISS_CALLBACK_PREFIX = "tip-dismiss:";
218
224
  const TIP_UNLOCK_CALLBACK_PREFIX = "tip-unlock:";
219
225
  export const LOGIN_CALLBACK_PREFIX = "login:";
220
- const INSTALL_CALLBACK_PREFIX = "install-select:";
226
+ /** Prompt kinds `/install-cli` used, before it became part of `/login` (#1131). */
227
+ const RETIRED_PROMPT_PREFIXES = ["install-select:", "install-login:"];
228
+ const NEVER_MATCHES = /(?!)/;
221
229
  const CLASSIC_APPROVE_CALLBACK_PREFIX = "classic-approve:";
222
230
  const LOGIN_MENU_CALLBACK_PREFIX = "login-menu:";
223
231
  const LOGIN_CONFIRM_CALLBACK_PREFIX = "login-confirm:";
224
- const INSTALL_LOGIN_CALLBACK_PREFIX = "install-login:";
225
232
  const CLEAR_CONFIRM_TIMEOUT_MS = 15_000;
226
233
  /** Default lifetime for long-lived nonce prompts (clear overrides this to 15s). */
227
234
  const NONCE_BUTTON_TIMEOUT_MS = 15 * 60_000;
@@ -382,6 +389,8 @@ export class FleetManager {
382
389
  instanceStateCache = new Map();
383
390
  /** CLI pane status overrides; daemon.pid alone only proves FleetManager lives. */
384
391
  instanceProcessStatus = new Map();
392
+ /** Adapters whose last slash command registration failed and General was told (#1131). */
393
+ slashRegistrationFailed = new Set();
385
394
  /** Instances currently being auto-paused by warm_cap, so concurrent checks don't double-evict. */
386
395
  warmCapEvicting = new Set();
387
396
  /** Per-instance tail keeps cross-instance and scheduled deliveries FIFO. */
@@ -2667,7 +2676,7 @@ export class FleetManager {
2667
2676
  /**
2668
2677
  * Probe the same executable set exposed by the web backend catalog.
2669
2678
  *
2670
- * This deliberately has no cache: an `/install-cli` completion can add a
2679
+ * This deliberately has no cache: an install `/login` ran can add a
2671
2680
  * binary to PATH while the fleet process remains alive, and the next bare
2672
2681
  * `/login` must see it without requiring a restart or an explicit cache
2673
2682
  * invalidation call.
@@ -3910,6 +3919,29 @@ export class FleetManager {
3910
3919
  return this.adapterState;
3911
3920
  }
3912
3921
  bindAdapterHealth(adapter, adapterId) {
3922
+ // Discord slash command registration, every gateway ready (#1131). A
3923
+ // rejected registration leaves the previous command list in place, so a
3924
+ // new command silently never appears: log every outcome, and tell General
3925
+ // once per failure streak (a reconnect loop must not repeat it).
3926
+ adapter.on("slash_registration", (outcome) => {
3927
+ if (this.adapters.get(adapterId) !== adapter)
3928
+ return;
3929
+ if (outcome.ok) {
3930
+ this.slashRegistrationFailed.delete(adapterId);
3931
+ this.logger.info({ adapterId, count: outcome.count }, "Registered Discord slash commands");
3932
+ return;
3933
+ }
3934
+ this.logger.warn({ adapterId, code: outcome.code, status: outcome.status, error: outcome.message }, "Discord rejected the slash command registration; the previous command list stays");
3935
+ if (this.slashRegistrationFailed.has(adapterId))
3936
+ return;
3937
+ const detail = [outcome.code, outcome.message].filter(v => v !== undefined && v !== "").join(" ");
3938
+ // The streak is the de-duplication (not the shared text throttle), and
3939
+ // it counts as told only once General actually got it: with nowhere to
3940
+ // post yet, the next failure tries again.
3941
+ if (this.notifyFleetError(t("discord.slash_registration_failed", adapterId, detail), { throttle: false })) {
3942
+ this.slashRegistrationFailed.add(adapterId);
3943
+ }
3944
+ });
3913
3945
  adapter.on("gateway_health", (snapshot) => {
3914
3946
  // A token rotation tears down the old EventEmitter before constructing
3915
3947
  // the replacement. A late health frame from that old client must never
@@ -4016,6 +4048,7 @@ export class FleetManager {
4016
4048
  botToken,
4017
4049
  accessManager,
4018
4050
  inboxDir,
4051
+ fleetLabel: fleetLabel(this.fleetConfig),
4019
4052
  });
4020
4053
  const adapter = this.adapter;
4021
4054
  const world = new AdapterWorld(adapterId, adapter, accessManager, channelConfig);
@@ -4034,44 +4067,7 @@ export class FleetManager {
4034
4067
  await this.handleInboundReaction(r);
4035
4068
  }, this.logger, "adapter.reaction"));
4036
4069
  this.adapter.on("callback_query", safeHandler(async (data) => {
4037
- if (!isCurrentAdapter())
4038
- return;
4039
- if (await this.handleTipDismiss(data, adapterId, this.adapter ?? undefined))
4040
- return;
4041
- if (await this.handleTipUnlock(data, adapterId, this.adapter ?? undefined))
4042
- return;
4043
- if (await this.handleLoginBackendSelect(data, adapterId, this.adapter ?? undefined))
4044
- return;
4045
- if (await this.handleInstallBackendSelect(data, adapterId, this.adapter ?? undefined))
4046
- return;
4047
- if (await this.handleClassicApproval(data, adapterId, this.adapter ?? undefined))
4048
- return;
4049
- if (await this.handleLoginMenuSelect(data, adapterId, this.adapter ?? undefined))
4050
- return;
4051
- if (await this.handleLoginConfirm(data, adapterId, this.adapter ?? undefined))
4052
- return;
4053
- if (await this.handleLoginTokenResend(data, adapterId, this.adapter ?? undefined))
4054
- return;
4055
- if (await this.handleInstallLoginConfirm(data, adapterId, this.adapter ?? undefined))
4056
- return;
4057
- if (await this.handleClearConfirmation(data, adapterId, this.adapter ?? undefined))
4058
- return;
4059
- if (await this.handleExitRestartPrompt(data, adapterId, this.adapter ?? undefined))
4060
- return;
4061
- if (await this.handleInteractivePromptAssist(data, adapterId, this.adapter ?? undefined))
4062
- return;
4063
- if (await this.handleClassicBackendSelection(data))
4064
- return;
4065
- if (await this.handleModelSelection(data))
4066
- return;
4067
- if (await this.handleEffortSelection(data))
4068
- return;
4069
- if (await this.handleHangPrompt(data, adapterId, this.adapter ?? undefined))
4070
- return;
4071
- if (data.callbackData.startsWith("cancel:")) {
4072
- this.handleCancelClick(data.callbackData.slice("cancel:".length), this.adapter, data);
4073
- return;
4074
- }
4070
+ await this.receiveAdapterCallback(data, adapterId, this.adapter ?? undefined, isCurrentAdapter);
4075
4071
  }, this.logger, "adapter.callback_query"));
4076
4072
  this.bindTopicClosedHandler(adapter, adapterId, "adapter.topic_closed");
4077
4073
  // Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
@@ -4183,9 +4179,6 @@ export class FleetManager {
4183
4179
  else if (data.command === "login") {
4184
4180
  await this.handleLoginSlash(data, adapterId, this.adapter);
4185
4181
  }
4186
- else if (data.command === "install-cli") {
4187
- await this.handleInstallCliSlash(data, adapterId, this.adapter);
4188
- }
4189
4182
  else if (data.command === "clear") {
4190
4183
  await this.handleClearSlash(data, adapterId);
4191
4184
  }
@@ -4348,9 +4341,6 @@ export class FleetManager {
4348
4341
  else if (data.command === "login") {
4349
4342
  await this.handleLoginSlash(data, adapterId, this.adapter);
4350
4343
  }
4351
- else if (data.command === "install-cli") {
4352
- await this.handleInstallCliSlash(data, adapterId, this.adapter);
4353
- }
4354
4344
  }, this.logger, "adapter.slash_command"));
4355
4345
  await this.topicCommands.registerBotCommands().catch(e => this.logger.warn({ err: e }, "registerBotCommands failed (non-fatal)"));
4356
4346
  // Background-probe each backend's CLI env (version/models) → cli-env cache.
@@ -4428,6 +4418,7 @@ export class FleetManager {
4428
4418
  accessManager,
4429
4419
  inboxDir,
4430
4420
  registerCommands,
4421
+ fleetLabel: fleetLabel(this.fleetConfig),
4431
4422
  });
4432
4423
  const world = new AdapterWorld(adapterId, adapter, accessManager, channelConfig);
4433
4424
  this.worlds.set(adapterId, world);
@@ -4446,44 +4437,7 @@ export class FleetManager {
4446
4437
  await this.handleInboundReaction(r);
4447
4438
  }, this.logger, `adapter[${adapterId}].reaction`));
4448
4439
  adapter.on("callback_query", safeHandler(async (data) => {
4449
- if (!isCurrentAdapter())
4450
- return;
4451
- if (await this.handleTipDismiss(data, adapterId, adapter))
4452
- return;
4453
- if (await this.handleTipUnlock(data, adapterId, adapter))
4454
- return;
4455
- if (await this.handleLoginBackendSelect(data, adapterId, adapter))
4456
- return;
4457
- if (await this.handleInstallBackendSelect(data, adapterId, adapter))
4458
- return;
4459
- if (await this.handleClassicApproval(data, adapterId, adapter))
4460
- return;
4461
- if (await this.handleLoginMenuSelect(data, adapterId, adapter))
4462
- return;
4463
- if (await this.handleLoginConfirm(data, adapterId, adapter))
4464
- return;
4465
- if (await this.handleLoginTokenResend(data, adapterId, adapter))
4466
- return;
4467
- if (await this.handleInstallLoginConfirm(data, adapterId, adapter))
4468
- return;
4469
- if (await this.handleClearConfirmation(data, adapterId, adapter))
4470
- return;
4471
- if (await this.handleExitRestartPrompt(data, adapterId, adapter))
4472
- return;
4473
- if (await this.handleInteractivePromptAssist(data, adapterId, adapter))
4474
- return;
4475
- if (await this.handleClassicBackendSelection(data))
4476
- return;
4477
- if (await this.handleModelSelection(data))
4478
- return;
4479
- if (await this.handleEffortSelection(data))
4480
- return;
4481
- if (await this.handleHangPrompt(data, adapterId, adapter))
4482
- return;
4483
- if (data.callbackData.startsWith("cancel:")) {
4484
- this.handleCancelClick(data.callbackData.slice("cancel:".length), adapter, data);
4485
- return;
4486
- }
4440
+ await this.receiveAdapterCallback(data, adapterId, adapter, isCurrentAdapter);
4487
4441
  }, this.logger, `adapter[${adapterId}].callback_query`));
4488
4442
  this.bindTopicClosedHandler(adapter, adapterId, `adapter[${adapterId}].topic_closed`);
4489
4443
  // Slash commands: classic bot + admin commands
@@ -4708,9 +4662,6 @@ export class FleetManager {
4708
4662
  else if (data.command === "login") {
4709
4663
  await this.handleLoginSlash(data, adapterId, adapter);
4710
4664
  }
4711
- else if (data.command === "install-cli") {
4712
- await this.handleInstallCliSlash(data, adapterId, adapter);
4713
- }
4714
4665
  }, this.logger, `adapter[${adapterId}].slash_command`));
4715
4666
  adapter.on("started", safeHandler((username, userId) => {
4716
4667
  if (!isCurrentAdapter())
@@ -8005,13 +7956,20 @@ export class FleetManager {
8005
7956
  this.adapter.sendText(target.chatId, text, target.opts)
8006
7957
  .catch(e => this.logger.warn({ err: e }, "Failed to send daily summary"));
8007
7958
  }
8008
- notifyFleetError(text) {
7959
+ /**
7960
+ * Returns whether the notice was dispatched. `throttle: false` is for a
7961
+ * caller that does its own de-duplication (one notice per failure streak):
7962
+ * the shared 10-minute text throttle would otherwise swallow the next
7963
+ * streak's notice, and it is neither consulted nor recorded.
7964
+ */
7965
+ notifyFleetError(text, opts = {}) {
7966
+ const throttled = opts.throttle !== false;
8009
7967
  const now = Date.now();
8010
7968
  const key = text.slice(0, 200);
8011
- const seen = this.fleetErrorNotices.get(key);
7969
+ const seen = throttled ? this.fleetErrorNotices.get(key) : undefined;
8012
7970
  if (seen && now - seen.at < FleetManager.FLEET_ERROR_THROTTLE_MS) {
8013
7971
  seen.suppressed++;
8014
- return;
7972
+ return false;
8015
7973
  }
8016
7974
  const suppressed = seen?.suppressed ?? 0;
8017
7975
  const body = suppressed > 0
@@ -8049,8 +8007,10 @@ export class FleetManager {
8049
8007
  // unspent lets the next caller, once an adapter is up, actually get
8050
8008
  // through. The counter is not reset either: nothing was shown.
8051
8009
  this.logger.warn({ text: body }, "Fleet error could not be delivered (no adapter or no target yet) — not consuming the throttle window");
8052
- return;
8010
+ return false;
8053
8011
  }
8012
+ if (!throttled)
8013
+ return true;
8054
8014
  this.fleetErrorNotices.set(key, { at: now, suppressed: 0 });
8055
8015
  // Bound the map: it is keyed by message text, and a message with a varying
8056
8016
  // suffix (a path, an id) would otherwise grow it without limit.
@@ -8059,6 +8019,7 @@ export class FleetManager {
8059
8019
  if (oldest !== undefined)
8060
8020
  this.fleetErrorNotices.delete(oldest);
8061
8021
  }
8022
+ return true;
8062
8023
  }
8063
8024
  static FLEET_ERROR_THROTTLE_MS = 10 * 60_000;
8064
8025
  fleetErrorNotices = new Map();
@@ -8121,7 +8082,22 @@ export class FleetManager {
8121
8082
  * fails, so a nonce in the map always refers to a message that exists (or
8122
8083
  * is about to). Returns the nonce, or null when the alert could not be sent.
8123
8084
  */
8085
+ /** postNonceButtonPromptOrThrow, logging a failure and returning null instead. */
8124
8086
  async postNonceButtonPrompt(opts) {
8087
+ try {
8088
+ return await this.postNonceButtonPromptOrThrow(opts);
8089
+ }
8090
+ catch (err) {
8091
+ this.logger.warn({ err, instanceName: opts.instanceName, prefix: opts.prefix }, "Failed to send button prompt");
8092
+ return null;
8093
+ }
8094
+ }
8095
+ /**
8096
+ * Post a nonce-armed button prompt; throws (with the nonce disarmed) when it
8097
+ * cannot be posted, so a caller that told the user "buttons posted" can
8098
+ * tell them the truth instead (#1133).
8099
+ */
8100
+ async postNonceButtonPromptOrThrow(opts) {
8125
8101
  // 16 bytes = the 128-bit capability the design claims. Telegram's 64-byte
8126
8102
  // callback_data cap still holds, with two prefixes tied at the longest:
8127
8103
  // "interactive-assist:" (19) + 32 hex + ":confirm" (8) = 59, and
@@ -8178,8 +8154,7 @@ export class FleetManager {
8178
8154
  this.pendingNonceButtons.delete(nonce);
8179
8155
  if (entry.timer)
8180
8156
  clearTimeout(entry.timer);
8181
- this.logger.warn({ err, instanceName: opts.instanceName, prefix: opts.prefix }, "Failed to send button prompt");
8182
- return null;
8157
+ throw err;
8183
8158
  }
8184
8159
  }
8185
8160
  /**
@@ -8206,12 +8181,15 @@ export class FleetManager {
8206
8181
  pending = undefined;
8207
8182
  if (!match || !pending) {
8208
8183
  const adapter = receivingAdapter ?? this.adapter;
8184
+ // The clicker always hears why nothing happens (#1133); collapsing the
8185
+ // dead buttons is a courtesy that can fail (>48h on Telegram, message gone).
8186
+ data.ack?.(t("buttons.stale_notice"));
8187
+ const collapseFailed = (err) => this.logger.info({ err: err?.message, prefix }, "Could not collapse an expired prompt's buttons");
8209
8188
  if (staleHandling?.keepText && adapter?.removeMessageButtons) {
8210
- adapter.removeMessageButtons(data.chatId, data.messageId, data.threadId)
8211
- .catch(() => { });
8189
+ adapter.removeMessageButtons(data.chatId, data.messageId, data.threadId).catch(collapseFailed);
8212
8190
  }
8213
8191
  else {
8214
- adapter?.editMessageRemoveButtons?.(data.chatId, data.messageId, t("buttons.stale"), data.threadId).catch(() => { });
8192
+ adapter?.editMessageRemoveButtons?.(data.chatId, data.messageId, t("buttons.stale"), data.threadId).catch(collapseFailed);
8215
8193
  }
8216
8194
  if (staleHandling?.notice) {
8217
8195
  adapter?.sendText(data.chatId, staleHandling.notice, { threadId: data.threadId })
@@ -8248,8 +8226,10 @@ export class FleetManager {
8248
8226
  userId: data.userId,
8249
8227
  mismatchedFields: mismatchedFields.join(","),
8250
8228
  }, "Rejected unauthorized or mismatched button callback");
8229
+ data.ack?.(t(isAuthorized ? "buttons.wrong_place" : "buttons.admin_only"));
8251
8230
  return "consumed";
8252
8231
  }
8232
+ data.ack?.();
8253
8233
  // Claim before any await: double clicks and duplicate callback delivery
8254
8234
  // can never act twice for state-changing actions.
8255
8235
  this.pendingNonceButtons.delete(match[1]);
@@ -8258,18 +8238,91 @@ export class FleetManager {
8258
8238
  return { entry: pending, action: match[2] };
8259
8239
  }
8260
8240
  /** Collapse a consumed prompt's buttons into a final status line. */
8261
- async retireNonceButtons(pending, messageId, text) {
8262
- if (!pending.adapter.editMessageRemoveButtons) {
8263
- this.logger.warn({ instanceName: pending.instanceName, adapterId: pending.adapterId }, "Adapter cannot remove prompt buttons");
8264
- return;
8241
+ /**
8242
+ * One button click from an adapter (#1133). Every click is acknowledged
8243
+ * once the fleet has decided — Telegram keeps a spinner until it is, and a
8244
+ * refused click acknowledges with a notice of its own (consumeNonceCallback) —
8245
+ * and a click nothing acts on is logged rather than vanishing.
8246
+ */
8247
+ async receiveAdapterCallback(data, adapterId, adapter, isCurrentAdapter) {
8248
+ try {
8249
+ if (!isCurrentAdapter()) {
8250
+ this.logger.info({ adapterId, prefix: callbackPrefix(data.callbackData) }, "Button click from a replaced adapter ignored");
8251
+ return;
8252
+ }
8253
+ if (!await this.dispatchAdapterCallback(data, adapterId, adapter)) {
8254
+ this.logger.info({ adapterId, prefix: callbackPrefix(data.callbackData) }, "Button click not handled by the fleet (adapter-level prompt or unknown button)");
8255
+ }
8256
+ }
8257
+ finally {
8258
+ data.ack?.();
8265
8259
  }
8260
+ }
8261
+ /**
8262
+ * A button from a prompt kind that no longer exists (#1131: `/install-cli`'s
8263
+ * picker and its "sign in now?" prompt). Still on screen after an upgrade;
8264
+ * a click gets the expired-prompt treatment — a private notice, the buttons
8265
+ * collapsed — instead of nothing.
8266
+ */
8267
+ handleRetiredPromptButton(data, adapterId, adapter) {
8268
+ for (const prefix of RETIRED_PROMPT_PREFIXES) {
8269
+ if (this.consumeNonceCallback(prefix, NEVER_MATCHES, data, adapterId, adapter) !== null)
8270
+ return true;
8271
+ }
8272
+ return false;
8273
+ }
8274
+ /** Routes a click to the handler that owns its prefix; false when none does. */
8275
+ async dispatchAdapterCallback(data, adapterId, adapter) {
8276
+ if (await this.handleTipDismiss(data, adapterId, adapter))
8277
+ return true;
8278
+ if (await this.handleTipUnlock(data, adapterId, adapter))
8279
+ return true;
8280
+ if (await this.handleLoginBackendSelect(data, adapterId, adapter))
8281
+ return true;
8282
+ if (await this.handleClassicApproval(data, adapterId, adapter))
8283
+ return true;
8284
+ if (await this.handleLoginMenuSelect(data, adapterId, adapter))
8285
+ return true;
8286
+ if (this.handleRetiredPromptButton(data, adapterId, adapter))
8287
+ return true;
8288
+ if (await this.handleLoginConfirm(data, adapterId, adapter))
8289
+ return true;
8290
+ if (await this.handleLoginTokenResend(data, adapterId, adapter))
8291
+ return true;
8292
+ if (await this.handleClearConfirmation(data, adapterId, adapter))
8293
+ return true;
8294
+ if (await this.handleExitRestartPrompt(data, adapterId, adapter))
8295
+ return true;
8296
+ if (await this.handleInteractivePromptAssist(data, adapterId, adapter))
8297
+ return true;
8298
+ if (await this.handleClassicBackendSelection(data))
8299
+ return true;
8300
+ if (await this.handleModelSelection(data))
8301
+ return true;
8302
+ if (await this.handleEffortSelection(data))
8303
+ return true;
8304
+ if (await this.handleHangPrompt(data, adapterId, adapter))
8305
+ return true;
8306
+ if (data.callbackData.startsWith("cancel:")) {
8307
+ this.handleCancelClick(data.callbackData.slice("cancel:".length), adapter ?? null, data);
8308
+ return true;
8309
+ }
8310
+ return false;
8311
+ }
8312
+ async retireNonceButtons(pending, messageId, text) {
8266
8313
  try {
8314
+ if (!pending.adapter.editMessageRemoveButtons)
8315
+ throw new Error("adapter cannot remove prompt buttons");
8267
8316
  await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
8268
8317
  }
8269
8318
  catch (err) {
8270
8319
  // The action was already atomically consumed. An edit failure must not
8271
- // undo that or make the button actionable again.
8272
- this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons");
8320
+ // undo that or make the button actionable again — but the edit is the
8321
+ // only place some outcomes are told (cancel, later, a resend's result),
8322
+ // so the text goes out as a message instead (#1133).
8323
+ this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons; posting the outcome instead");
8324
+ await pending.adapter.sendText(pending.chatId, text, { threadId: pending.threadId })
8325
+ .catch(sendErr => this.logger.warn({ err: sendErr, instanceName: pending.instanceName }, "Could not post a prompt's outcome"));
8273
8326
  }
8274
8327
  }
8275
8328
  tipsEnabled() {
@@ -9493,8 +9546,10 @@ export class FleetManager {
9493
9546
  isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
9494
9547
  windowBusyMessage: () => this.loginWindow.busyMessage(),
9495
9548
  tunnelDataDir: () => this.dataDir,
9549
+ // Throws when the prompt cannot be posted: the controller tells the
9550
+ // user so instead of leaving "Starting…" as the last word (#1133).
9496
9551
  postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText }) => {
9497
- await this.postNonceButtonPrompt({
9552
+ await this.postNonceButtonPromptOrThrow({
9498
9553
  prefix, alertType: "login", instanceName,
9499
9554
  adapter: chat.adapter, adapterId: chat.adapterId, chatId: chat.chatId, threadId: chat.threadId,
9500
9555
  message, choices, expiredText,
@@ -9504,37 +9559,50 @@ export class FleetManager {
9504
9559
  }
9505
9560
  return this.loginController;
9506
9561
  }
9507
- /** Post the backend chooser for a bare `/login`. Caller enforces admin. */
9562
+ /**
9563
+ * Post the backend chooser for a bare `/login`. Caller enforces admin.
9564
+ * Returns why the chooser could not be posted, for the caller to tell the
9565
+ * user (#1133); undefined when it was posted or there was nothing to choose.
9566
+ */
9508
9567
  async promptLoginBackends(chat) {
9509
9568
  const configured = new Set();
9510
9569
  for (const name of this.configuredBackendInstanceNames()) {
9511
9570
  configured.add(this.backendNameOf(name));
9512
9571
  }
9513
9572
  const installed = this.probeInstalledBackends();
9514
- const candidates = new Set([...installed, ...configured]);
9573
+ // One entry point for "get this CLI working" (#1131): a backend that is
9574
+ // not installed is offered too, and the click installs it first, then
9575
+ // signs in (startLoginSession routes it). gemini-cli is not recommended,
9576
+ // as before; `/login gemini-cli` still installs it.
9577
+ const installable = new Set(Object.keys(BACKEND_INSTALLATION_INFO).filter(backend => backend !== "gemini-cli"));
9578
+ const candidates = new Set([...installed, ...configured, ...installable]);
9515
9579
  const unsupported = [];
9516
9580
  const choices = [...candidates].sort().flatMap(backend => {
9517
9581
  const flow = LOGIN_FLOWS[backend];
9518
9582
  const remoteLogin = !!flow && flow.remoteLogin !== "unsupported";
9583
+ const isInstalled = installed.has(backend);
9519
9584
  const status = [];
9520
- if (installed.has(backend))
9521
- status.push(t("login.status_installed"));
9585
+ status.push(t(isInstalled ? "login.status_installed" : "login.status_not_installed"));
9522
9586
  if (configured.has(backend))
9523
9587
  status.push(t("login.status_configured"));
9524
- if (remoteLogin)
9588
+ if (!isInstalled && installable.has(backend)) {
9589
+ status.push(t(remoteLogin ? "login.status_install_then_auth" : "login.status_install"));
9590
+ return [{ action: backend, label: `${backend} · ${status.join(" · ")}` }];
9591
+ }
9592
+ if (remoteLogin && isInstalled) {
9525
9593
  status.push(t("login.status_auth"));
9526
- else
9527
- status.push(t("login.status_unsupported"));
9528
- if (!remoteLogin) {
9529
- unsupported.push({ backend, flow, status });
9530
- return [];
9594
+ return [{ action: backend, label: `${backend} · ${status.join(" · ")}` }];
9531
9595
  }
9532
- return [{ action: backend, label: `${backend} · ${status.join(" · ")}` }];
9596
+ status.push(t("login.status_unsupported"));
9597
+ unsupported.push({ backend, flow, status });
9598
+ return [];
9533
9599
  });
9534
9600
  if (unsupported.length) {
9535
- const guidance = unsupported.map(({ backend, flow, status }) => `${backend} · ${status.join(" · ")} — ${flow?.remoteLogin === "unsupported"
9536
- ? t("login.remote_unsupported_agent_cli", backend, flow.command)
9537
- : backend === "opencode" ? t("login.unsupported", backend) : t("login.no_remote_flow", backend)}`).join("\n");
9601
+ const guidance = unsupported.map(({ backend, flow, status }) => `${backend} · ${status.join(" · ")} — ${!installed.has(backend)
9602
+ ? (BACKEND_INSTALLATION_INFO[backend] ? t("login.install_by_name", backend) : t("login.install_on_host", backend))
9603
+ : flow?.remoteLogin === "unsupported"
9604
+ ? t("login.remote_unsupported_agent_cli", backend, flow.command)
9605
+ : backend === "opencode" ? t("login.unsupported", backend) : t("login.no_remote_flow", backend)}`).join("\n");
9538
9606
  await chat.adapter.sendText(chat.chatId, guidance, { threadId: chat.threadId })
9539
9607
  .catch(err => this.logger.warn({ err }, "Failed to post unsupported login guidance"));
9540
9608
  }
@@ -9545,7 +9613,7 @@ export class FleetManager {
9545
9613
  }
9546
9614
  return;
9547
9615
  }
9548
- await this.postNonceButtonPrompt({
9616
+ return this.postChooser({
9549
9617
  prefix: LOGIN_CALLBACK_PREFIX,
9550
9618
  alertType: "login",
9551
9619
  instanceName: "login",
@@ -9553,11 +9621,27 @@ export class FleetManager {
9553
9621
  adapterId: chat.adapterId,
9554
9622
  chatId: chat.chatId,
9555
9623
  threadId: chat.threadId,
9556
- message: t("login.choose_backend"),
9624
+ message: `${t("login.choose_backend")}\n${t("fleet.label_line", fleetLabel(this.fleetConfig))}`,
9557
9625
  choices,
9558
9626
  expiredText: t("buttons.stale"),
9559
9627
  });
9560
9628
  }
9629
+ /** A clicked prompt's follow-up line; a failure to post it is logged, not swallowed (#1133). */
9630
+ async postPromptOutcome(entry, text) {
9631
+ await entry.adapter.sendText(entry.chatId, text, { threadId: entry.threadId })
9632
+ .catch(err => this.logger.warn({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Could not post a prompt's follow-up"));
9633
+ }
9634
+ /** A chooser prompt: undefined when posted, else the user-facing reason it was not. */
9635
+ async postChooser(opts) {
9636
+ try {
9637
+ await this.postNonceButtonPromptOrThrow(opts);
9638
+ return undefined;
9639
+ }
9640
+ catch (err) {
9641
+ this.logger.warn({ err, prefix: opts.prefix }, "Could not post a backend chooser");
9642
+ return t("buttons.post_failed", err?.message ?? String(err));
9643
+ }
9644
+ }
9561
9645
  /**
9562
9646
  * Ask the General topic to approve a ClassicBot access request.
9563
9647
  *
@@ -9651,62 +9735,47 @@ export class FleetManager {
9651
9735
  await this.applyClassicApproval(entry, groupId, grantAdmin ? userId : undefined);
9652
9736
  return true;
9653
9737
  }
9654
- /**
9655
- * Backend chooser for a bare `/install-cli`, mirroring promptLoginBackends so
9656
- * both commands feel the same. Built on postNonceButtonPrompt rather than the
9657
- * `/model` selection coordinator: that is the mechanism `/login` already uses,
9658
- * and the one whose canonical-address binding (#682) makes the buttons answer
9659
- * in a Telegram General topic.
9660
- *
9661
- * Unlike the login chooser this does NOT filter to backends the fleet already
9662
- * runs. Installing is how you get a backend you do not have yet, so filtering
9663
- * by configured backends would hide the only entry the admin came for.
9664
- *
9665
- * gemini-cli is omitted: it is deprecated (see backend/factory.ts). Typing
9666
- * `/install-cli gemini-cli` still works — this only stops recommending it.
9667
- */
9668
- async promptInstallBackends(chat) {
9669
- const choices = Object.keys(BACKEND_INSTALLATION_INFO)
9670
- .filter(backend => backend !== "gemini-cli")
9671
- .map(backend => ({ action: backend, label: backend }));
9672
- await this.postNonceButtonPrompt({
9673
- prefix: INSTALL_CALLBACK_PREFIX,
9674
- alertType: "install",
9675
- instanceName: "install",
9676
- adapter: chat.adapter,
9677
- adapterId: chat.adapterId,
9678
- chatId: chat.chatId,
9679
- threadId: chat.threadId,
9680
- message: t("install.choose_backend"),
9681
- choices,
9682
- expiredText: t("buttons.stale"),
9683
- });
9684
- }
9685
- /** Backend chooser button → start that backend's install session. */
9686
- async handleInstallBackendSelect(data, callbackAdapterId, receivingAdapter) {
9687
- const claimed = this.consumeNonceCallback(INSTALL_CALLBACK_PREFIX, /^install-select:([0-9a-f]+):([a-z][a-z-]*)$/, data, callbackAdapterId, receivingAdapter);
9688
- if (claimed === null)
9689
- return false;
9690
- if (claimed === "consumed")
9691
- return true;
9692
- const { entry, action: backend } = claimed;
9693
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("install.starting_backend", backend));
9694
- const text = await this.startInstallSession(backend, {
9695
- adapter: entry.adapter,
9696
- adapterId: entry.adapterId,
9697
- chatId: entry.chatId,
9698
- threadId: entry.threadId,
9699
- });
9700
- if (text)
9701
- await entry.adapter.sendText(entry.chatId, text, { threadId: entry.threadId }).catch(() => { });
9702
- return true;
9703
- }
9704
9738
  /**
9705
9739
  * Start a login session for one backend. Caller enforces admin.
9706
9740
  * Returns a status line to post, or null when a confirmation prompt was
9707
9741
  * posted instead (auth still valid — see the pre-check below).
9708
9742
  */
9709
9743
  async startLoginSession(backendArg, chat, opts = {}) {
9744
+ // `/login` is the one entry point (#1131): a CLI that is not installed yet
9745
+ // is installed first, and the install's success signs in (startInstallSession);
9746
+ // `reinstall` runs the installer even over an installed CLI.
9747
+ // A confirmation click (skipAuthCheck) continues a sign-in already decided:
9748
+ // straight to it, never back through the install routing — a binary that
9749
+ // went missing meanwhile must not start an installer from a "go" button.
9750
+ if (opts.skipAuthCheck)
9751
+ return this.launchSignIn(backendArg, chat, opts);
9752
+ const wanted = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
9753
+ const installer = BACKEND_INSTALLATION_INFO[wanted];
9754
+ if (opts.reinstall) {
9755
+ if (!installer)
9756
+ return t("install.unsupported", backendArg);
9757
+ this.recordLoginFlow(wanted, "reinstall", chat.userId);
9758
+ return this.startInstallSession(wanted, chat, { reinstall: true });
9759
+ }
9760
+ if (installer && !this.isCliInstalled(wanted)) {
9761
+ const flow = LOGIN_FLOWS[wanted];
9762
+ this.recordLoginFlow(wanted, flow && flow.remoteLogin !== "unsupported" ? "install_then_login" : "install_only", chat.userId);
9763
+ return this.startInstallSession(wanted, chat);
9764
+ }
9765
+ this.recordLoginFlow(wanted, "login", chat.userId);
9766
+ return this.launchSignIn(backendArg, chat, opts);
9767
+ }
9768
+ /** Which way a `/login` went (#1131) — so the event log shows what users actually need. */
9769
+ recordLoginFlow(backend, flow, requester) {
9770
+ try {
9771
+ this.eventLog?.insert("login", "login_entry", { backend, flow, requester: requester ?? null });
9772
+ }
9773
+ catch (err) {
9774
+ this.logger.debug({ err, backend, flow }, "Could not record the /login flow");
9775
+ }
9776
+ }
9777
+ /** The sign-in itself (web, or the legacy relay) for a CLI that is installed. */
9778
+ async launchSignIn(backendArg, chat, opts = {}) {
9710
9779
  if (this.webLogin.mode() === "web")
9711
9780
  return this.webLogin.start(backendArg, chat, opts);
9712
9781
  // ── legacy relay mode (login.mode: relay) — removed in 2.1.6 ──
@@ -9742,7 +9811,7 @@ export class FleetManager {
9742
9811
  if (!this.loginWindow.isCurrent(claim))
9743
9812
  return t("login.web_shutting_down"); // fleet shut down while we probed
9744
9813
  if (status === "valid") {
9745
- await this.postNonceButtonPrompt({
9814
+ const failure = await this.postChooser({
9746
9815
  prefix: LOGIN_CONFIRM_CALLBACK_PREFIX,
9747
9816
  alertType: "login",
9748
9817
  instanceName: backend,
@@ -9757,7 +9826,7 @@ export class FleetManager {
9757
9826
  ],
9758
9827
  expiredText: t("buttons.stale"),
9759
9828
  });
9760
- return null;
9829
+ return failure ?? null;
9761
9830
  }
9762
9831
  }
9763
9832
  markTransferred(); // launchLoginSession owns the claim from here
@@ -9783,7 +9852,7 @@ export class FleetManager {
9783
9852
  }
9784
9853
  const session = new LoginSession(flow, tmux, {
9785
9854
  onMenu: async (options) => {
9786
- await this.postNonceButtonPrompt({
9855
+ const failure = await this.postChooser({
9787
9856
  prefix: LOGIN_MENU_CALLBACK_PREFIX,
9788
9857
  alertType: "login",
9789
9858
  instanceName: backend,
@@ -9795,6 +9864,10 @@ export class FleetManager {
9795
9864
  choices: options.map((label, index) => ({ action: String(index), label })),
9796
9865
  expiredText: t("buttons.stale"),
9797
9866
  });
9867
+ if (failure) {
9868
+ await chat.adapter.sendText(chat.chatId, failure, { threadId: chat.threadId })
9869
+ .catch(err => this.logger.warn({ err, backend }, "Could not report a login menu that failed to post"));
9870
+ }
9798
9871
  },
9799
9872
  onAuthHint: async (url, code) => {
9800
9873
  await this.sendLoginSecret(chat, backend, url, code);
@@ -9906,6 +9979,16 @@ export class FleetManager {
9906
9979
  }
9907
9980
  /** `/login cancel` — abort the active session and remove its window. */
9908
9981
  async cancelLoginSession() {
9982
+ // `/login cancel` also stops an install that `/login` started — and the
9983
+ // sign-in an install that just succeeded is about to hand over to.
9984
+ if (this.activeInstall)
9985
+ return this.cancelInstallSession();
9986
+ if (this.installHandoff) {
9987
+ const { backend } = this.installHandoff;
9988
+ this.installHandoff.cancelled = true;
9989
+ this.installHandoff = null;
9990
+ return t("login.cancelled", backend);
9991
+ }
9909
9992
  if (this.loginController?.isActive())
9910
9993
  return this.loginController.cancel();
9911
9994
  if (!this.activeLogin)
@@ -10005,7 +10088,7 @@ export class FleetManager {
10005
10088
  userId: data.userId,
10006
10089
  });
10007
10090
  if (text)
10008
- await entry.adapter.sendText(entry.chatId, text, { threadId: entry.threadId }).catch(() => { });
10091
+ await this.postPromptOutcome(entry, text);
10009
10092
  return true;
10010
10093
  }
10011
10094
  /** Re-login confirmation (auth pre-check said credentials still work). */
@@ -10035,7 +10118,7 @@ export class FleetManager {
10035
10118
  tunnel: action === "go-tunnel" || action === "go-relogin-tunnel",
10036
10119
  });
10037
10120
  if (text)
10038
- await entry.adapter.sendText(entry.chatId, text, { threadId: entry.threadId }).catch(() => { });
10121
+ await this.postPromptOutcome(entry, text);
10039
10122
  return true;
10040
10123
  }
10041
10124
  /** "Resend token" button (web login): only the requester may press it; the token never enters the channel. */
@@ -10064,20 +10147,22 @@ export class FleetManager {
10064
10147
  await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, ok ? t("login.provider_selected", label) : t("login.no_session"));
10065
10148
  return true;
10066
10149
  }
10067
- // ── Remote CLI install (`/install-cli`) ──────────────────────────────────
10150
+ // ── Remote CLI install (run by `/login` for a CLI that is missing) ──────────────────────────────────
10068
10151
  //
10069
10152
  // Same dedicated-window model as /login (and the same LoginSession state
10070
10153
  // machine — an install is a login flow with no auth hints): run the
10071
10154
  // installer, judge by exit code, verify the binary on a fresh login shell,
10072
- // then offer to chain straight into /login.
10155
+ // then sign in (the hand-off below).
10073
10156
  activeInstall = null;
10157
+ /** An install that succeeded, between its success line and the sign-in it hands over to (#1131). */
10158
+ installHandoff = null;
10074
10159
  /** Start a CLI install session. Caller enforces admin. */
10075
- async startInstallSession(backendArg, chat) {
10160
+ async startInstallSession(backendArg, chat, opts = {}) {
10076
10161
  const backend = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
10077
10162
  const info = BACKEND_INSTALLATION_INFO[backend];
10078
10163
  if (!info)
10079
10164
  return t("install.unsupported", backendArg);
10080
- if (checkBinaryInstalled(info.binary))
10165
+ if (!opts.reinstall && checkBinaryInstalled(info.binary))
10081
10166
  return t("install.already", backend, info.binary);
10082
10167
  // Reserve the fleet-wide window before the first await (shared with web/relay
10083
10168
  // login). Owned by this method until the session is published.
@@ -10147,22 +10232,24 @@ export class FleetManager {
10147
10232
  // publish a durable completion line first so a successful install can
10148
10233
  // never look like it silently disappeared. Include the binary that
10149
10234
  // passed the fresh-login-shell verification and the exact next step.
10235
+ // Between the install ending and the sign-in starting there is no
10236
+ // session to cancel; this hand-off is what `/login cancel` stops.
10237
+ const handoff = { backend, cancelled: false };
10238
+ this.installHandoff = handoff;
10150
10239
  await chat.adapter.sendText(chat.chatId, t("install.success", backend, info.binary), { threadId: chat.threadId }).catch(err => this.logger.warn({ err, backend }, "Failed to send durable install success notification"));
10151
- await this.postNonceButtonPrompt({
10152
- prefix: INSTALL_LOGIN_CALLBACK_PREFIX,
10153
- alertType: "login",
10154
- instanceName: backend,
10155
- adapter: chat.adapter,
10156
- adapterId: chat.adapterId,
10157
- chatId: chat.chatId,
10158
- threadId: chat.threadId,
10159
- message: t("install.login_prompt", backend),
10160
- choices: [
10161
- { action: "go", label: t("install.login_now") },
10162
- { action: "later", label: t("install.later") },
10163
- ],
10164
- expiredText: t("install.later_ack", backend),
10165
- });
10240
+ if (handoff.cancelled || this.installHandoff !== handoff)
10241
+ return;
10242
+ this.installHandoff = null;
10243
+ // The user asked `/login` for a working CLI: sign in straight away
10244
+ // (#1131). The login has its own confirmation, so nothing starts
10245
+ // without a click.
10246
+ // launchSignIn, not startLoginSession: if the new binary were still
10247
+ // not visible, startLoginSession would route straight back to an install.
10248
+ const next = await this.launchSignIn(backend, chat, {}).catch((err) => t("login.failed", backend, err?.message ?? String(err)));
10249
+ if (next) {
10250
+ await chat.adapter.sendText(chat.chatId, next, { threadId: chat.threadId })
10251
+ .catch(err => this.logger.warn({ err, backend }, "Could not post the sign-in step after an install"));
10252
+ }
10166
10253
  },
10167
10254
  }, this.logger);
10168
10255
  this.activeInstall = { session, backend, chat };
@@ -10188,7 +10275,12 @@ export class FleetManager {
10188
10275
  }
10189
10276
  return t("install.started", backend);
10190
10277
  }
10191
- /** `/install-cli cancel` — abort the active install and remove its window. */
10278
+ /** Whether this backend's CLI is on the fleet's PATH (what `/login` installs when it is not). */
10279
+ isCliInstalled(backend) {
10280
+ const info = BACKEND_INSTALLATION_INFO[backend];
10281
+ return !!info && checkBinaryInstalled(info.binary);
10282
+ }
10283
+ /** Abort the active install (`/login cancel`) and remove its window. */
10192
10284
  async cancelInstallSession() {
10193
10285
  if (!this.activeInstall)
10194
10286
  return t("install.no_session");
@@ -10246,31 +10338,6 @@ export class FleetManager {
10246
10338
  process.env.PATH = [dir, ...entries].join(delimiter);
10247
10339
  this.logger.info({ backend, dir }, "Added the installed CLI's directory to the fleet PATH");
10248
10340
  }
10249
- /** "Sign in now?" button after a successful install. */
10250
- async handleInstallLoginConfirm(data, callbackAdapterId, receivingAdapter) {
10251
- const claimed = this.consumeNonceCallback(INSTALL_LOGIN_CALLBACK_PREFIX, /^install-login:([0-9a-f]+):(go|later)$/, data, callbackAdapterId, receivingAdapter);
10252
- if (claimed === null)
10253
- return false;
10254
- if (claimed === "consumed")
10255
- return true;
10256
- const { entry, action } = claimed;
10257
- const backend = entry.instanceName;
10258
- if (action === "later") {
10259
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("install.later_ack", backend));
10260
- return true;
10261
- }
10262
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", backend));
10263
- const text = await this.startLoginSession(backend, {
10264
- adapter: entry.adapter,
10265
- adapterId: entry.adapterId,
10266
- chatId: entry.chatId,
10267
- threadId: entry.threadId,
10268
- userId: data.userId,
10269
- });
10270
- if (text)
10271
- await entry.adapter.sendText(entry.chatId, text, { threadId: entry.threadId }).catch(() => { });
10272
- return true;
10273
- }
10274
10341
  /** Discord native `/login` slash — shared by every adapter dispatch block. */
10275
10342
  async handleLoginSlash(data, adapterId, adapter) {
10276
10343
  if (!data.userId || !this.isFleetAdmin(data.userId, adapterId)) {
@@ -10288,34 +10355,19 @@ export class FleetManager {
10288
10355
  return;
10289
10356
  }
10290
10357
  const backend = String(data.options?.backend ?? "").trim();
10358
+ if (data.options?.reinstall === true) {
10359
+ await data.respond(backend
10360
+ ? await this.startLoginSession(backend, chat, { reinstall: true }) ?? t("login.confirm_posted")
10361
+ : t("login.usage"));
10362
+ return;
10363
+ }
10291
10364
  if (backend) {
10292
10365
  const text = await this.startLoginSession(backend, chat);
10293
10366
  await data.respond(text ?? t("login.confirm_posted"));
10294
10367
  return;
10295
10368
  }
10296
- await this.promptLoginBackends(chat);
10297
- await data.respond(t("login.chooser_posted"));
10298
- }
10299
- /** Discord native `/install-cli` slash — shared by every dispatch block. */
10300
- async handleInstallCliSlash(data, adapterId, adapter) {
10301
- if (!data.userId || !this.isFleetAdmin(data.userId, adapterId)) {
10302
- await data.respond(t("permission.denied"));
10303
- return;
10304
- }
10305
- if (data.options?.cancel === true) {
10306
- await data.respond(await this.cancelInstallSession());
10307
- return;
10308
- }
10309
- const backend = String(data.options?.backend ?? "").trim();
10310
- if (!backend) {
10311
- // Bare call: offer the backends instead of printing a usage line the user
10312
- // then has to retype. A Discord slash that DID pick the native `backend`
10313
- // choice never lands here, so the two paths cannot both fire.
10314
- await this.promptInstallBackends({ adapter, adapterId, chatId: data.channelId });
10315
- await data.respond(t("install.chooser_posted"));
10316
- return;
10317
- }
10318
- await data.respond(await this.startInstallSession(backend, { adapter, adapterId, chatId: data.channelId }));
10369
+ const failure = await this.promptLoginBackends(chat);
10370
+ await data.respond(failure ?? t("login.chooser_posted"));
10319
10371
  }
10320
10372
  queueMirrorMessage(text) {
10321
10373
  const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;