@songsid/agend 2.1.10-beta.3 → 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.
Files changed (40) hide show
  1. package/README.md +1 -1
  2. package/README.zh-TW.md +1 -1
  3. package/dist/channel/adapters/discord.d.ts +20 -1
  4. package/dist/channel/adapters/discord.js +111 -49
  5. package/dist/channel/adapters/discord.js.map +1 -1
  6. package/dist/channel/adapters/telegram.js +18 -1
  7. package/dist/channel/adapters/telegram.js.map +1 -1
  8. package/dist/channel/factory.d.ts +2 -0
  9. package/dist/channel/factory.js.map +1 -1
  10. package/dist/config-validator.js +3 -0
  11. package/dist/config-validator.js.map +1 -1
  12. package/dist/config.js +1 -0
  13. package/dist/config.js.map +1 -1
  14. package/dist/fleet-context.d.ts +3 -18
  15. package/dist/fleet-label.d.ts +5 -0
  16. package/dist/fleet-label.js +19 -0
  17. package/dist/fleet-label.js.map +1 -0
  18. package/dist/fleet-manager.d.ts +59 -32
  19. package/dist/fleet-manager.js +296 -244
  20. package/dist/fleet-manager.js.map +1 -1
  21. package/dist/instance-lifecycle.d.ts +1 -1
  22. package/dist/locale.js +36 -30
  23. package/dist/locale.js.map +1 -1
  24. package/dist/login-controller.js +9 -2
  25. package/dist/login-controller.js.map +1 -1
  26. package/dist/login-flows.js +1 -1
  27. package/dist/login-flows.js.map +1 -1
  28. package/dist/login-window-lock.js +1 -1
  29. package/dist/setup-wizard.js +1 -1
  30. package/dist/setup-wizard.js.map +1 -1
  31. package/dist/topic-commands.d.ts +0 -2
  32. package/dist/topic-commands.js +22 -47
  33. package/dist/topic-commands.js.map +1 -1
  34. package/dist/types.d.ts +18 -6
  35. package/dist/types.js +8 -2
  36. package/dist/types.js.map +1 -1
  37. package/dist/wake-coordinator.js +2 -2
  38. package/dist/web-terminal.d.ts +1 -1
  39. package/dist/web-terminal.js +1 -1
  40. package/package.json +1 -1
package/README.md CHANGED
@@ -124,7 +124,7 @@ graph LR
124
124
  | Grok Build | `curl -fsSL https://x.ai/cli/install.sh \| bash` | `grok` (x.ai OAuth device flow). Needs CLI 1.0.13 or later; run `grok update` if the server refuses an older one |
125
125
  | Meta Muse Code | `mkdir -p "$HOME/.local/bin" && curl -fsSL https://api.meta.ai/muse-launcher.sh -o "$HOME/.local/bin/muse" && chmod +x "$HOME/.local/bin/muse" && MUSE_LAUNCHER_INSTALL=1 "$HOME/.local/bin/muse"` (the launcher keeps its binary beside itself, so it is saved first) | `muse login` |
126
126
 
127
- **Or install from chat with `/install-cli <backend>`** (`/install_cli` on Telegram; beta, fleet admins only), without SSH-ing into the host. It runs the command above in a fleet window, then finds the CLI where its installer put it, adds that directory to the fleet's PATH and offers `/login`. It is offered for every backend above except Gemini CLI.
127
+ **Or install from chat with `/login <backend>`** (beta, fleet admins only), without SSH-ing into the host: when the CLI is not installed yet, `/login` runs the command above in a fleet window, finds the CLI where its installer put it, adds that directory to the fleet's PATH, and goes on to sign in. A bare `/login` offers every backend above except Gemini CLI (`/login gemini-cli` still installs it).
128
128
 
