@spinabot/brigade 1.6.1 → 1.7.0

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 (133) hide show
  1. package/dist/agents/channels/discord/account-config.d.ts +60 -0
  2. package/dist/agents/channels/discord/account-config.d.ts.map +1 -1
  3. package/dist/agents/channels/discord/account-config.js +89 -0
  4. package/dist/agents/channels/discord/account-config.js.map +1 -1
  5. package/dist/agents/channels/discord/adapter.d.ts +24 -1
  6. package/dist/agents/channels/discord/adapter.d.ts.map +1 -1
  7. package/dist/agents/channels/discord/adapter.js +208 -41
  8. package/dist/agents/channels/discord/adapter.js.map +1 -1
  9. package/dist/agents/channels/discord/component-blocks.d.ts +108 -0
  10. package/dist/agents/channels/discord/component-blocks.d.ts.map +1 -0
  11. package/dist/agents/channels/discord/component-blocks.js +113 -0
  12. package/dist/agents/channels/discord/component-blocks.js.map +1 -0
  13. package/dist/agents/channels/discord/components.d.ts +78 -0
  14. package/dist/agents/channels/discord/components.d.ts.map +1 -1
  15. package/dist/agents/channels/discord/components.js +89 -0
  16. package/dist/agents/channels/discord/components.js.map +1 -1
  17. package/dist/agents/channels/discord/connection.d.ts +195 -12
  18. package/dist/agents/channels/discord/connection.d.ts.map +1 -1
  19. package/dist/agents/channels/discord/connection.js +852 -38
  20. package/dist/agents/channels/discord/connection.js.map +1 -1
  21. package/dist/agents/channels/discord/directory-cache.d.ts +47 -0
  22. package/dist/agents/channels/discord/directory-cache.d.ts.map +1 -0
  23. package/dist/agents/channels/discord/directory-cache.js +131 -0
  24. package/dist/agents/channels/discord/directory-cache.js.map +1 -0
  25. package/dist/agents/channels/discord/directory-live.d.ts +61 -0
  26. package/dist/agents/channels/discord/directory-live.d.ts.map +1 -0
  27. package/dist/agents/channels/discord/directory-live.js +140 -0
  28. package/dist/agents/channels/discord/directory-live.js.map +1 -0
  29. package/dist/agents/channels/discord/format.d.ts +15 -0
  30. package/dist/agents/channels/discord/format.d.ts.map +1 -1
  31. package/dist/agents/channels/discord/format.js +56 -0
  32. package/dist/agents/channels/discord/format.js.map +1 -1
  33. package/dist/agents/channels/discord/guilds.d.ts +25 -0
  34. package/dist/agents/channels/discord/guilds.d.ts.map +1 -0
  35. package/dist/agents/channels/discord/guilds.js +46 -0
  36. package/dist/agents/channels/discord/guilds.js.map +1 -0
  37. package/dist/agents/channels/discord/inbound-extras.d.ts +166 -9
  38. package/dist/agents/channels/discord/inbound-extras.d.ts.map +1 -1
  39. package/dist/agents/channels/discord/inbound-extras.js +246 -8
  40. package/dist/agents/channels/discord/inbound-extras.js.map +1 -1
  41. package/dist/agents/channels/discord/index.d.ts +10 -3
  42. package/dist/agents/channels/discord/index.d.ts.map +1 -1
  43. package/dist/agents/channels/discord/index.js +10 -3
  44. package/dist/agents/channels/discord/index.js.map +1 -1
  45. package/dist/agents/channels/discord/modal-registry.d.ts +89 -0
  46. package/dist/agents/channels/discord/modal-registry.d.ts.map +1 -0
  47. package/dist/agents/channels/discord/modal-registry.js +104 -0
  48. package/dist/agents/channels/discord/modal-registry.js.map +1 -0
  49. package/dist/agents/channels/discord/modals.d.ts +100 -0
  50. package/dist/agents/channels/discord/modals.d.ts.map +1 -0
  51. package/dist/agents/channels/discord/modals.js +124 -0
  52. package/dist/agents/channels/discord/modals.js.map +1 -0
  53. package/dist/agents/channels/discord/permission-audit.d.ts +43 -0
  54. package/dist/agents/channels/discord/permission-audit.d.ts.map +1 -0
  55. package/dist/agents/channels/discord/permission-audit.js +192 -0
  56. package/dist/agents/channels/discord/permission-audit.js.map +1 -0
  57. package/dist/agents/channels/discord/plugin.d.ts +18 -2
  58. package/dist/agents/channels/discord/plugin.d.ts.map +1 -1
  59. package/dist/agents/channels/discord/plugin.js +73 -4
  60. package/dist/agents/channels/discord/plugin.js.map +1 -1
  61. package/dist/agents/channels/discord/probe.d.ts +23 -1
  62. package/dist/agents/channels/discord/probe.d.ts.map +1 -1
  63. package/dist/agents/channels/discord/probe.js +40 -5
  64. package/dist/agents/channels/discord/probe.js.map +1 -1
  65. package/dist/agents/channels/discord/rest-actions.d.ts +346 -0
  66. package/dist/agents/channels/discord/rest-actions.d.ts.map +1 -0
  67. package/dist/agents/channels/discord/rest-actions.js +559 -0
  68. package/dist/agents/channels/discord/rest-actions.js.map +1 -0
  69. package/dist/agents/channels/discord/rest-components.d.ts +122 -0
  70. package/dist/agents/channels/discord/rest-components.d.ts.map +1 -0
  71. package/dist/agents/channels/discord/rest-components.js +243 -0
  72. package/dist/agents/channels/discord/rest-components.js.map +1 -0
  73. package/dist/agents/channels/discord/security-audit.d.ts +29 -0
  74. package/dist/agents/channels/discord/security-audit.d.ts.map +1 -0
  75. package/dist/agents/channels/discord/security-audit.js +94 -0
  76. package/dist/agents/channels/discord/security-audit.js.map +1 -0
  77. package/dist/agents/channels/discord/security-doctor.d.ts +43 -0
  78. package/dist/agents/channels/discord/security-doctor.d.ts.map +1 -0
  79. package/dist/agents/channels/discord/security-doctor.js +83 -0
  80. package/dist/agents/channels/discord/security-doctor.js.map +1 -0
  81. package/dist/agents/channels/discord/status-issues.d.ts +37 -0
  82. package/dist/agents/channels/discord/status-issues.d.ts.map +1 -0
  83. package/dist/agents/channels/discord/status-issues.js +66 -0
  84. package/dist/agents/channels/discord/status-issues.js.map +1 -0
  85. package/dist/agents/channels/discord/subagent-thread-binding-store.d.ts +57 -0
  86. package/dist/agents/channels/discord/subagent-thread-binding-store.d.ts.map +1 -0
  87. package/dist/agents/channels/discord/subagent-thread-binding-store.js +98 -0
  88. package/dist/agents/channels/discord/subagent-thread-binding-store.js.map +1 -0
  89. package/dist/agents/channels/discord/subagent-thread-binding.d.ts +95 -0
  90. package/dist/agents/channels/discord/subagent-thread-binding.d.ts.map +1 -0
  91. package/dist/agents/channels/discord/subagent-thread-binding.js +208 -0
  92. package/dist/agents/channels/discord/subagent-thread-binding.js.map +1 -0
  93. package/dist/agents/channels/discord/system-events.d.ts +31 -0
  94. package/dist/agents/channels/discord/system-events.d.ts.map +1 -0
  95. package/dist/agents/channels/discord/system-events.js +74 -0
  96. package/dist/agents/channels/discord/system-events.js.map +1 -0
  97. package/dist/agents/channels/general-callback.d.ts +12 -0
  98. package/dist/agents/channels/general-callback.d.ts.map +1 -1
  99. package/dist/agents/channels/general-callback.js +18 -0
  100. package/dist/agents/channels/general-callback.js.map +1 -1
  101. package/dist/agents/channels/inbound-pipeline.d.ts.map +1 -1
  102. package/dist/agents/channels/inbound-pipeline.js +5 -3
  103. package/dist/agents/channels/inbound-pipeline.js.map +1 -1
  104. package/dist/agents/extensions/types.d.ts +7 -0
  105. package/dist/agents/extensions/types.d.ts.map +1 -1
  106. package/dist/agents/extensions/types.js.map +1 -1
  107. package/dist/agents/subagent-announce-delivery.d.ts +10 -0
  108. package/dist/agents/subagent-announce-delivery.d.ts.map +1 -1
  109. package/dist/agents/subagent-announce-delivery.js +1 -0
  110. package/dist/agents/subagent-announce-delivery.js.map +1 -1
  111. package/dist/agents/subagent-completion-bridge.d.ts.map +1 -1
  112. package/dist/agents/subagent-completion-bridge.js +81 -0
  113. package/dist/agents/subagent-completion-bridge.js.map +1 -1
  114. package/dist/agents/subagent-spawn.d.ts.map +1 -1
  115. package/dist/agents/subagent-spawn.js +57 -4
  116. package/dist/agents/subagent-spawn.js.map +1 -1
  117. package/dist/agents/tools/discord-action-tool.d.ts +224 -0
  118. package/dist/agents/tools/discord-action-tool.d.ts.map +1 -0
  119. package/dist/agents/tools/discord-action-tool.js +848 -0
  120. package/dist/agents/tools/discord-action-tool.js.map +1 -0
  121. package/dist/agents/tools/registry.d.ts.map +1 -1
  122. package/dist/agents/tools/registry.js +21 -0
  123. package/dist/agents/tools/registry.js.map +1 -1
  124. package/dist/agents/tools/sessions/index.d.ts +8 -0
  125. package/dist/agents/tools/sessions/index.d.ts.map +1 -1
  126. package/dist/agents/tools/sessions/index.js +15 -3
  127. package/dist/agents/tools/sessions/index.js.map +1 -1
  128. package/dist/buildstamp.json +1 -1
  129. package/dist/cli/commands/channels.d.ts +2 -0
  130. package/dist/cli/commands/channels.d.ts.map +1 -1
  131. package/dist/cli/commands/channels.js +58 -1
  132. package/dist/cli/commands/channels.js.map +1 -1
  133. package/package.json +1 -1
@@ -32,8 +32,15 @@
32
32
  */
33
33
  import { createDedupeCache, nextBackoffDelay, } from "../sdk.js";
34
34
  import { maskProxyUrl } from "./account-config.js";
35
- import { buildDiscordSenderName, discordChannelType, discordThreadId, extractDiscordMemberRoleIds, extractDiscordMentions, extractDiscordReplyContext, extractDiscordText, hasInboundMedia, isThreadChannel, resolveInboundAttachments, } from "./inbound-extras.js";
35
+ import { isDiscordSelectSpec, } from "./components.js";
36
+ import { DISCORD_FLAG_IS_COMPONENTS_V2, DISCORD_BUTTON_STYLE_LINK, isDiscordLinkButton, isDiscordV2MessageSpec, } from "./component-blocks.js";
37
+ import { buildDiscordModal, decodeDiscordModalCustomId, extractModalFieldValues, formatModalSubmissionText, isDiscordModalCustomId, } from "./modals.js";
38
+ import { consumeDiscordModal, getDiscordModal } from "./modal-registry.js";
39
+ import { rememberDiscordUser } from "./directory-cache.js";
40
+ import { assembleDiscordText, buildDiscordSenderName, discordChannelType, discordThreadId, extractDiscordMemberRoleIds, extractDiscordMentions, extractDiscordReplyContext, hasInboundMedia, isThreadChannel, resolveInboundAttachments, } from "./inbound-extras.js";
36
41
  import { buildDiscordAttachment, downloadDiscordAttachment } from "./media.js";
42
+ import { isDiscordUserMessageType, resolveDiscordSystemEvent } from "./system-events.js";
43
+ import { forgetDiscordSubagentThreadBindingByThreadId } from "./subagent-thread-binding-store.js";
37
44
  /* ───────────────────────── reconnect backoff ───────────────────────── */
38
45
  // Shares the neutral `nextBackoffDelay` curve with every other channel (see
39
46
  // `channels/backoff.ts`), tuned to the SAME schedule WhatsApp + Telegram + Slack
@@ -60,6 +67,48 @@ export function discordBackoffDelay(attempt) {
60
67
  }
61
68
  /** Discord's hard per-message content limit (chars). Sends chunk under this. */
62
69
  const DISCORD_MESSAGE_LIMIT = 2_000;