129
129
  **Tested CLI versions (AgEnD 2.1.9).** Codex 0.155 to 0.159. Claude Code 2.1.286 (first-run, trust and resume screens captured from the real CLI). Kiro CLI 2.21 to 2.27 run live; older 2.x versions still start, with a warning to update, and anything newer than 2.27 is launched only after its own `--help` confirms the flags AgEnD pins (the legacy UI on kiro's v1 engine, the terminal UI on v2). A kiro-cli that can no longer run an instance that way is refused, not started on another engine (#1109). `kiro_ui: v3` is not supported yet (#849). Grok CLI 1.0.13 or later. Muse 1.3.0. Antigravity and OpenCode have no pinned version. Other versions usually work, but a new CLI release can change its screens, so AgEnD only claims a version after testing against it.
130
130
 
package/README.zh-TW.md CHANGED
@@ -109,7 +109,7 @@ graph LR
109
109
  | Grok Build | `curl -fsSL https://x.ai/cli/install.sh \| bash` | `grok`(x.ai OAuth device flow)。CLI 需 1.0.13 以上,舊版會被伺服器拒絕,請執行 `grok update` |
110
110
  | Meta Muse Code | `mkdir -p "$HOME/.local/bin" && curl -fsSL https://api.meta.ai/muse-launcher.sh -o "$HOME/.local/bin/muse" && chmod +x "$HOME/.local/bin/muse" && MUSE_LAUNCHER_INSTALL=1 "$HOME/.local/bin/muse"`(launcher 會把 binary 放在自己旁邊,所以要先存檔) | `muse login` |
111
111
 
112
- **也可以在聊天室用 `/install-cli <backend>` 安裝**(Telegram 上是 `/install_cli`;beta、僅限 fleet 管理員),不必 SSH 進主機。它會在 fleet 視窗執行上表的指令,接著到安裝程式實際放置的位置找到 CLI,把那個目錄加進 fleet 的 PATH,並提供 `/login`。上表除了 Gemini CLI 以外的 backend 都可以用。
112
+ **也可以在聊天室用 `/login <backend>` 安裝**(beta、僅限 fleet 管理員),不必 SSH 進主機:CLI 還沒安裝時,`/login` 會在 fleet 視窗執行上表的指令,到安裝程式實際放置的位置找到 CLI,把那個目錄加進 fleet 的 PATH,然後接著登入。單獨輸入 `/login` 會列出上表除了 Gemini CLI 以外的所有 backend(`/login gemini-cli` 仍可安裝它)。
113
113
 
114
114
  **已測試的 CLI 版本(AgEnD 2.1.9)**:Codex 0.155 到 0.159;Claude Code 2.1.286(首次啟動、trust、resume 畫面都從真實 CLI 擷取);Kiro CLI 2.21 到 2.27 實際跑過;更舊的 2.x 仍可啟動,但會提醒更新;比 2.27 更新的版本,要先用它自己的 `--help` 確認 AgEnD 鎖定的參數(legacy UI 搭 kiro 的 v1 engine、terminal UI 搭 v2)還在才會啟動。若 kiro-cli 已無法這樣執行某個 instance,AgEnD 會拒絕啟動,不會換成別的 engine 執行(#1109)。`kiro_ui: v3` 尚未支援(#849);Grok CLI 1.0.13 以上;Muse 1.3.0。Antigravity 與 OpenCode 沒有釘定版本。其他版本通常也能用,但 CLI 新版可能改動畫面,所以 AgEnD 只在實測過後才宣稱支援某個版本。
115
115
 
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from "node:events";
2
- import { Client } from "discord.js";
2
+ import { Client, ActionRowBuilder, ButtonBuilder } from "discord.js";
3
3
  import type { ChannelAdapter, ApprovalHandle, SendOpts, SentMessage, PermissionPrompt, Choice, AlertData, AdapterHealthSnapshot, TopicPresence } from "../types.js";
4
4
  import type { AccessManager } from "../access-manager.js";
5
5
  /** Curated ClassicBot backends for Discord's native slash-option dropdown. */
@@ -34,6 +34,8 @@ export interface DiscordAdapterOptions {
34
34
  categoryName?: string;
35
35
  generalChannelId?: string;
36
36
  registerCommands?: boolean;
37
+ /** This fleet's label, appended to the `/login` description. */
38
+ fleetLabel?: string;
37
39
  /** Test seams; production callers leave these unset. */
38
40
  clientFactory?: () => Client;
39
41
  now?: () => number;
@@ -41,6 +43,22 @@ export interface DiscordAdapterOptions {
41
43
  staleThresholdMs?: number;
42
44
  reconnectBaseDelayMs?: number;
43
45
  }
46
+ /**
47
+ * Buttons laid out in rows of five (#1133). One row used to hold them all, and
48
+ * Discord rejects a row of more than five, so a chooser of seven backends
49
+ * could never be posted. More than 25 cannot be shown at all — an
50
+ * error, never a silent truncation.
51
+ */
52
+ export declare function buttonRows(choices: ReadonlyArray<{
53
+ id: string;
54
+ label: string;
55
+ }>): ActionRowBuilder<ButtonBuilder>[];
56
+ /**
57
+ * A slash command description that names its fleet: every AgEnD bot in a
58
+ * guild registers `/login`, and the menu shows them side by side. Discord
59
+ * caps descriptions at 100 characters.
60
+ */
61
+ export declare function withFleetLabel(description: string, label: string | undefined): string;
44
62
  export declare class DiscordAdapter extends EventEmitter implements ChannelAdapter {
45
63
  readonly type = "discord";
46
64
  readonly topology: "channels";
@@ -75,6 +93,7 @@ export declare class DiscordAdapter extends EventEmitter implements ChannelAdapt
75
93
  private categoryName;
76
94
  private generalChannelId?;
77
95
  private registerCommands;
96
+ private fleetLabel;
78
97
  private queue;
79
98
  private lastChatId;
80
99
  private attachmentUrls;
@@ -6,7 +6,7 @@ import { join } from "node:path";
6
6
  import { pipeline } from "node:stream/promises";
7
7
  import { Readable } from "node:stream";
8
8
  import { createWriteStream } from "node:fs";
9
- import { Client, GatewayIntentBits, Partials, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, ApplicationCommandOptionType, ChannelType, PermissionFlagsBits, MessageFlags, Status, ActivityType, } from "discord.js";
9
+ import { Client, GatewayIntentBits, Partials, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, ApplicationCommandOptionType, ChannelType, PermissionFlagsBits, MessageFlags, Events, Status, ActivityType, } from "discord.js";
10
10
  import { MessageQueue } from "../message-queue.js";
11
11
  import { splitTextFenceAware, truncatePreview, fenceBlock } from "../markdown-chunk.js";
12
12
  const DISCORD_MAX_LENGTH = 2000;
@@ -25,6 +25,62 @@ export const DISCORD_START_BACKEND_CHOICES = [
25
25
  { name: "Meta Muse Code", value: "muse" },
26
26
  ];
27
27
  const CLASSIC_START_BACKEND_SELECT_ID = "classic-start-backend";
28
+ /** Discord's limits: 5 buttons to an action row, 5 rows to a message. */
29
+ const BUTTONS_PER_ROW = 5;
30
+ const MAX_BUTTON_ROWS = 5;
31
+ /**
32
+ * Buttons laid out in rows of five (#1133). One row used to hold them all, and
33
+ * Discord rejects a row of more than five, so a chooser of seven backends
34
+ * could never be posted. More than 25 cannot be shown at all — an
35
+ * error, never a silent truncation.
36
+ */
37
+ export function buttonRows(choices) {
38
+ if (choices.length > BUTTONS_PER_ROW * MAX_BUTTON_ROWS) {
39
+ throw new Error(`Discord shows at most ${BUTTONS_PER_ROW * MAX_BUTTON_ROWS} buttons on one message (${choices.length} requested)`);
40
+ }
41
+ const rows = [];
42
+ for (let i = 0; i < choices.length; i += BUTTONS_PER_ROW) {
43
+ rows.push(new ActionRowBuilder().addComponents(choices.slice(i, i + BUTTONS_PER_ROW).map(choice => new ButtonBuilder()
44
+ .setCustomId(choice.id)
45
+ .setLabel(choice.label.slice(0, 80))
46
+ .setStyle(ButtonStyle.Secondary))));
47
+ }
48
+ return rows;
49
+ }
50
+ /**
51
+ * The click's acknowledgement (#1133). The click itself was acknowledged on
52
+ * arrival (deferUpdate, inside Discord's 3 s window); a notice — why nothing
53
+ * happened — goes to the clicker alone as an ephemeral follow-up.
54
+ */
55
+ function privateNotice(interaction, adapterId) {
56
+ let done = false;
57
+ return (notice) => {
58
+ if (done)
59
+ return;
60
+ done = true;
61
+ if (!notice)
62
+ return;
63
+ // Never throws into the caller: the fleet's decision is already made.
64
+ Promise.resolve().then(() => interaction.followUp({ content: notice, flags: MessageFlags.Ephemeral }))
65
+ .catch(err => console.warn(`[discord:${adapterId}] could not send a private button notice (${err.message})`));
66
+ };
67
+ }
68
+ /**
69
+ * A nonce-armed prompt button (`postNonceButtonPrompt`): `<prefix>:<32 hex>:<action>`.
70
+ * Fleet-manager validates these against the prompt that created them.
71
+ */
72
+ const NONCE_BUTTON_ID = /^[a-z][a-z-]*:[0-9a-f]{32}:[a-z0-9-]+$/;
73
+ /**
74
+ * A slash command description that names its fleet: every AgEnD bot in a
75
+ * guild registers `/login`, and the menu shows them side by side. Discord
76
+ * caps descriptions at 100 characters.
77
+ */
78
+ export function withFleetLabel(description, label) {
79
+ if (!label)
80
+ return description;
81
+ const suffix = ` · ${label}`;
82
+ return description.length + suffix.length <= 100 ? description + suffix : `${description.slice(0, 100 - suffix.length - 1)}…${suffix}`;
83
+ }
28
84
  export class DiscordAdapter extends EventEmitter {
29
85
  type = "discord";
30
86
  topology = "channels";
@@ -59,6 +115,7 @@ export class DiscordAdapter extends EventEmitter {
59
115
  categoryName;
60
116
  generalChannelId;
61
117
  registerCommands;
118
+ fleetLabel;
62
119
  queue;
63
120
  lastChatId = null;
64
121
  attachmentUrls = new Map();
@@ -73,6 +130,7 @@ export class DiscordAdapter extends EventEmitter {
73
130
  this.categoryName = opts.categoryName ?? "AgEnD Agents";
74
131
  this.generalChannelId = opts.generalChannelId;
75
132
  this.registerCommands = opts.registerCommands !== false;
133
+ this.fleetLabel = opts.fleetLabel;
76
134
  mkdirSync(this.inboxDir, { recursive: true });
77
135
  this.clientFactory = opts.clientFactory ?? (() => this.buildClient());
78
136
  this.now = opts.now ?? Date.now;
@@ -203,7 +261,7 @@ export class DiscordAdapter extends EventEmitter {
203
261
  if (packet.t)
204
262
  this.lastDispatchAt = this.now();
205
263
  });
206
- client.once("ready", () => void this.handleClientReady(client, generation));
264
+ client.once(Events.ClientReady, () => void this.handleClientReady(client, generation));
207
265
  // Reactions on the bot's messages, as inbound events (#408). Both add and remove
208
266
  // are reported so an agent can see an approval being withdrawn.
209
267
  const onReaction = async (reaction, user, action) => {
@@ -461,8 +519,12 @@ export class DiscordAdapter extends EventEmitter {
461
519
  // interaction expires (>3s). Catch to prevent crashing the entire daemon.
462
520
  client.on("interactionCreate", async (interaction) => {
463
521
  try {
464
- if (!this.isCurrentClient(client, generation))
522
+ if (!this.isCurrentClient(client, generation)) {
523
+ // A replaced client is destroyed, so this should never fire; if it
524
+ // does, the click is not lost without a trace (#1133).
525
+ console.info(`[discord:${this.id}] ignored an interaction on a replaced gateway client`);
465
526
  return;
527
+ }
466
528
  // Buttons: acknowledge IMMEDIATELY, before any guild/channel filtering.
467
529
  // A button has a 3s ack window; any early return (unknown guild/channel)
468
530
  // or a downstream no-op (e.g. the cancel button was already cleared) would
@@ -474,9 +536,14 @@ export class DiscordAdapter extends EventEmitter {
474
536
  await interaction.deferUpdate();
475
537
  }
476
538
  catch { /* already acknowledged / unknown interaction */ }
477
- // Only act on buttons from the primary guild or a known open channel.
478
- if (interaction.guildId !== this.guildId && !this.openChannels.has(interaction.channelId ?? "")) {
479
- // console.log(`[discord] ignoring button from non-primary guild ${interaction.guildId} channel ${interaction.channelId}`);
539
+ // Act on buttons from the primary guild or a known open channel, and
540
+ // on nonce-armed prompts from anywhere (#1131): a slash command is
541
+ // accepted from other guilds, so the prompt it posts there must be
542
+ // clickable. Fleet-manager checks those fail-closed — nonce, chat,
543
+ // message and fleet admin. Anything else is dropped, with a log line.
544
+ if (interaction.guildId !== this.guildId && !this.openChannels.has(interaction.channelId ?? "")
545
+ && !NONCE_BUTTON_ID.test(interaction.customId)) {
546
+ console.info(`[discord:${this.id}] ignored a button outside the primary guild (guild ${interaction.guildId ?? "none"}, channel ${interaction.channelId ?? "none"})`);
480
547
  return;
481
548
  }
482
549
  this.emitFromClient(client, generation, "callback_query", {
@@ -485,6 +552,7 @@ export class DiscordAdapter extends EventEmitter {
485
552
  threadId: interaction.channelId,
486
553
  messageId: interaction.message.id,
487
554
  userId: interaction.user.id,
555
+ ack: privateNotice(interaction, this.id),
488
556
  });
489
557
  return;
490
558
  }
@@ -513,6 +581,7 @@ export class DiscordAdapter extends EventEmitter {
513
581
  threadId: interaction.channelId,
514
582
  messageId: interaction.message.id,
515
583
  userId: interaction.user.id,
584
+ ack: privateNotice(interaction, this.id),
516
585
  });
517
586
  return;
518
587
  }
@@ -550,7 +619,8 @@ export class DiscordAdapter extends EventEmitter {
550
619
  // Discord ephemeral interaction replies cannot.
551
620
  const fullRestart = interaction.commandName === "restart"
552
621
  && interaction.options.getString("mode") === "full";
553
- await interaction.deferReply({ ephemeral: interaction.commandName !== "update" && !fullRestart });
622
+ const ephemeral = interaction.commandName !== "update" && !fullRestart;
623
+ await interaction.deferReply(ephemeral ? { flags: MessageFlags.Ephemeral } : {});
554
624
  // Extract options as key-value pairs for fleet-manager
555
625
  const options = {};
556
626
  for (const opt of interaction.options.data) {
@@ -894,7 +964,7 @@ export class DiscordAdapter extends EventEmitter {
894
964
  // a guild with the primary — only the primary owns the guild's commands).
895
965
  if (this.registerCommands)
896
966
  try {
897
- await client.application?.commands.set([
967
+ const registered = await client.application?.commands.set([
898
968
  {
899
969
  name: "start", description: t("slash.start"),
900
970
  options: [{
@@ -963,7 +1033,7 @@ export class DiscordAdapter extends EventEmitter {
963
1033
  }],
964
1034
  },
965
1035
  {
966
- name: "login", description: "🔒 " + t("slash.login"),
1036
+ name: "login", description: withFleetLabel("🔒 " + t("slash.login"), this.fleetLabel),
967
1037
  options: [
968
1038
  {
969
1039
  name: "backend", description: t("slash.option.login_backend"),
@@ -975,29 +1045,12 @@ export class DiscordAdapter extends EventEmitter {
975
1045
  { name: "grok", value: "grok" },
976
1046
  { name: "muse", value: "muse" },
977
1047
  { name: "antigravity", value: "antigravity" },
1048
+ { name: "opencode", value: "opencode" },
978
1049
  ],
979
1050
  },
980
1051
  { name: "code", description: t("slash.option.login_code"), type: ApplicationCommandOptionType.String, required: false },
981
1052
  { name: "cancel", description: t("slash.option.login_cancel"), type: ApplicationCommandOptionType.Boolean, required: false },
982
- ],
983
- },
984
- {
985
- name: "install-cli", description: "🔒 " + t("slash.install_cli"),
986
- options: [
987
- {
988
- name: "backend", description: t("slash.option.install_backend"),
989
- type: ApplicationCommandOptionType.String, required: false,
990
- choices: [
991
- { name: "claude-code", value: "claude-code" },
992
- { name: "codex", value: "codex" },
993
- { name: "kiro-cli", value: "kiro-cli" },
994
- { name: "grok", value: "grok" },
995
- { name: "muse", value: "muse" },
996
- { name: "antigravity", value: "antigravity" },
997
- { name: "opencode", value: "opencode" },
998
- ],
999
- },
1000
- { name: "cancel", description: t("slash.option.install_cancel"), type: ApplicationCommandOptionType.Boolean, required: false },
1053
+ { name: "reinstall", description: t("slash.option.login_reinstall"), type: ApplicationCommandOptionType.Boolean, required: false },
1001
1054
  ],
1002
1055
  },
1003
1056
  { name: "clear", description: "🔒 " + t("slash.clear") },
@@ -1030,9 +1083,19 @@ export class DiscordAdapter extends EventEmitter {
1030
1083
  },
1031
1084
  { name: "cancel", description: t("slash.cancel") },
1032
1085
  ]);
1086
+ this.emitFromClient(client, generation, "slash_registration", { ok: true, count: registered?.size });
1033
1087
  }
1034
1088
  catch (err) {
1035
- // Non-fatal — slash commands may fail on network issues
1089
+ // Non-fatal, and no retry here (the next ready registers again) — but
1090
+ // never silent (#1131): Discord keeps the previous command list when a
1091
+ // registration is rejected, so a new command just never appears.
1092
+ const e = err;
1093
+ this.emitFromClient(client, generation, "slash_registration", {
1094
+ ok: false,
1095
+ code: e?.code,
1096
+ status: e?.status,
1097
+ message: String(e?.message ?? err),
1098
+ });
1036
1099
  }
1037
1100
  if (!this.isCurrentClient(client, generation))
1038
1101
  return;
@@ -1089,7 +1152,7 @@ export class DiscordAdapter extends EventEmitter {
1089
1152
  const chunks = splitTextFenceAware(reply, EMBED_MAX);
1090
1153
  const message = await interaction.editReply({ content: "", embeds: [{ description: chunks[0] }], components: [] });
1091
1154
  for (let i = 1; i < chunks.length; i++) {
1092
- await interaction.followUp({ ephemeral: true, embeds: [{ description: chunks[i] }] });
1155
+ await interaction.followUp({ flags: MessageFlags.Ephemeral, embeds: [{ description: chunks[i] }] });
1093
1156
  }
1094
1157
  return message.id;
1095
1158
  }
@@ -1178,13 +1241,16 @@ export class DiscordAdapter extends EventEmitter {
1178
1241
  async editMessageRemoveButtons(chatId, messageId, text, threadId) {
1179
1242
  // Prefer the exact channel (handles forum-topic threads, which a GuildText
1180
1243
  // scan misses); fall back to scanning top-level text channels.
1244
+ let exactError;
1181
1245
  try {
1182
1246
  const channel = await this._fetchTextChannel(threadId ?? chatId);
1183
1247
  const msg = await channel.messages.fetch(messageId);
1184
1248
  await msg.edit({ content: truncatePreview(text, DISCORD_MAX_LENGTH), components: [] });
1185
1249
  return;
1186
1250
  }
1187
- catch { /* not in that channel — fall through to scan */ }
1251
+ catch (err) {
1252
+ exactError = err; /* not in that channel — fall through to scan */
1253
+ }
1188
1254
  try {
1189
1255
  const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
1190
1256
  const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
@@ -1200,7 +1266,11 @@ export class DiscordAdapter extends EventEmitter {
1200
1266
  }
1201
1267
  }
1202
1268
  }
1203
- catch { /* message gone — nothing to clear */ }
1269
+ catch { /* no guild to scan — the edit failed */ }
1270
+ // Nothing was edited: say so (#1133). The text may be the only place an
1271
+ // outcome is shown (a cancel, a resend's result), and a caller that
1272
+ // believes it landed has no reason to post it another way.
1273
+ throw exactError instanceof Error ? exactError : new Error("the message could not be edited");
1204
1274
  }
1205
1275
  async deleteMessage(chatId, messageId, threadId) {
1206
1276
  // Prefer the exact channel (handles forum-topic threads, which a GuildText
@@ -1451,15 +1521,14 @@ export class DiscordAdapter extends EventEmitter {
1451
1521
  if (alert.choices && alert.choices.length > 0) {
1452
1522
  const channelId = opts?.threadId ?? chatId;
1453
1523
  const channel = await this._fetchTextChannel(channelId);
1454
- const row = new ActionRowBuilder();
1455
- for (const choice of alert.choices) {
1456
- row.addComponents(new ButtonBuilder()
1457
- .setCustomId(choice.id)
1458
- .setLabel(choice.label.slice(0, 80))
1459
- .setStyle(ButtonStyle.Secondary));
1460
- }
1461
- const msg = await channel.send({ content: alert.message, components: [row] });
1462
- return { messageId: msg.id, chatId, threadId: opts?.threadId };
1524
+ const msg = await channel.send({ content: alert.message, components: buttonRows(alert.choices) });
1525
+ // The address this adapter's own callbacks report for these buttons
1526
+ // (guild + channel as thread), not the caller's spelling of it (#1131).
1527
+ // A native slash command addresses its channel as the chat; the button
1528
+ // click then arrived with chatId = guild and every nonce check rejected it.
1529
+ // Exactly the callback's value, even when no group_id is configured
1530
+ // (guildId is then "" in both places).
1531
+ return { messageId: msg.id, chatId: this.guildId, threadId: channelId };
1463
1532
  }
1464
1533
  return this.sendText(chatId, alert.message, opts);
1465
1534
  }
@@ -1480,16 +1549,9 @@ export class DiscordAdapter extends EventEmitter {
1480
1549
  async editAlert(chatId, messageId, alert, opts) {
1481
1550
  const channel = await this._fetchTextChannel(opts?.threadId ?? chatId);
1482
1551
  const msg = await channel.messages.fetch(messageId);
1483
- const row = new ActionRowBuilder();
1484
- for (const choice of alert.choices ?? []) {
1485
- row.addComponents(new ButtonBuilder()
1486
- .setCustomId(choice.id)
1487
- .setLabel(choice.label.slice(0, 80))
1488
- .setStyle(ButtonStyle.Secondary));
1489
- }
1490
1552
  await msg.edit({
1491
1553
  content: truncatePreview(alert.message, DISCORD_MAX_LENGTH),
1492
- components: row.components.length > 0 ? [row] : [],
1554
+ components: buttonRows(alert.choices ?? []),
1493
1555
  });
1494
1556
  }
1495
1557
  // ── Topology: create channel ────────────────────────────────────────────