70
+ /* ───────────────────────── channel-type + flag constants ───────────────────────── */
71
+ /**
72
+ * discord.js `ChannelType` values for forum/media channels (Fix 2b). A plain
73
+ * `.send()` to these is REJECTED — the connection opens a thread (forum post)
74
+ * instead. Hardcoded so the connection never has to import the discord.js enum on
75
+ * the (injected-fake) test path; the values are stable wire constants.
76
+ */
77
+ const CHANNEL_TYPE_GUILD_FORUM = 15;
78
+ const CHANNEL_TYPE_GUILD_MEDIA = 16;
79
+ const CHANNEL_TYPE_GUILD_VOICE = 2;
80
+ const CHANNEL_TYPE_GUILD_STAGE_VOICE = 13;
81
+ /**
82
+ * Channel types where a message-thread can't be started (Fix 6). Forum/Media
83
+ * channels only host posts (their own threads), and Voice/Stage channels have no
84
+ * message-thread surface — `message.startThread(...)` 400s on all of them. The
85
+ * auto-thread path guards on these to avoid a wasteful failed REST call + a noisy
86
+ * error log (it already falls back to an un-threaded reply).
87
+ */
88
+ const THREAD_UNSUPPORTED_CHANNEL_TYPES = new Set([
89
+ CHANNEL_TYPE_GUILD_FORUM,
90
+ CHANNEL_TYPE_GUILD_MEDIA,
91
+ CHANNEL_TYPE_GUILD_VOICE,
92
+ CHANNEL_TYPE_GUILD_STAGE_VOICE,
93
+ ]);
94
+ /** `MessageFlags.SuppressNotifications` (1 << 12) — a silent send (Fix 2c). */
95
+ const MESSAGE_FLAG_SUPPRESS_NOTIFICATIONS = 1 << 12;
96
+ /** Discord thread titles are capped at 100 chars. */
97
+ const DISCORD_THREAD_NAME_LIMIT = 100;
98
+ /** True for a forum / media channel (which rejects a plain `.send()`). */
99
+ function isForumLikeChannel(channel) {
100
+ return channel.type === CHANNEL_TYPE_GUILD_FORUM || channel.type === CHANNEL_TYPE_GUILD_MEDIA;
101
+ }
102
+ /**
103
+ * Derive a forum-post thread name from the first non-empty line of the body,
104
+ * trimmed to {@link DISCORD_THREAD_NAME_LIMIT}. Falls back to a timestamp stub
105
+ * when the body is empty so the post always has a title.
106
+ */
107
+ function deriveForumThreadName(text) {
108
+ const firstLine = (text ?? "").split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
109
+ const name = firstLine.slice(0, DISCORD_THREAD_NAME_LIMIT).trim();
110
+ return name || new Date().toISOString().slice(0, 16);
111
+ }
63
112
  /**
64
113
  * The SAFE default `allowedMentions` applied to every outbound Discord send.
65
114
  * `parse: ["users", "roles"]` lets explicit `<@id>` / `<@&roleid>` mentions ping
@@ -72,6 +121,28 @@ const DISCORD_MESSAGE_LIMIT = 2_000;
72
121
  export function safeDiscordAllowedMentions() {
73
122
  return { parse: ["users", "roles"], repliedUser: false };
74
123
  }
124
+ /**
125
+ * Sanitize a string into a Discord thread name (Phase 5 autoThread): take the
126
+ * first non-empty line, strip user/role/channel mention markup, collapse
127
+ * whitespace, and truncate to Discord's 100-char limit. Falls back to
128
+ * `"Thread <id>"` when nothing usable remains.
129
+ */
130
+ export function sanitizeThreadName(raw, fallbackId) {
131
+ const firstLine = (raw ?? "")
132
+ .split(/\r?\n/)
133
+ .map((l) => l.trim())
134
+ .find((l) => l.length > 0);
135
+ const cleaned = (firstLine ?? "")
136
+ .replace(/<@!?\d+>/g, "") // user mentions
137
+ .replace(/<@&\d+>/g, "") // role mentions
138
+ .replace(/<#\d+>/g, "") // channel mentions
139
+ .replace(/\s+/g, " ")
140
+ .trim();
141
+ const base = cleaned || `Thread ${fallbackId}`;
142
+ // Truncate to 100 UTF-16 code units (Discord's hard cap).
143
+ const truncated = base.length > 100 ? base.slice(0, 100).trim() : base;
144
+ return truncated || `Thread ${fallbackId}`;
145
+ }
75
146
  /* ───────────────────────── error classification ───────────────────────── */
76
147
  /** Pull a message string off any thrown shape. */
77
148
  function errorText(err) {
@@ -98,6 +169,40 @@ export function isDiscordUnauthorized(err) {
98
169
  return true;
99
170
  return /invalid token|incorrect login|disallowed intents|used disallowed intents/i.test(errorText(err));
100
171
  }
172
+ /* ───────────────────────── structured send-error decode (Fix 2d) ───────────────────────── */
173
+ /** Discord JSON error code: the bot lacks permission to act in the channel. */
174
+ const DISCORD_ERR_MISSING_PERMISSIONS = 50013;
175
+ /** Discord JSON error code: cannot send messages to this user (DM blocked / disabled). */
176
+ const DISCORD_ERR_CANNOT_SEND_TO_USER = 50007;
177
+ /** Pull the numeric Discord error `code` off a thrown discord.js error, if any. */
178
+ function discordErrorCode(err) {
179
+ if (!err || typeof err !== "object")
180
+ return undefined;
181
+ const e = err;
182
+ const candidate = e.code !== undefined ? e.code : e.rawError?.code;
183
+ if (typeof candidate === "number")
184
+ return candidate;
185
+ if (typeof candidate === "string" && /^\d+$/.test(candidate))
186
+ return Number(candidate);
187
+ return undefined;
188
+ }
189
+ /**
190
+ * Turn a raw discord.js send error into an operator-readable one for the two
191
+ * actionable cases (Fix 2d). A 50013 (Missing Permissions) and a 50007
192
+ * (cannot-send-to-user / DM blocked) each map to a specific remediation hint;
193
+ * every other error is rethrown VERBATIM so nothing is masked. Wrapped around the
194
+ * three send fns so `adapter.handleAction`'s catch surfaces the decoded message.
195
+ */
196
+ function decodeDiscordSendError(err) {
197
+ const code = discordErrorCode(err);
198
+ if (code === DISCORD_ERR_MISSING_PERMISSIONS) {
199
+ return new Error("Missing permission to post in this channel (need View Channel + Send Messages).");
200
+ }
201
+ if (code === DISCORD_ERR_CANNOT_SEND_TO_USER) {
202
+ return new Error("Can't DM this user — they've blocked the bot or disabled DMs.");
203
+ }
204
+ return err instanceof Error ? err : new Error(typeof err === "string" ? err : String(err));
205
+ }
101
206
  /** Strip a Discord token out of a string before it logs. */
102
207
  export function redactDiscordToken(text, ...tokens) {
103
208
  if (!text)
@@ -112,6 +217,150 @@ export function redactDiscordToken(text, ...tokens) {
112
217
  out = out.replace(/[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{20,}/g, "<redacted>");
113
218
  return out;
114
219
  }
220
+ /**
221
+ * Build the discord.js `ActionRowBuilder` wrapping ONE select menu (Fix 3a). The
222
+ * `kind` picks the builder; a STRING select adds its options, an entity select
223
+ * (user/role/channel/mentionable) carries none. Placeholder + min/max are set
224
+ * when present.
225
+ */
226
+ function buildSelectActionRow(discord, spec) {
227
+ const applyCommon = (menu) => {
228
+ menu.setCustomId(spec.customId);
229
+ if (spec.placeholder && typeof menu.setPlaceholder === "function")
230
+ menu.setPlaceholder(spec.placeholder);
231
+ if (typeof spec.minValues === "number" && typeof menu.setMinValues === "function")
232
+ menu.setMinValues(spec.minValues);
233
+ if (typeof spec.maxValues === "number" && typeof menu.setMaxValues === "function")
234
+ menu.setMaxValues(spec.maxValues);
235
+ };
236
+ let menu;
237
+ switch (spec.kind) {
238
+ case "user": {
239
+ const m = new discord.UserSelectMenuBuilder();
240
+ applyCommon(m);
241
+ menu = m;
242
+ break;
243
+ }
244
+ case "role": {
245
+ const m = new discord.RoleSelectMenuBuilder();
246
+ applyCommon(m);
247
+ menu = m;
248
+ break;
249
+ }
250
+ case "channel": {
251
+ const m = new discord.ChannelSelectMenuBuilder();
252
+ applyCommon(m);
253
+ menu = m;
254
+ break;
255
+ }
256
+ case "mentionable": {
257
+ const m = new discord.MentionableSelectMenuBuilder();
258
+ applyCommon(m);
259
+ menu = m;
260
+ break;
261
+ }
262
+ case "string":
263
+ default: {
264
+ const s = new discord.StringSelectMenuBuilder();
265
+ applyCommon(s);
266
+ const options = (spec.options ?? []).map((o) => {
267
+ const opt = new discord.StringSelectMenuOptionBuilder().setLabel(o.label).setValue(o.value);
268
+ if (o.description)
269
+ opt.setDescription(o.description);
270
+ return opt;
271
+ });
272
+ if (options.length > 0)
273
+ s.addOptions(...options);
274
+ menu = s;
275
+ break;
276
+ }
277
+ }
278
+ const row = new discord.ActionRowBuilder();
279
+ row.addComponents(menu);
280
+ return row;
281
+ }
282
+ /** Map ONE V2 block spec to its discord.js builder (Fix 3c). */
283
+ function buildV2Block(discord, block) {
284
+ switch (block.type) {
285
+ case "text":
286
+ return new discord.TextDisplayBuilder().setContent(block.text);
287
+ case "section": {
288
+ const section = new discord.SectionBuilder();
289
+ section.addTextDisplayComponents(...block.texts.map((t) => new discord.TextDisplayBuilder().setContent(t)));
290
+ if (block.accessory?.kind === "thumbnail") {
291
+ section.setThumbnailAccessory(new discord.ThumbnailBuilder().setURL(block.accessory.url));
292
+ }
293
+ else if (block.accessory?.kind === "button") {
294
+ section.setButtonAccessory(buildV2Button(discord, block.accessory.button));
295
+ }
296
+ return section;
297
+ }
298
+ case "separator": {
299
+ const sep = new discord.SeparatorBuilder();
300
+ if (typeof block.divider === "boolean")
301
+ sep.setDivider(block.divider);
302
+ if (block.spacing) {
303
+ const spacing = block.spacing === "large" ? 2 : 1;
304
+ sep.setSpacing(spacing);
305
+ }
306
+ return sep;
307
+ }
308
+ case "actions": {
309
+ const row = new discord.ActionRowBuilder();
310
+ row.addComponents(...block.buttons.map((b) => buildV2Button(discord, b)));
311
+ return row;
312
+ }
313
+ case "media-gallery": {
314
+ const gallery = new discord.MediaGalleryBuilder();
315
+ gallery.addItems(...block.items.map((it) => {
316
+ const item = new discord.MediaGalleryItemBuilder().setURL(it.url);
317
+ if (it.description)
318
+ item.setDescription(it.description);
319
+ if (typeof it.spoiler === "boolean")
320
+ item.setSpoiler(it.spoiler);
321
+ return item;
322
+ }));
323
+ return gallery;
324
+ }
325
+ case "file": {
326
+ const file = new discord.FileBuilder().setURL(block.url);
327
+ if (typeof block.spoiler === "boolean")
328
+ file.setSpoiler(block.spoiler);
329
+ return file;
330
+ }
331
+ default:
332
+ return new discord.TextDisplayBuilder().setContent("");
333
+ }
334
+ }
335
+ /** Build a V2 button (a link button when it has a url, else an interactive button). */
336
+ function buildV2Button(discord, b) {
337
+ if (isDiscordLinkButton(b)) {
338
+ return new discord.ButtonBuilder().setLabel(b.label).setStyle(DISCORD_BUTTON_STYLE_LINK).setURL(b.url);
339
+ }
340
+ return new discord.ButtonBuilder().setLabel(b.label).setCustomId(b.customId).setStyle((b.style ?? 2));
341
+ }
342
+ /** Build the discord.js `ContainerBuilder` for a Components-V2 message (Fix 3c). */
343
+ function buildV2Container(discord, spec) {
344
+ const container = new discord.ContainerBuilder();
345
+ if (typeof spec.accentColor === "number")
346
+ container.setAccentColor(spec.accentColor);
347
+ for (const block of spec.blocks) {
348
+ const built = buildV2Block(discord, block);
349
+ if (block.type === "section")
350
+ container.addSectionComponents(built);
351
+ else if (block.type === "separator")
352
+ container.addSeparatorComponents(built);
353
+ else if (block.type === "actions")
354
+ container.addActionRowComponents(built);
355
+ else if (block.type === "media-gallery")
356
+ container.addMediaGalleryComponents(built);
357
+ else if (block.type === "file")
358
+ container.addFileComponents(built);
359
+ else
360
+ container.addTextDisplayComponents(built);
361
+ }
362
+ return container;
363
+ }
115
364
  /* ───────────────────────── the connection ───────────────────────── */
116
365
  export async function connectDiscord(args) {
117
366
  const accountId = args.accountId ?? "default";
@@ -141,6 +390,7 @@ export async function connectDiscord(args) {
141
390
  builders ??= {
142
391
  buildAttachment: (p, name) => ({ attachment: p, name }),
143
392
  buildComponentRows: (rows) => rows.map((row) => ({ components: row })),
393
+ buildModal: (params) => ({ modal: params }),
144
394
  };
145
395
  }
146
396
  else {
@@ -185,6 +435,15 @@ export async function connectDiscord(args) {
185
435
  },
186
436
  buildComponentRows(rows) {
187
437
  return rows.map((row) => {
438
+ // SELECT row (Fix 3a) — alone in its ActionRow; the kind picks the builder.
439
+ if (isDiscordSelectSpec(row)) {
440
+ return buildSelectActionRow(discord, row);
441
+ }
442
+ // Components-V2 container (Fix 3c) — a single ContainerBuilder of blocks.
443
+ if (isDiscordV2MessageSpec(row)) {
444
+ return buildV2Container(discord, row);
445
+ }
446
+ // Classic BUTTON row (unchanged byte-for-byte behavior).
188
447
  const r = new discord.ActionRowBuilder();
189
448
  for (const b of row) {
190
449
  r.addComponents(new discord.ButtonBuilder().setCustomId(b.customId).setLabel(b.label).setStyle(b.style));
@@ -192,6 +451,13 @@ export async function connectDiscord(args) {
192
451
  return r;
193
452
  });
194
453
  },
454
+ buildModal(params) {
455
+ return buildDiscordModal({
456
+ ModalBuilder: discord.ModalBuilder,
457
+ ActionRowBuilder: discord.ActionRowBuilder,
458
+ TextInputBuilder: discord.TextInputBuilder,
459
+ }, params);
460
+ },
195
461
  };
196
462
  }
197
463
  // `builders` is always assigned by here (every branch sets it); the non-null
@@ -211,6 +477,32 @@ export async function connectDiscord(args) {
211
477
  let reconnectAttempts = 0;
212
478
  let client = null;
213
479
  let loopPromise = null;
480
+ // READY-gating (Phase 5): a socket can `login()` (open) yet never reach the
481
+ // `clientReady` handshake. `ready` is the trustworthy "fully up" flag the
482
+ // presence apply + the watchdog gate on.
483
+ let ready = false;
484
+ // Resolved presence to (re)apply on every READY (Phase 5). `null` → leave
485
+ // Discord's default presence untouched.
486
+ const presencePayload = args.presence ?? null;
487
+ const setPresenceImpl = args.setPresenceImpl ??
488
+ ((c, payload) => {
489
+ c.user?.setPresence?.(payload);
490
+ });
491
+ /** Apply (or re-apply) a presence payload to the live client. Best-effort. */
492
+ const applyPresence = (payload) => {
493
+ const c = client;
494
+ const p = payload === undefined ? presencePayload : payload;
495
+ if (!c || !p)
496
+ return;
497
+ try {
498
+ setPresenceImpl(c, p);
499
+ }
500
+ catch (err) {
501
+ safeLog("discord presence apply failed", { error: err instanceof Error ? err.message : String(err) });
502
+ }
503
+ };
504
+ // READY watchdog (Phase 5): default ~20s deadline; ≤0 disables it.
505
+ const readyTimeoutMs = typeof args.readyTimeoutMs === "number" ? args.readyTimeoutMs : 20_000;
214
506
  // Dedupe inbound events by id — a redelivered event after a reconnect must not
215
507
  // double-run the agent. Per-connection lifetime.
216
508
  const eventDedupe = createDedupeCache({ maxEntries: 10_000, ttlMs: 60 * 60 * 1_000 });
@@ -235,13 +527,51 @@ export async function connectDiscord(args) {
235
527
  }
236
528
  return { user: (id) => users.get(id) };
237
529
  };
530
+ /**
531
+ * Prime the account's handle→id directory cache (Fix 2a) from an inbound: the
532
+ * message author plus every resolved `<@…>` mention. This is what later lets
533
+ * the outbound path rewrite a plain `@handle` the agent typed into a real
534
+ * `<@id>` ping. Best-effort + side-effect-only — never throws into normalize.
535
+ */
536
+ const primeDirectoryFromMessage = (message) => {
537
+ try {
538
+ const author = message.author;
539
+ if (author && typeof author.id === "string") {
540
+ rememberDiscordUser(accountId, {
541
+ id: author.id,
542
+ username: author.username ?? undefined,
543
+ displayName: (author.globalName ?? author.displayName ?? undefined),
544
+ });
545
+ }
546
+ const mentionUsers = message.mentions?.users;
547
+ if (mentionUsers) {
548
+ const iter = mentionUsers instanceof Map ? mentionUsers.values() : mentionUsers;
549
+ for (const u of iter) {
550
+ if (typeof u?.id !== "string")
551
+ continue;
552
+ rememberDiscordUser(accountId, {
553
+ id: u.id,
554
+ username: u.username ?? undefined,
555
+ displayName: (u.globalName ?? u.displayName ?? undefined),
556
+ });
557
+ }
558
+ }
559
+ }
560
+ catch {
561
+ /* directory priming is best-effort */
562
+ }
563
+ };
238
564
  /** Normalize a discord.js message into the deferred-media inbound shape. */
239
565
  const normalize = (message, opts) => {
240
566
  const channelId = typeof message.channelId === "string" ? message.channelId : typeof message.channel?.id === "string" ? message.channel.id : "";
241
567
  if (!channelId)
242
568
  return null;
569
+ primeDirectoryFromMessage(message);
243
570
  const resolve = resolveLookups(message);
244
- const text = extractDiscordText(message, resolve);
571
+ // Assembled text: content leads, with an embed-title/description fallback when
572
+ // content is empty, plus appended `<sticker: …>` + `[Forwarded from …]` blocks
573
+ // so an embed-only / sticker-only / forwarded message isn't dropped as empty.
574
+ const text = assembleDiscordText(message, resolve);
245
575
  const chatType = discordChannelType(message);
246
576
  const threadId = discordThreadId(message);
247
577
  const mentions = extractDiscordMentions(message, selfId ?? undefined);
@@ -297,8 +627,55 @@ export async function connectDiscord(args) {
297
627
  // but only OUR own id is a definite echo; other bots are allowed through.
298
628
  return false;
299
629
  };
300
- /** Handle a messageCreate / messageUpdate event. */
301
- const handleMessage = (message, opts) => {
630
+ /**
631
+ * Best-effort resolve the parent of a reply into `replyTo.body` (+ `from`).
632
+ * Discord doesn't inline the replied-to text, so we fetch it: `fetchReference()`
633
+ * first (discord.js resolves the reference directly), then `channel.messages.fetch(id)`
634
+ * as a fallback. The body is the parent's assembled text (token-expanded), hard-capped.
635
+ * Mutates `normalized.replyTo` in place. Fully guarded — any error leaves the
636
+ * `{ messageId }`-only context untouched so delivery never blocks/fails.
637
+ */
638
+ const backfillReplyBody = async (message, normalized) => {
639
+ const refId = message.reference?.messageId;
640
+ if (!normalized.replyTo || normalized.replyTo.body || typeof refId !== "string" || !refId)
641
+ return;
642
+ try {
643
+ let parent = null;
644
+ if (typeof message.fetchReference === "function") {
645
+ parent = await message.fetchReference();
646
+ }
647
+ if (!parent && typeof message.channel?.messages?.fetch === "function") {
648
+ parent = await message.channel.messages.fetch(refId);
649
+ }
650
+ if (!parent)
651
+ return;
652
+ const body = assembleDiscordText(parent, resolveLookups(parent)).replace(/\n/g, " ").slice(0, 300);
653
+ const from = typeof parent.author?.id === "string" ? parent.author.id : undefined;
654
+ if (body || from) {
655
+ normalized.replyTo = {
656
+ ...normalized.replyTo,
657
+ ...(body ? { body } : {}),
658
+ ...(from ? { from } : {}),
659
+ };
660
+ }
661
+ }
662
+ catch (err) {
663
+ safeLog("discord reply-parent backfill failed", { error: err instanceof Error ? err.message : String(err) });
664
+ }
665
+ };
666
+ /**
667
+ * Handle a messageCreate / messageUpdate event.
668
+ *
669
+ * ASYNC because of three best-effort REST hydrations (all guarded, all
670
+ * post-`normalize`, mirroring the Slack thread-backfill pattern):
671
+ * - reply-parent backfill → fills `replyTo.body` so the agent sees what was
672
+ * replied to (Fix 1b);
673
+ * - system events (joins / pins / boosts / thread-created …) → synthesize a
674
+ * concise note as the inbound text so the agent learns the event (Fix 1c);
675
+ * - empty-payload hydration → re-pull a late / proxied empty-content message
676
+ * once and re-assemble before bailing (Fix 1d).
677
+ */
678
+ const handleMessage = async (message, opts) => {
302
679
  try {
303
680
  stampInboundEvent();
304
681
  // Skip the bot's own messages (echoes) — a bot must never reply to itself.
@@ -316,9 +693,61 @@ export async function connectDiscord(args) {
316
693
  const key = opts?.edited ? `edit:${id}:${editStamp}` : id;
317
694
  if (!eventDedupe.claim(key))
318
695
  return; // already seen
319
- const normalized = normalize(message, opts);
696
+ // SYSTEM event (join / pin / boost / thread-created …): no user content, so
697
+ // synthesize a concise note as the inbound text and route it (no debounce).
698
+ // An UNMAPPED system type yields null → drop it. Checked BEFORE normalize so
699
+ // a content-less system message isn't treated as an empty user message.
700
+ if (!isDiscordUserMessageType(message.type)) {
701
+ const channelId = typeof message.channelId === "string" ? message.channelId : typeof message.channel?.id === "string" ? message.channel.id : "";
702
+ const note = resolveDiscordSystemEvent(message, channelId);
703
+ if (!note)
704
+ return; // unmapped system type — drop
705
+ const normalized = normalize(message, opts);
706
+ if (!normalized)
707
+ return;
708
+ normalized.text = note;
709
+ args.onMessage(normalized);
710
+ lastInboundChannel.add(normalized.conversationId);
711
+ return;
712
+ }
713
+ let normalized = normalize(message, opts);
320
714
  if (!normalized)
321
715
  return;
716
+ // Does this message need any async REST hydration? (Empty-payload re-pull OR
717
+ // reply-parent backfill.) When NOTHING async applies — the common case — we
718
+ // deliver SYNCHRONOUSLY so callers see the inbound on the same tick.
719
+ const needsHydration = !normalized.text.trim() && !hasInboundMedia(message) && typeof message.fetch === "function";
720
+ const refId = message.reference?.messageId;
721
+ const needsReplyBackfill = !!normalized.replyTo &&
722
+ !normalized.replyTo.body &&
723
+ typeof refId === "string" &&
724
+ !!refId &&
725
+ (typeof message.fetchReference === "function" || typeof message.channel?.messages?.fetch === "function");
726
+ if (!needsHydration && !needsReplyBackfill) {
727
+ args.onMessage(normalized);
728
+ lastInboundChannel.add(normalized.conversationId);
729
+ return;
730
+ }
731
+ // Empty-payload hydration (Fix 1d): the MESSAGE CONTENT intent can deliver a
732
+ // late / proxied payload with empty content + no media. Best-effort re-pull
733
+ // the message once, re-assemble, and only bail if STILL empty.
734
+ if (needsHydration) {
735
+ try {
736
+ const refetched = await message.fetch();
737
+ if (refetched && typeof refetched === "object") {
738
+ const renorm = normalize(refetched, opts);
739
+ if (renorm && (renorm.text.trim() || renorm.resolveMedia)) {
740
+ normalized = renorm;
741
+ message = refetched;
742
+ }
743
+ }
744
+ }
745
+ catch (err) {
746
+ safeLog("discord empty-payload hydration failed", { error: err instanceof Error ? err.message : String(err) });
747
+ }
748
+ }
749
+ // Reply-parent backfill (Fix 1b): fill `replyTo.body` from the parent message.
750
+ await backfillReplyBody(message, normalized);
322
751
  args.onMessage(normalized);
323
752
  lastInboundChannel.add(normalized.conversationId);
324
753
  }
@@ -346,6 +775,8 @@ export async function connectDiscord(args) {
346
775
  return null; // ignore other bots' reactions
347
776
  const fromName = user?.username;
348
777
  const guildId = typeof msg?.guildId === "string" ? msg.guildId : undefined;
778
+ // Author of the REACTED message — lets the adapter gate `reactionNotifications: "own"`.
779
+ const targetAuthorId = typeof msg?.author?.id === "string" ? msg.author.id : undefined;
349
780
  return {
350
781
  conversationId: channel,
351
782
  from: fromId,
@@ -353,7 +784,7 @@ export async function connectDiscord(args) {
353
784
  text: "",
354
785
  chatType: msg?.guildId ? "group" : "direct",
355
786
  ...(guildId ? { guildId } : {}),
356
- reaction: { emojis: [emoji], targetMessageId: target },
787
+ reaction: { emojis: [emoji], targetMessageId: target, ...(targetAuthorId ? { targetAuthorId } : {}) },
357
788
  raw: { reaction, user },
358
789
  };
359
790
  };
@@ -401,6 +832,76 @@ export async function connectDiscord(args) {
401
832
  raw: interaction,
402
833
  };
403
834
  };
835
+ /**
836
+ * Normalize a SELECT-menu press into the same callback-inbound shape a button
837
+ * press uses (Fix 3a), but carrying the chosen `values`. The select's custom_id
838
+ * IS a general token (the central pipeline surfaces the values in the turn
839
+ * text). For an ENTITY select the raw values are Discord ids — they're prefixed
840
+ * with the select's kind (`user:` / `role:` / `channel:` / `mentionable:`) so
841
+ * the agent can tell what kind of id it received; a STRING select's values are
842
+ * the option values verbatim.
843
+ */
844
+ const normalizeSelect = (interaction, kind) => {
845
+ const value = typeof interaction.customId === "string" ? interaction.customId : "";
846
+ if (!value)
847
+ return null;
848
+ const channel = typeof interaction.channelId === "string" ? interaction.channelId : typeof interaction.channel?.id === "string" ? interaction.channel.id : "";
849
+ const fromId = typeof interaction.user?.id === "string" ? interaction.user.id : channel;
850
+ if (!channel && !fromId)
851
+ return null;
852
+ const threadId = interaction.channel && isThreadChannel(interaction.channel) ? channel : undefined;
853
+ const fromName = interaction.user?.username;
854
+ const rawValues = Array.isArray(interaction.values) ? interaction.values.filter((v) => typeof v === "string") : [];
855
+ const prefix = kind === "string" ? "" : `${kind}:`;
856
+ const values = rawValues.map((v) => `${prefix}${v}`);
857
+ return {
858
+ conversationId: channel || fromId,
859
+ from: fromId,
860
+ ...(fromName ? { fromName } : {}),
861
+ text: "",
862
+ chatType: interaction.guildId ? "group" : "direct",
863
+ ...(typeof interaction.guildId === "string" ? { guildId: interaction.guildId } : {}),
864
+ ...(threadId ? { threadId } : {}),
865
+ callbackQuery: { data: value, callbackId: interaction.id ?? "", values },
866
+ raw: interaction,
867
+ };
868
+ };
869
+ /**
870
+ * Normalize a MODAL SUBMIT into an ordinary inbound MESSAGE (Fix 3b). A filled
871
+ * form is a typed turn, not a button tap, so it routes via `onMessage` carrying
872
+ * the formatted `Label: value` body — the agent sees what was entered exactly as
873
+ * if the person typed it. The modal entry is consumed (single-use); a missing /
874
+ * expired entry yields null so the submit degrades gracefully.
875
+ */
876
+ const normalizeModalSubmit = (interaction) => {
877
+ const customId = typeof interaction.customId === "string" ? interaction.customId : "";
878
+ const modalId = decodeDiscordModalCustomId(customId);
879
+ if (!modalId)
880
+ return null;
881
+ const entry = consumeDiscordModal(modalId);
882
+ if (!entry) {
883
+ safeLog("discord modal submit for an unknown/expired form — dropped", { modalId });
884
+ return null;
885
+ }
886
+ const channel = typeof interaction.channelId === "string" ? interaction.channelId : typeof interaction.channel?.id === "string" ? interaction.channel.id : "";
887
+ const fromId = typeof interaction.user?.id === "string" ? interaction.user.id : channel;
888
+ if (!channel && !fromId)
889
+ return null;
890
+ const threadId = interaction.channel && isThreadChannel(interaction.channel) ? channel : undefined;
891
+ const fromName = interaction.user?.username;
892
+ const values = extractModalFieldValues(interaction, entry.fields);
893
+ const text = formatModalSubmissionText(entry, values);
894
+ return {
895
+ conversationId: channel || fromId,
896
+ from: fromId,
897
+ ...(fromName ? { fromName } : {}),
898
+ text,
899
+ chatType: interaction.guildId ? "group" : "direct",
900
+ ...(typeof interaction.guildId === "string" ? { guildId: interaction.guildId } : {}),
901
+ ...(threadId ? { threadId } : {}),
902
+ raw: interaction,
903
+ };
904
+ };
404
905
  /**
405
906
  * Normalize a slash-command interaction into an ordinary inbound message so the
406
907
  * central command map (`/help`, `/status`, …) handles it. The command name is
@@ -423,11 +924,35 @@ export async function connectDiscord(args) {
423
924
  raw: interaction,
424
925
  };
425
926
  };
426
- /** Handle an interactionCreate event (button press OR slash command). */
927
+ /**
928
+ * The five select-menu type guards mapped to their kind. The first that fires
929
+ * wins (exactly one is true for a given select press).
930
+ */
931
+ const selectKindOf = (interaction) => {
932
+ if (typeof interaction.isStringSelectMenu === "function" && interaction.isStringSelectMenu())
933
+ return "string";
934
+ if (typeof interaction.isUserSelectMenu === "function" && interaction.isUserSelectMenu())
935
+ return "user";
936
+ if (typeof interaction.isRoleSelectMenu === "function" && interaction.isRoleSelectMenu())
937
+ return "role";
938
+ if (typeof interaction.isChannelSelectMenu === "function" && interaction.isChannelSelectMenu())
939
+ return "channel";
940
+ if (typeof interaction.isMentionableSelectMenu === "function" && interaction.isMentionableSelectMenu())
941
+ return "mentionable";
942
+ return null;
943
+ };
944
+ /** Handle an interactionCreate event (button / select / modal-submit / slash). */
427
945
  const handleInteraction = (interaction) => {
428
946
  try {
429
947
  stampInboundEvent();
430
948
  if (typeof interaction.isButton === "function" && interaction.isButton()) {
949
+ // A button whose custom_id is a `modal:<id>` marker OPENS a modal instead
950
+ // of routing a turn (Fix 3b). Otherwise ack silently + route the press.
951
+ const customId = typeof interaction.customId === "string" ? interaction.customId : "";
952
+ if (isDiscordModalCustomId(customId)) {
953
+ void openModalForTrigger(interaction, customId);
954
+ return;
955
+ }
431
956
  // Ack the press silently first so Discord doesn't show "interaction
432
957
  // failed"; then route the normalized inbound.
433
958
  void interaction.deferUpdate?.().catch(() => { });
@@ -436,6 +961,24 @@ export async function connectDiscord(args) {
436
961
  args.onCallbackQuery?.(normalized);
437
962
  return;
438
963
  }
964
+ const selectKind = selectKindOf(interaction);
965
+ if (selectKind) {
966
+ // A select press is acked silently (no visible change) then routed like a
967
+ // button press, carrying the chosen values (Fix 3a).
968
+ void interaction.deferUpdate?.().catch(() => { });
969
+ const normalized = normalizeSelect(interaction, selectKind);
970
+ if (normalized)
971
+ args.onCallbackQuery?.(normalized);
972
+ return;
973
+ }
974
+ if (typeof interaction.isModalSubmit === "function" && interaction.isModalSubmit()) {
975
+ // Ack the submit so the form closes; route the filled form as a turn (Fix 3b).
976
+ void interaction.deferUpdate?.().catch(() => { });
977
+ const normalized = normalizeModalSubmit(interaction);
978
+ if (normalized)
979
+ args.onMessage(normalized);
980
+ return;
981
+ }
439
982
  if (typeof interaction.isChatInputCommand === "function" && interaction.isChatInputCommand()) {
440
983
  // Ack the command ephemerally so the client spinner clears; the real
441
984
  // reply is delivered by the pipeline as a normal channel message.
@@ -450,19 +993,84 @@ export async function connectDiscord(args) {
450
993
  safeLog("discord interaction handler error", { error: err instanceof Error ? err.message : String(err) });
451
994
  }
452
995
  };
996
+ /**
997
+ * Open the modal a modal-trigger button references (Fix 3b). Looks up the
998
+ * (non-consuming) registry entry, builds the discord.js modal via the injected
999
+ * builders, and calls `interaction.showModal`. A missing entry / absent builder
1000
+ * is acked-silently so the client doesn't hang. The entry is NOT consumed here —
1001
+ * consumption happens on submit so the modal stays openable until then.
1002
+ */
1003
+ const openModalForTrigger = async (interaction, customId) => {
1004
+ try {
1005
+ const modalId = decodeDiscordModalCustomId(customId);
1006
+ const entry = modalId ? getDiscordModal(modalId) : undefined;
1007
+ if (!entry || typeof interaction.showModal !== "function" || typeof resolvedBuilders.buildModal !== "function") {
1008
+ // Nothing to show — ack silently so the press doesn't read as failed.
1009
+ void interaction.deferUpdate?.().catch(() => { });
1010
+ if (modalId && !entry)
1011
+ safeLog("discord modal trigger for an unknown/expired form", { modalId });
1012
+ return;
1013
+ }
1014
+ const modal = resolvedBuilders.buildModal({ modalId, title: entry.title, entry });
1015
+ await interaction.showModal(modal);
1016
+ }
1017
+ catch (err) {
1018
+ safeLog("discord showModal failed", { error: err instanceof Error ? err.message : String(err) });
1019
+ void interaction.deferUpdate?.().catch(() => { });
1020
+ }
1021
+ };
1022
+ /**
1023
+ * Handle a THREAD_UPDATE (Fix 6). When a thread transitions INTO the archived
1024
+ * state (was not archived, now is), drop any sub-agent thread binding pointing
1025
+ * at it so an archived thread doesn't leak a binding. discord.js hands the old +
1026
+ * new `ThreadChannel`; we read `.archived` off each (a partial may omit `old`,
1027
+ * in which case we act on the new archived state alone). No-op for any other
1028
+ * thread update (rename, lock, slow-mode, …).
1029
+ */
1030
+ const handleThreadArchive = (oldThread, newThread) => {
1031
+ const next = newThread;
1032
+ const prev = oldThread;
1033
+ const threadId = typeof next?.id === "string" ? next.id : "";
1034
+ if (!threadId)
1035
+ return;
1036
+ const nowArchived = next?.archived === true;
1037
+ const wasArchived = prev?.archived === true;
1038
+ // Only act on the not-archived → archived transition (or when we have no prior
1039
+ // state to compare and it's archived now).
1040
+ if (!nowArchived || wasArchived)
1041
+ return;
1042
+ const dropped = forgetDiscordSubagentThreadBindingByThreadId(threadId);
1043
+ if (dropped > 0) {
1044
+ safeLog("discord thread archived — dropped sub-agent thread binding", { threadId, dropped });
1045
+ }
1046
+ };
453
1047
  /* ── event wiring ── */
454
1048
  const wireClient = (c) => {
455
- c.on("messageCreate", ((message) => handleMessage(message)));
1049
+ // handleMessage is async (it does best-effort REST hydration for reply bodies /
1050
+ // empty payloads); it self-guards every path, so the promise is voided here.
1051
+ c.on("messageCreate", ((message) => void handleMessage(message)));
456
1052
  c.on("messageUpdate", ((_old, updated) => {
457
1053
  // messageUpdate fires for non-content edits too (embeds resolving, pins);
458
1054
  // only route when there's content to act on.
459
1055
  if (updated && typeof updated === "object")
460
- handleMessage(updated, { edited: true });
1056
+ void handleMessage(updated, { edited: true });
461
1057
  }));
462
1058
  c.on("messageDelete", (() => {
463
1059
  // A deleted message carries no routable content — just stamp liveness.
464
1060
  stampInboundEvent();
465
1061
  }));
1062
+ // THREAD_UPDATE (Fix 6): when a thread transitions to archived, best-effort
1063
+ // drop any sub-agent thread binding for it so an archived thread doesn't leak
1064
+ // a binding. Fully guarded — a malformed/partial payload is a no-op.
1065
+ c.on("threadUpdate", ((oldThread, newThread) => {
1066
+ stampInboundEvent(); // thread state change is still gateway traffic (liveness)
1067
+ try {
1068
+ handleThreadArchive(oldThread, newThread);
1069
+ }
1070
+ catch {
1071
+ /* never let a thread-update payload break the listener */
1072
+ }
1073
+ }));
466
1074
  c.on("messageReactionAdd", ((reaction, user) => handleReactionAdd(reaction, user)));
467
1075
  c.on("messageReactionRemove", ((reaction, user) => handleReactionRemove(reaction, user)));
468
1076
  c.on("interactionCreate", ((interaction) => handleInteraction(interaction)));
@@ -499,15 +1107,26 @@ export async function connectDiscord(args) {
499
1107
  }));
500
1108
  };
501
1109
  /* ── bootstrap + supervise ── */
502
- /** Build the client, wire events, login, cache identity. */
1110
+ /**
1111
+ * Build the client, wire events, login, cache identity, and WAIT for the
1112
+ * Gateway to reach READY (Phase 5). A socket can `login()` (open) yet never
1113
+ * fire `clientReady`; we don't trust such a socket. When `readyTimeoutMs > 0`
1114
+ * and READY hasn't fired by the deadline we destroy the client and throw so the
1115
+ * supervise loop reconnects. On READY we apply the configured presence.
1116
+ */
503
1117
  const startOnce = async () => {
504
1118
  const c = buildClient(args.botToken);
505
1119
  client = c;
1120
+ ready = false;
506
1121
  wireClient(c);
507
1122
  // `clientReady` fires once the Gateway handshake + initial guild sync settle.
508
1123
  c.once("clientReady", (() => {
509
1124
  selfId = typeof c.user?.id === "string" ? c.user.id : null;
510
1125
  selfName = typeof c.user?.username === "string" ? c.user.username : null;
1126
+ ready = true;
1127
+ cancelReadyWatchdog();
1128
+ // Apply the configured presence once the client is fully ready.
1129
+ applyPresence();
511
1130
  }));
512
1131
  // login() rejects on an invalid token (terminal); resolves once the Gateway
513
1132
  // is identifying. We treat a resolved login as connected and read the cached
@@ -515,6 +1134,52 @@ export async function connectDiscord(args) {
515
1134
  await c.login(args.botToken);
516
1135
  selfId = selfId ?? (typeof c.user?.id === "string" ? c.user.id : null);
517
1136
  selfName = selfName ?? (typeof c.user?.username === "string" ? c.user.username : null);
1137
+ // Arm the READY watchdog (Phase 5, NON-blocking): a socket can open
1138
+ // (`login()` resolved) yet never fire `clientReady`. If that's still the case
1139
+ // after the deadline, destroy the client + re-enter the supervise loop so we
1140
+ // don't trust a half-open socket. `clientReady` cancels it. `startOnce`
1141
+ // returns immediately so the adapter's start() never blocks on READY.
1142
+ armReadyWatchdog(c);
1143
+ };
1144
+ /* ── READY watchdog (Phase 5) ── */
1145
+ let readyTimer;
1146
+ const cancelReadyWatchdog = () => {
1147
+ if (readyTimer) {
1148
+ clearTimeout(readyTimer);
1149
+ readyTimer = undefined;
1150
+ }
1151
+ };
1152
+ const armReadyWatchdog = (c) => {
1153
+ cancelReadyWatchdog();
1154
+ if (readyTimeoutMs <= 0 || ready || closed)
1155
+ return;
1156
+ readyTimer = setTimeout(() => {
1157
+ readyTimer = undefined;
1158
+ // READY arrived in the meantime / we tore down / the token died — nothing to do.
1159
+ if (ready || closed || tokenInvalid || client !== c)
1160
+ return;
1161
+ safeLog("discord gateway opened but did not reach READY within deadline — destroying + reconnecting", {
1162
+ account: accountId,
1163
+ deadlineMs: readyTimeoutMs,
1164
+ });
1165
+ connected = false;
1166
+ // Re-enter the supervise loop on a fresh tick (destroy + reconnect with backoff).
1167
+ void (async () => {
1168
+ await teardownClient();
1169
+ if (closed || tokenInvalid)
1170
+ return;
1171
+ reconnectAttempts += 1;
1172
+ const delay = discordBackoffDelay(reconnectAttempts);
1173
+ await sleep(delay);
1174
+ if (closed || tokenInvalid)
1175
+ return;
1176
+ loopPromise = superviseLoop().catch((err) => {
1177
+ safeLog("discord supervise loop crashed", { error: err instanceof Error ? err.message : String(err) });
1178
+ });
1179
+ })();
1180
+ }, readyTimeoutMs);
1181
+ if (typeof readyTimer.unref === "function")
1182
+ readyTimer.unref();
518
1183
  };
519
1184
  /**
520
1185
  * The supervise loop — login, and on a transient setup failure reconnect with
@@ -567,6 +1232,7 @@ export async function connectDiscord(args) {
567
1232
  const teardownClient = async () => {
568
1233
  const c = client;
569
1234
  client = null;
1235
+ ready = false;
570
1236
  if (c) {
571
1237
  try {
572
1238
  await c.destroy();
@@ -616,45 +1282,118 @@ export async function connectDiscord(args) {
616
1282
  const ch = await c.channels.fetch(targetId);
617
1283
  if (!ch)
618
1284
  throw new Error(`Discord: channel ${targetId} not found`);
619
- if (typeof ch.isTextBased === "function" && !ch.isTextBased()) {
1285
+ // A forum / media channel reports `isTextBased() === false` but IS a valid
1286
+ // send target — the post is created as a thread (Fix 2b). So we only reject
1287
+ // genuinely non-text channels (voice/category/…) that aren't forum-like.
1288
+ if (typeof ch.isTextBased === "function" && !ch.isTextBased() && !isForumLikeChannel(ch)) {
620
1289
  throw new Error(`Discord: channel ${targetId} is not text-based`);
621
1290
  }
622
1291
  return ch;
623
1292
  };
624
- const sendText = async (channel, text, opts) => {
625
- const ch = await resolveSendChannel(channel, opts?.threadId);
626
- // SAFE allowed-mentions on EVERY send: explicit user/role pings still notify,
627
- // but a stray `@everyone`/`@here` (agent text or prompt injection) can't
628
- // mass-ping, and a reply won't ping the author it answers.
629
- const options = { content: text, allowedMentions: safeDiscordAllowedMentions() };
630
- // Native reply target reply under the message being answered (only when not
631
- // threading, since a thread send is already scoped).
632
- if (opts?.replyToMessageId && !opts?.threadId) {
633
- options.reply = { messageReference: opts.replyToMessageId, failIfNotExists: false };
1293
+ /**
1294
+ * Post a message to a resolved channel, auto-creating a forum/media thread when
1295
+ * the target is a `GuildForum`/`GuildMedia` channel (Fix 2b) those reject a
1296
+ * plain `.send()`. The thread name is derived from the first non-empty content
1297
+ * line. Returns the created message's id. Used by every text-ish send path.
1298
+ */
1299
+ const postToChannel = async (ch, options) => {
1300
+ if (isForumLikeChannel(ch)) {
1301
+ if (typeof ch.threads?.create !== "function") {
1302
+ throw new Error("Discord: forum/media channel cannot create a thread (missing threads.create)");
1303
+ }
1304
+ const name = deriveForumThreadName(options.content ?? "");
1305
+ const message = {};
1306
+ if (options.content !== undefined)
1307
+ message.content = options.content;
1308
+ if (options.flags !== undefined)
1309
+ message.flags = options.flags;
1310
+ const created = await ch.threads.create({ name, message });
1311
+ const messageId = typeof created.lastMessage?.id === "string" ? created.lastMessage.id : typeof created.id === "string" ? created.id : "";
1312
+ return { messageId };
634
1313
  }
635
1314
  const sent = await ch.send(options);
636
1315
  return { messageId: typeof sent.id === "string" ? sent.id : "" };
637
1316
  };
1317
+ const sendText = async (channel, text, opts) => {
1318
+ try {
1319
+ const ch = await resolveSendChannel(channel, opts?.threadId);
1320
+ // SAFE allowed-mentions on EVERY send: explicit user/role pings still notify,
1321
+ // but a stray `@everyone`/`@here` (agent text or prompt injection) can't
1322
+ // mass-ping, and a reply won't ping the author it answers.
1323
+ const options = { content: text, allowedMentions: safeDiscordAllowedMentions() };
1324
+ // Silent send — suppress the recipient's notification (Fix 2c).
1325
+ if (opts?.silent)
1326
+ options.flags = MESSAGE_FLAG_SUPPRESS_NOTIFICATIONS;
1327
+ // Native reply target — reply under the message being answered (only when not
1328
+ // threading, since a thread send is already scoped).
1329
+ if (opts?.replyToMessageId && !opts?.threadId) {
1330
+ options.reply = { messageReference: opts.replyToMessageId, failIfNotExists: false };
1331
+ }
1332
+ return await postToChannel(ch, options);
1333
+ }
1334
+ catch (err) {
1335
+ throw decodeDiscordSendError(err);
1336
+ }
1337
+ };
638
1338
  const sendInteractive = async (channel, text, rows, opts) => {
639
- const ch = await resolveSendChannel(channel, opts?.threadId);
640
- const components = resolvedBuilders.buildComponentRows(rows);
641
- const options = { content: text, components, allowedMentions: safeDiscordAllowedMentions() };
642
- if (opts?.replyToMessageId && !opts?.threadId) {
643
- options.reply = { messageReference: opts.replyToMessageId, failIfNotExists: false };
1339
+ try {
1340
+ const ch = await resolveSendChannel(channel, opts?.threadId);
1341
+ // Components V2 (Fix 3c): when any row is a V2 container, the WHOLE message
1342
+ // is V2 the IsComponentsV2 flag is set and plain `content` is forbidden
1343
+ // (text must live inside a V2 text block). When no V2 row is present the
1344
+ // classic button/select path is byte-identical to before (plain content +
1345
+ // no flag).
1346
+ const hasV2 = rows.some((row) => isDiscordV2MessageSpec(row));
1347
+ let effectiveRows = rows;
1348
+ if (hasV2 && text && text.trim()) {
1349
+ // Prepend the message text as a leading TextDisplay block inside the FIRST
1350
+ // V2 container so the V2 message still shows the body (it can't use content).
1351
+ effectiveRows = rows.map((row) => {
1352
+ if (isDiscordV2MessageSpec(row)) {
1353
+ return { ...row, blocks: [{ type: "text", text }, ...row.blocks] };
1354
+ }
1355
+ return row;
1356
+ });
1357
+ }
1358
+ const components = resolvedBuilders.buildComponentRows(effectiveRows);
1359
+ const options = { components, allowedMentions: safeDiscordAllowedMentions() };
1360
+ if (hasV2) {
1361
+ // A V2 message carries no plain content; the flag tells Discord to render
1362
+ // the component tree. SuppressNotifications (silent) ORs in alongside it.
1363
+ options.flags = DISCORD_FLAG_IS_COMPONENTS_V2 | (opts?.silent ? MESSAGE_FLAG_SUPPRESS_NOTIFICATIONS : 0);
1364
+ }
1365
+ else {
1366
+ options.content = text;
1367
+ if (opts?.silent)
1368
+ options.flags = MESSAGE_FLAG_SUPPRESS_NOTIFICATIONS;
1369
+ }
1370
+ if (opts?.replyToMessageId && !opts?.threadId) {
1371
+ options.reply = { messageReference: opts.replyToMessageId, failIfNotExists: false };
1372
+ }
1373
+ const sent = await ch.send(options);
1374
+ return { messageId: typeof sent.id === "string" ? sent.id : "" };
1375
+ }
1376
+ catch (err) {
1377
+ throw decodeDiscordSendError(err);
644
1378
  }
645
- const sent = await ch.send(options);
646
- return { messageId: typeof sent.id === "string" ? sent.id : "" };
647
1379
  };
648
1380
  const sendMedia = async (channel, media, opts) => {
649
- const ch = await resolveSendChannel(channel, opts?.threadId);
650
- // validateOutboundMediaPath runs inside buildDiscordAttachment (throws on a
651
- // refused path).
652
- const att = buildDiscordAttachment(media);
653
- const file = resolvedBuilders.buildAttachment(att.path, att.name);
654
- const options = { files: [file], allowedMentions: safeDiscordAllowedMentions() };
655
- if (att.caption)
656
- options.content = att.caption;
657
- await ch.send(options);
1381
+ try {
1382
+ const ch = await resolveSendChannel(channel, opts?.threadId);
1383
+ // validateOutboundMediaPath runs inside buildDiscordAttachment (throws on a
1384
+ // refused path).
1385
+ const att = buildDiscordAttachment(media);
1386
+ const file = resolvedBuilders.buildAttachment(att.path, att.name);
1387
+ const options = { files: [file], allowedMentions: safeDiscordAllowedMentions() };
1388
+ if (opts?.silent)
1389
+ options.flags = MESSAGE_FLAG_SUPPRESS_NOTIFICATIONS;
1390
+ if (att.caption)
1391
+ options.content = att.caption;
1392
+ await ch.send(options);
1393
+ }
1394
+ catch (err) {
1395
+ throw decodeDiscordSendError(err);
1396
+ }
658
1397
  };
659
1398
  const fetchMessage = async (channel, messageId) => {
660
1399
  const ch = await resolveSendChannel(channel);
@@ -674,6 +1413,18 @@ export async function connectDiscord(args) {
674
1413
  const msg = await fetchMessage(channel, messageId);
675
1414
  await msg.delete();
676
1415
  };
1416
+ const pinMessage = async (channel, messageId) => {
1417
+ const msg = await fetchMessage(channel, messageId);
1418
+ if (typeof msg.pin !== "function")
1419
+ throw new Error("Discord: message cannot be pinned");
1420
+ await msg.pin();
1421
+ };
1422
+ const unpinMessage = async (channel, messageId) => {
1423
+ const msg = await fetchMessage(channel, messageId);
1424
+ if (typeof msg.unpin !== "function")
1425
+ throw new Error("Discord: message cannot be unpinned");
1426
+ await msg.unpin();
1427
+ };
677
1428
  const react = async (channel, messageId, emoji) => {
678
1429
  const name = emoji.trim();
679
1430
  if (!name)
@@ -748,9 +1499,65 @@ export async function connectDiscord(args) {
748
1499
  /* cosmetic — missing permission / not live: ignore */
749
1500
  }
750
1501
  };
1502
+ /**
1503
+ * Create a thread off an existing message (Phase 5 autoThread). Fetches the
1504
+ * channel + message, calls `message.startThread(...)`, and returns the new
1505
+ * thread id. On a create-race (the message already has a thread — Discord
1506
+ * rejects a second `startThread`) it returns the existing thread id. Returns
1507
+ * `null` on any failure so the caller falls back to an un-threaded reply.
1508
+ */
1509
+ const createThreadFromMessage = async (channelId, messageId, opts) => {
1510
+ try {
1511
+ const c = requireLive();
1512
+ const ch = await c.channels.fetch(channelId);
1513
+ if (!ch || typeof ch.messages?.fetch !== "function")
1514
+ return null;
1515
+ // Forum/Media/Voice channels reject `startThread` (they have no
1516
+ // message-thread surface). Skip SILENTLY (Fix 6) — the caller falls back
1517
+ // to an un-threaded reply, and we suppress the noisy known-unsupported error.
1518
+ const channelType = ch.type;
1519
+ if (typeof channelType === "number" && THREAD_UNSUPPORTED_CHANNEL_TYPES.has(channelType)) {
1520
+ return null;
1521
+ }
1522
+ const message = await ch.messages.fetch(messageId);
1523
+ if (!message)
1524
+ return null;
1525
+ // Already threaded → reuse (avoids the "already has a thread" reject).
1526
+ if (message.hasThread && message.thread?.id)
1527
+ return message.thread.id;
1528
+ if (typeof message.startThread !== "function")
1529
+ return null;
1530
+ const created = await message.startThread({
1531
+ name: opts.name,
1532
+ ...(typeof opts.autoArchiveMinutes === "number" ? { autoArchiveDuration: opts.autoArchiveMinutes } : {}),
1533
+ });
1534
+ const id = typeof created?.id === "string" ? created.id : "";
1535
+ return id || null;
1536
+ }
1537
+ catch (err) {
1538
+ safeLog("discord createThreadFromMessage failed", {
1539
+ channelId,
1540
+ error: err instanceof Error ? err.message : String(err),
1541
+ });
1542
+ // Create-race: another actor may have threaded the message already.
1543
+ try {
1544
+ const c = client;
1545
+ const ch = c ? await c.channels.fetch(channelId) : null;
1546
+ const message = ch && typeof ch.messages?.fetch === "function" ? await ch.messages.fetch(messageId) : null;
1547
+ if (message?.thread?.id)
1548
+ return message.thread.id;
1549
+ }
1550
+ catch {
1551
+ /* refetch also failed — give up */
1552
+ }
1553
+ return null;
1554
+ }
1555
+ };
751
1556
  const close = async () => {
752
1557
  closed = true;
753
1558
  connected = false;
1559
+ ready = false;
1560
+ cancelReadyWatchdog();
754
1561
  await teardownClient();
755
1562
  try {
756
1563
  await Promise.race([
@@ -767,7 +1574,10 @@ export async function connectDiscord(args) {
767
1574
  selfName: () => selfName,
768
1575
  connectedAt: () => connectedAtMs,
769
1576
  lastEventAt: () => lastEventAtMs,
770
- isConnected: () => connected,
1577
+ // `connected` is only set after startOnce resolves, which (when the watchdog
1578
+ // is enabled) requires READY — so connected already implies the Gateway is
1579
+ // fully up. The `|| ready` keeps the flag honest if the watchdog is disabled.
1580
+ isConnected: () => connected && (ready || readyTimeoutMs <= 0),
771
1581
  isTokenInvalid: () => tokenInvalid,
772
1582
  sendText,
773
1583
  sendInteractive,
@@ -776,9 +1586,13 @@ export async function connectDiscord(args) {
776
1586
  removeOwnReactions,
777
1587
  editMessageText,
778
1588
  deleteMessage,
1589
+ pinMessage,
1590
+ unpinMessage,
779
1591
  registerCommands,
780
1592
  setComposing,
781
1593
  markRead: async () => { },
1594
+ applyPresence,
1595
+ createThreadFromMessage,
782
1596
  close,
783
1597
  };
784
1598
  }