@spinabot/brigade 1.6.0 → 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 (194) hide show
  1. package/dist/agents/agent-loop.d.ts.map +1 -1
  2. package/dist/agents/agent-loop.js +51 -1
  3. package/dist/agents/agent-loop.js.map +1 -1
  4. package/dist/agents/channels/bundled-channel-metas.d.ts +2 -0
  5. package/dist/agents/channels/bundled-channel-metas.d.ts.map +1 -1
  6. package/dist/agents/channels/bundled-channel-metas.js +11 -0
  7. package/dist/agents/channels/bundled-channel-metas.js.map +1 -1
  8. package/dist/agents/channels/discord/account-config.d.ts +177 -0
  9. package/dist/agents/channels/discord/account-config.d.ts.map +1 -0
  10. package/dist/agents/channels/discord/account-config.js +349 -0
  11. package/dist/agents/channels/discord/account-config.js.map +1 -0
  12. package/dist/agents/channels/discord/adapter.d.ts +79 -0
  13. package/dist/agents/channels/discord/adapter.d.ts.map +1 -0
  14. package/dist/agents/channels/discord/adapter.js +693 -0
  15. package/dist/agents/channels/discord/adapter.js.map +1 -0
  16. package/dist/agents/channels/discord/approval-authorize.d.ts +43 -0
  17. package/dist/agents/channels/discord/approval-authorize.d.ts.map +1 -0
  18. package/dist/agents/channels/discord/approval-authorize.js +71 -0
  19. package/dist/agents/channels/discord/approval-authorize.js.map +1 -0
  20. package/dist/agents/channels/discord/approval-native.d.ts +68 -0
  21. package/dist/agents/channels/discord/approval-native.d.ts.map +1 -0
  22. package/dist/agents/channels/discord/approval-native.js +81 -0
  23. package/dist/agents/channels/discord/approval-native.js.map +1 -0
  24. package/dist/agents/channels/discord/command-menu.d.ts +49 -0
  25. package/dist/agents/channels/discord/command-menu.d.ts.map +1 -0
  26. package/dist/agents/channels/discord/command-menu.js +73 -0
  27. package/dist/agents/channels/discord/command-menu.js.map +1 -0
  28. package/dist/agents/channels/discord/component-blocks.d.ts +108 -0
  29. package/dist/agents/channels/discord/component-blocks.d.ts.map +1 -0
  30. package/dist/agents/channels/discord/component-blocks.js +113 -0
  31. package/dist/agents/channels/discord/component-blocks.js.map +1 -0
  32. package/dist/agents/channels/discord/components.d.ts +175 -0
  33. package/dist/agents/channels/discord/components.d.ts.map +1 -0
  34. package/dist/agents/channels/discord/components.js +220 -0
  35. package/dist/agents/channels/discord/components.js.map +1 -0
  36. package/dist/agents/channels/discord/connection.d.ts +570 -0
  37. package/dist/agents/channels/discord/connection.d.ts.map +1 -0
  38. package/dist/agents/channels/discord/connection.js +1600 -0
  39. package/dist/agents/channels/discord/connection.js.map +1 -0
  40. package/dist/agents/channels/discord/directory-cache.d.ts +47 -0
  41. package/dist/agents/channels/discord/directory-cache.d.ts.map +1 -0
  42. package/dist/agents/channels/discord/directory-cache.js +131 -0
  43. package/dist/agents/channels/discord/directory-cache.js.map +1 -0
  44. package/dist/agents/channels/discord/directory-live.d.ts +61 -0
  45. package/dist/agents/channels/discord/directory-live.d.ts.map +1 -0
  46. package/dist/agents/channels/discord/directory-live.js +140 -0
  47. package/dist/agents/channels/discord/directory-live.js.map +1 -0
  48. package/dist/agents/channels/discord/draft-stream.d.ts +92 -0
  49. package/dist/agents/channels/discord/draft-stream.d.ts.map +1 -0
  50. package/dist/agents/channels/discord/draft-stream.js +213 -0
  51. package/dist/agents/channels/discord/draft-stream.js.map +1 -0
  52. package/dist/agents/channels/discord/format.d.ts +70 -0
  53. package/dist/agents/channels/discord/format.d.ts.map +1 -0
  54. package/dist/agents/channels/discord/format.js +303 -0
  55. package/dist/agents/channels/discord/format.js.map +1 -0
  56. package/dist/agents/channels/discord/guilds.d.ts +25 -0
  57. package/dist/agents/channels/discord/guilds.d.ts.map +1 -0
  58. package/dist/agents/channels/discord/guilds.js +46 -0
  59. package/dist/agents/channels/discord/guilds.js.map +1 -0
  60. package/dist/agents/channels/discord/inbound-extras.d.ts +377 -0
  61. package/dist/agents/channels/discord/inbound-extras.d.ts.map +1 -0
  62. package/dist/agents/channels/discord/inbound-extras.js +589 -0
  63. package/dist/agents/channels/discord/inbound-extras.js.map +1 -0
  64. package/dist/agents/channels/discord/index.d.ts +21 -0
  65. package/dist/agents/channels/discord/index.d.ts.map +1 -0
  66. package/dist/agents/channels/discord/index.js +21 -0
  67. package/dist/agents/channels/discord/index.js.map +1 -0
  68. package/dist/agents/channels/discord/media.d.ts +85 -0
  69. package/dist/agents/channels/discord/media.d.ts.map +1 -0
  70. package/dist/agents/channels/discord/media.js +242 -0
  71. package/dist/agents/channels/discord/media.js.map +1 -0
  72. package/dist/agents/channels/discord/modal-registry.d.ts +89 -0
  73. package/dist/agents/channels/discord/modal-registry.d.ts.map +1 -0
  74. package/dist/agents/channels/discord/modal-registry.js +104 -0
  75. package/dist/agents/channels/discord/modal-registry.js.map +1 -0
  76. package/dist/agents/channels/discord/modals.d.ts +100 -0
  77. package/dist/agents/channels/discord/modals.d.ts.map +1 -0
  78. package/dist/agents/channels/discord/modals.js +124 -0
  79. package/dist/agents/channels/discord/modals.js.map +1 -0
  80. package/dist/agents/channels/discord/module.d.ts +15 -0
  81. package/dist/agents/channels/discord/module.d.ts.map +1 -0
  82. package/dist/agents/channels/discord/module.js +22 -0
  83. package/dist/agents/channels/discord/module.js.map +1 -0
  84. package/dist/agents/channels/discord/permission-audit.d.ts +43 -0
  85. package/dist/agents/channels/discord/permission-audit.d.ts.map +1 -0
  86. package/dist/agents/channels/discord/permission-audit.js +192 -0
  87. package/dist/agents/channels/discord/permission-audit.js.map +1 -0
  88. package/dist/agents/channels/discord/plugin.d.ts +89 -0
  89. package/dist/agents/channels/discord/plugin.d.ts.map +1 -0
  90. package/dist/agents/channels/discord/plugin.js +372 -0
  91. package/dist/agents/channels/discord/plugin.js.map +1 -0
  92. package/dist/agents/channels/discord/probe.d.ts +115 -0
  93. package/dist/agents/channels/discord/probe.d.ts.map +1 -0
  94. package/dist/agents/channels/discord/probe.js +193 -0
  95. package/dist/agents/channels/discord/probe.js.map +1 -0
  96. package/dist/agents/channels/discord/reasoning-lane.d.ts +42 -0
  97. package/dist/agents/channels/discord/reasoning-lane.d.ts.map +1 -0
  98. package/dist/agents/channels/discord/reasoning-lane.js +68 -0
  99. package/dist/agents/channels/discord/reasoning-lane.js.map +1 -0
  100. package/dist/agents/channels/discord/rest-actions.d.ts +346 -0
  101. package/dist/agents/channels/discord/rest-actions.d.ts.map +1 -0
  102. package/dist/agents/channels/discord/rest-actions.js +559 -0
  103. package/dist/agents/channels/discord/rest-actions.js.map +1 -0
  104. package/dist/agents/channels/discord/rest-components.d.ts +122 -0
  105. package/dist/agents/channels/discord/rest-components.d.ts.map +1 -0
  106. package/dist/agents/channels/discord/rest-components.js +243 -0
  107. package/dist/agents/channels/discord/rest-components.js.map +1 -0
  108. package/dist/agents/channels/discord/security-audit.d.ts +29 -0
  109. package/dist/agents/channels/discord/security-audit.d.ts.map +1 -0
  110. package/dist/agents/channels/discord/security-audit.js +94 -0
  111. package/dist/agents/channels/discord/security-audit.js.map +1 -0
  112. package/dist/agents/channels/discord/security-doctor.d.ts +43 -0
  113. package/dist/agents/channels/discord/security-doctor.d.ts.map +1 -0
  114. package/dist/agents/channels/discord/security-doctor.js +83 -0
  115. package/dist/agents/channels/discord/security-doctor.js.map +1 -0
  116. package/dist/agents/channels/discord/status-issues.d.ts +37 -0
  117. package/dist/agents/channels/discord/status-issues.d.ts.map +1 -0
  118. package/dist/agents/channels/discord/status-issues.js +66 -0
  119. package/dist/agents/channels/discord/status-issues.js.map +1 -0
  120. package/dist/agents/channels/discord/subagent-thread-binding-store.d.ts +57 -0
  121. package/dist/agents/channels/discord/subagent-thread-binding-store.d.ts.map +1 -0
  122. package/dist/agents/channels/discord/subagent-thread-binding-store.js +98 -0
  123. package/dist/agents/channels/discord/subagent-thread-binding-store.js.map +1 -0
  124. package/dist/agents/channels/discord/subagent-thread-binding.d.ts +95 -0
  125. package/dist/agents/channels/discord/subagent-thread-binding.d.ts.map +1 -0
  126. package/dist/agents/channels/discord/subagent-thread-binding.js +208 -0
  127. package/dist/agents/channels/discord/subagent-thread-binding.js.map +1 -0
  128. package/dist/agents/channels/discord/system-events.d.ts +31 -0
  129. package/dist/agents/channels/discord/system-events.d.ts.map +1 -0
  130. package/dist/agents/channels/discord/system-events.js +74 -0
  131. package/dist/agents/channels/discord/system-events.js.map +1 -0
  132. package/dist/agents/channels/general-callback.d.ts +12 -0
  133. package/dist/agents/channels/general-callback.d.ts.map +1 -1
  134. package/dist/agents/channels/general-callback.js +18 -0
  135. package/dist/agents/channels/general-callback.js.map +1 -1
  136. package/dist/agents/channels/inbound-pipeline.d.ts.map +1 -1
  137. package/dist/agents/channels/inbound-pipeline.js +70 -10
  138. package/dist/agents/channels/inbound-pipeline.js.map +1 -1
  139. package/dist/agents/channels/sdk.d.ts +2 -0
  140. package/dist/agents/channels/sdk.d.ts.map +1 -1
  141. package/dist/agents/channels/sdk.js +2 -0
  142. package/dist/agents/channels/sdk.js.map +1 -1
  143. package/dist/agents/extensions/modules/index.d.ts.map +1 -1
  144. package/dist/agents/extensions/modules/index.js +5 -0
  145. package/dist/agents/extensions/modules/index.js.map +1 -1
  146. package/dist/agents/extensions/types.d.ts +7 -0
  147. package/dist/agents/extensions/types.d.ts.map +1 -1
  148. package/dist/agents/extensions/types.js.map +1 -1
  149. package/dist/agents/subagent-announce-delivery.d.ts +10 -0
  150. package/dist/agents/subagent-announce-delivery.d.ts.map +1 -1
  151. package/dist/agents/subagent-announce-delivery.js +1 -0
  152. package/dist/agents/subagent-announce-delivery.js.map +1 -1
  153. package/dist/agents/subagent-completion-bridge.d.ts.map +1 -1
  154. package/dist/agents/subagent-completion-bridge.js +81 -0
  155. package/dist/agents/subagent-completion-bridge.js.map +1 -1
  156. package/dist/agents/subagent-spawn.d.ts.map +1 -1
  157. package/dist/agents/subagent-spawn.js +57 -4
  158. package/dist/agents/subagent-spawn.js.map +1 -1
  159. package/dist/agents/tools/cron-tool.d.ts.map +1 -1
  160. package/dist/agents/tools/cron-tool.js +4 -1
  161. package/dist/agents/tools/cron-tool.js.map +1 -1
  162. package/dist/agents/tools/discord-action-tool.d.ts +224 -0
  163. package/dist/agents/tools/discord-action-tool.d.ts.map +1 -0
  164. package/dist/agents/tools/discord-action-tool.js +848 -0
  165. package/dist/agents/tools/discord-action-tool.js.map +1 -0
  166. package/dist/agents/tools/registry.d.ts.map +1 -1
  167. package/dist/agents/tools/registry.js +21 -0
  168. package/dist/agents/tools/registry.js.map +1 -1
  169. package/dist/agents/tools/sessions/index.d.ts +8 -0
  170. package/dist/agents/tools/sessions/index.d.ts.map +1 -1
  171. package/dist/agents/tools/sessions/index.js +15 -3
  172. package/dist/agents/tools/sessions/index.js.map +1 -1
  173. package/dist/buildstamp.json +1 -1
  174. package/dist/cli/commands/channels.d.ts +2 -0
  175. package/dist/cli/commands/channels.d.ts.map +1 -1
  176. package/dist/cli/commands/channels.js +58 -1
  177. package/dist/cli/commands/channels.js.map +1 -1
  178. package/dist/core/auth-bridge.d.ts +1 -0
  179. package/dist/core/auth-bridge.d.ts.map +1 -1
  180. package/dist/core/auth-bridge.js +46 -1
  181. package/dist/core/auth-bridge.js.map +1 -1
  182. package/dist/core/server.d.ts.map +1 -1
  183. package/dist/core/server.js +18 -2
  184. package/dist/core/server.js.map +1 -1
  185. package/dist/cron/isolated-agent/run-executor.d.ts +11 -0
  186. package/dist/cron/isolated-agent/run-executor.d.ts.map +1 -1
  187. package/dist/cron/isolated-agent/run-executor.js +20 -4
  188. package/dist/cron/isolated-agent/run-executor.js.map +1 -1
  189. package/dist/cron/types.d.ts +8 -0
  190. package/dist/cron/types.d.ts.map +1 -1
  191. package/dist/system-prompt/assembler.d.ts.map +1 -1
  192. package/dist/system-prompt/assembler.js +4 -2
  193. package/dist/system-prompt/assembler.js.map +1 -1
  194. package/package.json +2 -1
@@ -0,0 +1,1600 @@
1
+ /**
2
+ * Discord connection (Gateway WebSocket inbound + REST outbound).
3
+ *
4
+ * The Brigade analogue of `slack/connection.ts`, on top of discord.js v14. A
5
+ * single `Client` owns BOTH halves: the Gateway websocket delivers INBOUND
6
+ * events (no public URL needed — the local-first default, analogous to Slack
7
+ * Socket Mode / Telegram long-polling), and the same Client's REST drives every
8
+ * OUTBOUND call (send / edit / delete / react / upload / typing). `discord.js`
9
+ * is lazy-imported here (`await import("discord.js")` inside `connectDiscord`)
10
+ * so a non-Discord boot never pays for it; types are `type`-only on the static
11
+ * import so the static import never pulls the runtime in.
12
+ *
13
+ * Lifecycle:
14
+ * - `client.login(botToken)` BOOTSTRAPS the connection. An invalid token
15
+ * rejects login → TERMINAL (the only fix is a fresh token, mirroring Slack's
16
+ * `tokenInvalid`). The bot's own user id is cached on `ClientReady` (the
17
+ * group ACL needs it to detect `<@bot>` mentions + to filter the bot's own
18
+ * echoes; without it group messages never reach the agent and the bot could
19
+ * reply to itself).
20
+ * - Event handlers subscribe messageCreate / messageUpdate (edit) /
21
+ * messageDelete / messageReactionAdd / messageReactionRemove /
22
+ * interactionCreate (button presses = approval + general callbacks, and
23
+ * slash commands). Each normalizes the payload into a `DiscordInboundMessage`
24
+ * and routes it via `onMessage` / `onCallbackQuery` / `onReaction`.
25
+ * Attachment bytes are downloaded via a DEFERRED `resolveMedia` thunk — only
26
+ * after the central access gate admits the sender (mirrors Slack/Telegram).
27
+ * - discord.js auto-reconnects the Gateway internally; we SUPERVISE the initial
28
+ * `.login()` with the SAME backoff curve as Slack (2s → 30s, ×1.8, ±25%) and
29
+ * go terminal on an auth error.
30
+ * - Events are de-duplicated by message id (a redelivered event after a
31
+ * reconnect must not double-run the agent).
32
+ */
33
+ import { createDedupeCache, nextBackoffDelay, } from "../sdk.js";
34
+ import { maskProxyUrl } from "./account-config.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";
41
+ import { buildDiscordAttachment, downloadDiscordAttachment } from "./media.js";
42
+ import { isDiscordUserMessageType, resolveDiscordSystemEvent } from "./system-events.js";
43
+ import { forgetDiscordSubagentThreadBindingByThreadId } from "./subagent-thread-binding-store.js";
44
+ /* ───────────────────────── reconnect backoff ───────────────────────── */
45
+ // Shares the neutral `nextBackoffDelay` curve with every other channel (see
46
+ // `channels/backoff.ts`), tuned to the SAME schedule WhatsApp + Telegram + Slack
47
+ // use (2s → 30s, ×1.8, ±25%). The constants live here so Discord owns its own
48
+ // knobs; the arithmetic is the shared helper's.
49
+ const RECONNECT_INITIAL_MS = 2_000;
50
+ const RECONNECT_MAX_MS = 30_000;
51
+ const RECONNECT_FACTOR = 1.8;
52
+ const RECONNECT_JITTER = 0.25;
53
+ const RECONNECT_MAX_ATTEMPTS = 12;
54
+ /**
55
+ * Jittered exponential backoff for reconnect attempt `attempt` (0-based). Thin
56
+ * wrapper over the neutral `nextBackoffDelay` helper — kept as a named export so
57
+ * `index.ts` + the connection tests have a stable entry point.
58
+ */
59
+ export function discordBackoffDelay(attempt) {
60
+ return nextBackoffDelay({
61
+ attempt,
62
+ initialMs: RECONNECT_INITIAL_MS,
63
+ maxMs: RECONNECT_MAX_MS,
64
+ factor: RECONNECT_FACTOR,
65
+ jitter: RECONNECT_JITTER,
66
+ });
67
+ }
68
+ /** Discord's hard per-message content limit (chars). Sends chunk under this. */
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
+ }
112
+ /**
113
+ * The SAFE default `allowedMentions` applied to every outbound Discord send.
114
+ * `parse: ["users", "roles"]` lets explicit `<@id>` / `<@&roleid>` mentions ping
115
+ * as intended, while the absence of `"everyone"` means an `@everyone` / `@here`
116
+ * that slipped into the content (agent text or a prompt injection) renders as
117
+ * text and notifies no one. `repliedUser: false` keeps a native reply from
118
+ * pinging the author it answers. A fresh object is returned per call so a send
119
+ * can't mutate the shared default.
120
+ */
121
+ export function safeDiscordAllowedMentions() {
122
+ return { parse: ["users", "roles"], repliedUser: false };
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
+ }
146
+ /* ───────────────────────── error classification ───────────────────────── */
147
+ /** Pull a message string off any thrown shape. */
148
+ function errorText(err) {
149
+ if (!err)
150
+ return "";
151
+ if (typeof err === "string")
152
+ return err;
153
+ const e = err;
154
+ return e.message ?? String(err);
155
+ }
156
+ /**
157
+ * A Discord auth failure → the token is wrong / revoked / reset. Re-tokening is
158
+ * the only fix; reconnecting with the same token loops forever. discord.js
159
+ * surfaces a bad token on login as a `TokenInvalid` error / a message containing
160
+ * "invalid token" / an "disallowed intents" privileged-intents rejection (which
161
+ * is also terminal until the operator fixes the bot's intent settings).
162
+ */
163
+ export function isDiscordUnauthorized(err) {
164
+ const name = err?.name ?? "";
165
+ if (/TokenInvalid|DisallowedIntents/i.test(name))
166
+ return true;
167
+ const code = err?.code;
168
+ if (code === "TokenInvalid" || code === "DisallowedIntents")
169
+ return true;
170
+ return /invalid token|incorrect login|disallowed intents|used disallowed intents/i.test(errorText(err));
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
+ }
206
+ /** Strip a Discord token out of a string before it logs. */
207
+ export function redactDiscordToken(text, ...tokens) {
208
+ if (!text)
209
+ return text;
210
+ let out = text;
211
+ for (const token of tokens) {
212
+ if (token)
213
+ out = out.split(token).join("<redacted>");
214
+ }
215
+ // Discord bot tokens look like `<base64 id>.<base64 ts>.<secret>`; mask a
216
+ // plausible token fragment even if the exact token differs.
217
+ out = out.replace(/[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{20,}/g, "<redacted>");
218
+ return out;
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
+ }
364
+ /* ───────────────────────── the connection ───────────────────────── */
365
+ export async function connectDiscord(args) {
366
+ const accountId = args.accountId ?? "default";
367
+ const sleep = args.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms).unref?.()));
368
+ const safeLog = (msg, meta) => {
369
+ const redactedMsg = redactDiscordToken(msg, args.botToken);
370
+ if (!meta)
371
+ return args.log(redactedMsg);
372
+ const redactedMeta = {};
373
+ for (const [k, v] of Object.entries(meta)) {
374
+ redactedMeta[k] = typeof v === "string" ? redactDiscordToken(v, args.botToken) : v;
375
+ }
376
+ args.log(redactedMsg, redactedMeta);
377
+ };
378
+ const proxyUrl = (args.proxyUrl ?? "").trim();
379
+ // ── lazy-load discord.js (production path only) ──
380
+ // `builders` resolves in this precedence: an injected `buildersFactory` wins
381
+ // (tests / a custom build); else the production path builds real discord.js
382
+ // builders; else (a `clientFactory` with no builders supplied) a pass-through
383
+ // fake that emits plain JSON. So a test injecting just a `clientFactory` gets
384
+ // the pass-through, and a real boot gets real discord.js builders.
385
+ let buildClient;
386
+ let builders = args.buildersFactory ? args.buildersFactory() : undefined;
387
+ if (args.clientFactory) {
388
+ const factory = args.clientFactory;
389
+ buildClient = (botToken) => factory(botToken, proxyUrl || undefined);
390
+ builders ??= {
391
+ buildAttachment: (p, name) => ({ attachment: p, name }),
392
+ buildComponentRows: (rows) => rows.map((row) => ({ components: row })),
393
+ buildModal: (params) => ({ modal: params }),
394
+ };
395
+ }
396
+ else {
397
+ const discord = await import("discord.js");
398
+ const { Client, GatewayIntentBits, Partials } = discord;
399
+ // Optional proxy → a custom REST `makeRequest` via undici's ProxyAgent. A
400
+ // missing/malformed proxy must not wedge the channel (logged, ignored).
401
+ let rest;
402
+ if (proxyUrl) {
403
+ try {
404
+ const { ProxyAgent, fetch: undiciFetch } = await import("undici");
405
+ const dispatcher = new ProxyAgent(proxyUrl);
406
+ rest = {
407
+ makeRequest: ((url, init) => undiciFetch(url, { ...init, dispatcher })),
408
+ };
409
+ safeLog("discord routing through proxy", { account: accountId, proxy: maskProxyUrl(proxyUrl) });
410
+ }
411
+ catch (err) {
412
+ safeLog("discord proxy setup failed — connecting directly", {
413
+ account: accountId,
414
+ proxy: maskProxyUrl(proxyUrl),
415
+ error: err instanceof Error ? err.message : String(err),
416
+ });
417
+ }
418
+ }
419
+ buildClient = (_botToken) => new Client({
420
+ intents: [
421
+ GatewayIntentBits.Guilds,
422
+ GatewayIntentBits.GuildMessages,
423
+ GatewayIntentBits.MessageContent,
424
+ GatewayIntentBits.GuildMessageReactions,
425
+ GatewayIntentBits.DirectMessages,
426
+ GatewayIntentBits.DirectMessageReactions,
427
+ ],
428
+ // Partials so DM channels + uncached reactions/messages still fire events.
429
+ partials: [Partials.Channel, Partials.Message, Partials.Reaction],
430
+ ...(rest ? { rest: rest } : {}),
431
+ });
432
+ builders ??= {
433
+ buildAttachment(p, name) {
434
+ return new discord.AttachmentBuilder(p, { name });
435
+ },
436
+ buildComponentRows(rows) {
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).
447
+ const r = new discord.ActionRowBuilder();
448
+ for (const b of row) {
449
+ r.addComponents(new discord.ButtonBuilder().setCustomId(b.customId).setLabel(b.label).setStyle(b.style));
450
+ }
451
+ return r;
452
+ });
453
+ },
454
+ buildModal(params) {
455
+ return buildDiscordModal({
456
+ ModalBuilder: discord.ModalBuilder,
457
+ ActionRowBuilder: discord.ActionRowBuilder,
458
+ TextInputBuilder: discord.TextInputBuilder,
459
+ }, params);
460
+ },
461
+ };
462
+ }
463
+ // `builders` is always assigned by here (every branch sets it); the non-null
464
+ // assertion documents that for the closures below.
465
+ const resolvedBuilders = builders;
466
+ // ── connection state ──
467
+ let selfId = null;
468
+ let selfName = null;
469
+ let connectedAtMs = null;
470
+ let lastEventAtMs = null;
471
+ const stampInboundEvent = () => {
472
+ lastEventAtMs = Date.now();
473
+ };
474
+ let connected = false;
475
+ let tokenInvalid = false;
476
+ let closed = false;
477
+ let reconnectAttempts = 0;
478
+ let client = null;
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;
506
+ // Dedupe inbound events by id — a redelivered event after a reconnect must not
507
+ // double-run the agent. Per-connection lifetime.
508
+ const eventDedupe = createDedupeCache({ maxEntries: 10_000, ttlMs: 60 * 60 * 1_000 });
509
+ // Last inbound message id per channel — the target setComposing targets (Discord
510
+ // shows typing per-channel, so we just need the channel; the map is kept for
511
+ // parity / future per-message affordances).
512
+ const lastInboundChannel = new Set();
513
+ /** Token-resolver lookups primed from the cached client (best-effort, sync). */
514
+ const resolveLookups = (message) => {
515
+ // On a live message discord.js resolves mention display via the resolved
516
+ // collections; expandDiscordTokens falls back to the bare id when a name
517
+ // isn't cached. We pass resolvers that read the message's own resolved
518
+ // mention caches when present.
519
+ const users = new Map();
520
+ const mentionUsers = message.mentions?.users;
521
+ if (mentionUsers) {
522
+ const iter = mentionUsers instanceof Map ? mentionUsers.values() : mentionUsers;
523
+ for (const u of iter) {
524
+ if (typeof u?.id === "string")
525
+ users.set(u.id, (u.globalName || u.username || u.id));
526
+ }
527
+ }
528
+ return { user: (id) => users.get(id) };
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
+ };
564
+ /** Normalize a discord.js message into the deferred-media inbound shape. */
565
+ const normalize = (message, opts) => {
566
+ const channelId = typeof message.channelId === "string" ? message.channelId : typeof message.channel?.id === "string" ? message.channel.id : "";
567
+ if (!channelId)
568
+ return null;
569
+ primeDirectoryFromMessage(message);
570
+ const resolve = resolveLookups(message);
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);
575
+ const chatType = discordChannelType(message);
576
+ const threadId = discordThreadId(message);
577
+ const mentions = extractDiscordMentions(message, selfId ?? undefined);
578
+ const replyTo = extractDiscordReplyContext(message);
579
+ const fromName = buildDiscordSenderName(message);
580
+ const fromId = typeof message.author?.id === "string" ? message.author.id : channelId;
581
+ // Discord routes on guildId + member role ids (NOT teamId — that's Slack's
582
+ // workspace tier). A DM carries no guildId and no roles.
583
+ const guildId = typeof message.guildId === "string" ? message.guildId : undefined;
584
+ const memberRoleIds = guildId ? extractDiscordMemberRoleIds(message) : [];
585
+ const messageId = typeof message.id === "string" ? message.id : undefined;
586
+ const timestampMs = typeof message.createdTimestamp === "number" ? message.createdTimestamp : undefined;
587
+ // DEFERRED media — captured by reference, not downloaded. The thunk is only
588
+ // invoked by the pipeline after the access gate admits the sender.
589
+ const carriesMedia = hasInboundMedia(message);
590
+ const resolveMedia = carriesMedia
591
+ ? async () => {
592
+ const atts = resolveInboundAttachments(message);
593
+ if (atts.length === 0)
594
+ return [];
595
+ const out = [];
596
+ for (const att of atts) {
597
+ const dl = await downloadDiscordAttachment({ attachment: att, log: safeLog });
598
+ if (dl)
599
+ out.push(dl);
600
+ }
601
+ return out;
602
+ }
603
+ : undefined;
604
+ return {
605
+ conversationId: channelId,
606
+ ...(messageId ? { messageId } : {}),
607
+ ...(timestampMs !== undefined ? { messageTimestampMs: timestampMs } : {}),
608
+ from: fromId,
609
+ ...(fromName ? { fromName } : {}),
610
+ text,
611
+ chatType,
612
+ ...(guildId ? { guildId } : {}),
613
+ ...(memberRoleIds.length > 0 ? { memberRoleIds } : {}),
614
+ ...(threadId ? { threadId } : {}),
615
+ ...(mentions.length > 0 ? { mentions } : {}),
616
+ ...(replyTo ? { replyTo } : {}),
617
+ ...(opts?.edited ? { edited: true } : {}),
618
+ ...(resolveMedia ? { resolveMedia } : {}),
619
+ raw: message,
620
+ };
621
+ };
622
+ /** Is this message one the bot itself authored (its own echo)? */
623
+ const isSelfAuthored = (message) => {
624
+ if (selfId && message.author?.id === selfId)
625
+ return true;
626
+ // A webhook / bot author with no resolvable user shouldn't loop us either,
627
+ // but only OUR own id is a definite echo; other bots are allowed through.
628
+ return false;
629
+ };
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) => {
679
+ try {
680
+ stampInboundEvent();
681
+ // Skip the bot's own messages (echoes) — a bot must never reply to itself.
682
+ if (isSelfAuthored(message))
683
+ return;
684
+ // Skip a message authored by ANY bot/webhook to avoid bot-loops (parity with
685
+ // the conservative default; a human-only channel is the norm for Brigade).
686
+ if (message.author?.bot === true)
687
+ return;
688
+ const id = typeof message.id === "string" ? message.id : "";
689
+ if (!id)
690
+ return;
691
+ // Edits fold the edit timestamp into the key so a second edit still routes.
692
+ const editStamp = opts?.edited && typeof message.editedTimestamp === "number" ? message.editedTimestamp : "";
693
+ const key = opts?.edited ? `edit:${id}:${editStamp}` : id;
694
+ if (!eventDedupe.claim(key))
695
+ return; // already seen
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);
714
+ if (!normalized)
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);
751
+ args.onMessage(normalized);
752
+ lastInboundChannel.add(normalized.conversationId);
753
+ }
754
+ catch (err) {
755
+ safeLog("discord inbound handler error", { error: err instanceof Error ? err.message : String(err) });
756
+ }
757
+ };
758
+ /**
759
+ * Normalize a reaction-add into the inbound shape. Surfaces the single added
760
+ * emoji, the actor, and the target message id. Reactions carry no text.
761
+ */
762
+ const normalizeReaction = (reaction, user) => {
763
+ const msg = reaction.message;
764
+ const channel = typeof msg?.channelId === "string" ? msg.channelId : typeof msg?.channel?.id === "string" ? msg.channel.id : "";
765
+ const target = typeof msg?.id === "string" ? msg.id : "";
766
+ // A custom emoji surfaces as `name:id`; a unicode emoji as its char.
767
+ const emojiName = reaction.emoji?.name ?? "";
768
+ const emoji = reaction.emoji?.id ? `${emojiName}:${reaction.emoji.id}` : emojiName;
769
+ if (!channel || !target || !emoji)
770
+ return null;
771
+ const fromId = typeof user?.id === "string" ? user.id : channel;
772
+ if (selfId && fromId === selfId)
773
+ return null; // the bot's own reaction
774
+ if (user?.bot === true)
775
+ return null; // ignore other bots' reactions
776
+ const fromName = user?.username;
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;
780
+ return {
781
+ conversationId: channel,
782
+ from: fromId,
783
+ ...(fromName ? { fromName } : {}),
784
+ text: "",
785
+ chatType: msg?.guildId ? "group" : "direct",
786
+ ...(guildId ? { guildId } : {}),
787
+ reaction: { emojis: [emoji], targetMessageId: target, ...(targetAuthorId ? { targetAuthorId } : {}) },
788
+ raw: { reaction, user },
789
+ };
790
+ };
791
+ /** Handle a messageReactionAdd event. */
792
+ const handleReactionAdd = (reaction, user) => {
793
+ try {
794
+ stampInboundEvent();
795
+ const key = `react:${user?.id}:${reaction.emoji?.name ?? reaction.emoji?.id}:${reaction.message?.id}`;
796
+ if (!eventDedupe.claim(key))
797
+ return;
798
+ const normalized = normalizeReaction(reaction, user);
799
+ if (!normalized)
800
+ return;
801
+ args.onReaction?.(normalized);
802
+ }
803
+ catch (err) {
804
+ safeLog("discord reaction handler error", { error: err instanceof Error ? err.message : String(err) });
805
+ }
806
+ };
807
+ /** Handle a messageReactionRemove event — release the add-dedupe key so a re-add re-routes. */
808
+ const handleReactionRemove = (reaction, user) => {
809
+ stampInboundEvent(); // liveness: a removal is still inbound traffic
810
+ eventDedupe.release(`react:${user?.id}:${reaction.emoji?.name ?? reaction.emoji?.id}:${reaction.message?.id}`);
811
+ };
812
+ /** Normalize a button-press interaction into the approval-callback inbound shape. */
813
+ const normalizeButton = (interaction) => {
814
+ const value = typeof interaction.customId === "string" ? interaction.customId : "";
815
+ if (!value)
816
+ return null;
817
+ const channel = typeof interaction.channelId === "string" ? interaction.channelId : typeof interaction.channel?.id === "string" ? interaction.channel.id : "";
818
+ const fromId = typeof interaction.user?.id === "string" ? interaction.user.id : channel;
819
+ if (!channel && !fromId)
820
+ return null;
821
+ const threadId = interaction.channel && isThreadChannel(interaction.channel) ? channel : undefined;
822
+ const fromName = interaction.user?.username;
823
+ return {
824
+ conversationId: channel || fromId,
825
+ from: fromId,
826
+ ...(fromName ? { fromName } : {}),
827
+ text: "",
828
+ chatType: interaction.guildId ? "group" : "direct",
829
+ ...(typeof interaction.guildId === "string" ? { guildId: interaction.guildId } : {}),
830
+ ...(threadId ? { threadId } : {}),
831
+ callbackQuery: { data: value, callbackId: interaction.id ?? "" },
832
+ raw: interaction,
833
+ };
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
+ };
905
+ /**
906
+ * Normalize a slash-command interaction into an ordinary inbound message so the
907
+ * central command map (`/help`, `/status`, …) handles it. The command name is
908
+ * mapped to `/command` text.
909
+ */
910
+ const normalizeSlash = (interaction) => {
911
+ const command = typeof interaction.commandName === "string" ? interaction.commandName : "";
912
+ const channel = typeof interaction.channelId === "string" ? interaction.channelId : "";
913
+ const fromId = typeof interaction.user?.id === "string" ? interaction.user.id : channel;
914
+ if (!command || !channel)
915
+ return null;
916
+ const fromName = interaction.user?.username;
917
+ return {
918
+ conversationId: channel,
919
+ from: fromId,
920
+ ...(fromName ? { fromName } : {}),
921
+ text: `/${command}`,
922
+ chatType: interaction.guildId ? "group" : "direct",
923
+ ...(typeof interaction.guildId === "string" ? { guildId: interaction.guildId } : {}),
924
+ raw: interaction,
925
+ };
926
+ };
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). */
945
+ const handleInteraction = (interaction) => {
946
+ try {
947
+ stampInboundEvent();
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
+ }
956
+ // Ack the press silently first so Discord doesn't show "interaction
957
+ // failed"; then route the normalized inbound.
958
+ void interaction.deferUpdate?.().catch(() => { });
959
+ const normalized = normalizeButton(interaction);
960
+ if (normalized)
961
+ args.onCallbackQuery?.(normalized);
962
+ return;
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
+ }
982
+ if (typeof interaction.isChatInputCommand === "function" && interaction.isChatInputCommand()) {
983
+ // Ack the command ephemerally so the client spinner clears; the real
984
+ // reply is delivered by the pipeline as a normal channel message.
985
+ void interaction.reply?.({ content: "On it.", ephemeral: true }).catch(() => { });
986
+ const normalized = normalizeSlash(interaction);
987
+ if (normalized)
988
+ args.onMessage(normalized);
989
+ return;
990
+ }
991
+ }
992
+ catch (err) {
993
+ safeLog("discord interaction handler error", { error: err instanceof Error ? err.message : String(err) });
994
+ }
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
+ };
1047
+ /* ── event wiring ── */
1048
+ const wireClient = (c) => {
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)));
1052
+ c.on("messageUpdate", ((_old, updated) => {
1053
+ // messageUpdate fires for non-content edits too (embeds resolving, pins);
1054
+ // only route when there's content to act on.
1055
+ if (updated && typeof updated === "object")
1056
+ void handleMessage(updated, { edited: true });
1057
+ }));
1058
+ c.on("messageDelete", (() => {
1059
+ // A deleted message carries no routable content — just stamp liveness.
1060
+ stampInboundEvent();
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
+ }));
1074
+ c.on("messageReactionAdd", ((reaction, user) => handleReactionAdd(reaction, user)));
1075
+ c.on("messageReactionRemove", ((reaction, user) => handleReactionRemove(reaction, user)));
1076
+ c.on("interactionCreate", ((interaction) => handleInteraction(interaction)));
1077
+ // A privileged-intents (4014) or auth-failed (4004) Gateway CLOSE is terminal:
1078
+ // discord.js would otherwise loop reconnect attempts forever while health
1079
+ // stayed "connected". Flip the token-invalid flag so health goes "logged-out"
1080
+ // and the operator is told to fix intents / re-token. Any other close code
1081
+ // (e.g. 1006) is a normal transient drop discord.js recovers from on its own.
1082
+ c.on("shardDisconnect", ((closeEvent) => {
1083
+ stampInboundEvent(); // a disconnect is still gateway traffic (liveness)
1084
+ const code = closeEvent?.code;
1085
+ if (code !== 4014 && code !== 4004)
1086
+ return;
1087
+ if (tokenInvalid)
1088
+ return;
1089
+ tokenInvalid = true;
1090
+ connected = false;
1091
+ safeLog("discord gateway closed with a terminal code — re-token / fix intents required", { code });
1092
+ args.onTokenInvalid?.();
1093
+ }));
1094
+ // A token revoked / Gateway error mid-session surfaces on `error`; mark the
1095
+ // token invalid on an auth-class error so health flips to "logged-out".
1096
+ c.on("error", ((err) => {
1097
+ if (!isDiscordUnauthorized(err)) {
1098
+ safeLog("discord client error", { error: err instanceof Error ? err.message : String(err) });
1099
+ return;
1100
+ }
1101
+ if (tokenInvalid)
1102
+ return;
1103
+ tokenInvalid = true;
1104
+ connected = false;
1105
+ safeLog("discord token rejected mid-session — re-token required");
1106
+ args.onTokenInvalid?.();
1107
+ }));
1108
+ };
1109
+ /* ── bootstrap + supervise ── */
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
+ */
1117
+ const startOnce = async () => {
1118
+ const c = buildClient(args.botToken);
1119
+ client = c;
1120
+ ready = false;
1121
+ wireClient(c);
1122
+ // `clientReady` fires once the Gateway handshake + initial guild sync settle.
1123
+ c.once("clientReady", (() => {
1124
+ selfId = typeof c.user?.id === "string" ? c.user.id : null;
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();
1130
+ }));
1131
+ // login() rejects on an invalid token (terminal); resolves once the Gateway
1132
+ // is identifying. We treat a resolved login as connected and read the cached
1133
+ // user id (clientReady may fire just after login resolves).
1134
+ await c.login(args.botToken);
1135
+ selfId = selfId ?? (typeof c.user?.id === "string" ? c.user.id : null);
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();
1183
+ };
1184
+ /**
1185
+ * The supervise loop — login, and on a transient setup failure reconnect with
1186
+ * backoff. discord.js auto-reconnects the Gateway internally once logged in, so
1187
+ * this loop mainly guards the initial login. A terminal auth error stops it.
1188
+ */
1189
+ const superviseLoop = async () => {
1190
+ while (!closed && !tokenInvalid) {
1191
+ try {
1192
+ await startOnce();
1193
+ }
1194
+ catch (err) {
1195
+ if (isDiscordUnauthorized(err)) {
1196
+ tokenInvalid = true;
1197
+ connected = false;
1198
+ safeLog("discord token rejected — re-token required; not connecting");
1199
+ args.onTokenInvalid?.();
1200
+ return;
1201
+ }
1202
+ if (closed)
1203
+ return;
1204
+ const delay = discordBackoffDelay(reconnectAttempts);
1205
+ reconnectAttempts += 1;
1206
+ if (reconnectAttempts > RECONNECT_MAX_ATTEMPTS) {
1207
+ safeLog("discord setup attempts exhausted — giving up until restart", { attempts: reconnectAttempts });
1208
+ return;
1209
+ }
1210
+ safeLog("discord setup failed — retrying", {
1211
+ attempt: reconnectAttempts,
1212
+ delayMs: delay,
1213
+ error: err instanceof Error ? err.message : String(err),
1214
+ });
1215
+ // Tear down the half-built client before retrying.
1216
+ await teardownClient();
1217
+ await sleep(delay);
1218
+ continue;
1219
+ }
1220
+ if (closed) {
1221
+ await teardownClient();
1222
+ return;
1223
+ }
1224
+ connected = true;
1225
+ connectedAtMs = Date.now();
1226
+ reconnectAttempts = 0;
1227
+ safeLog("discord connected", { account: accountId, self: selfName ? `@${selfName}` : selfId });
1228
+ args.onConnected?.();
1229
+ return;
1230
+ }
1231
+ };
1232
+ const teardownClient = async () => {
1233
+ const c = client;
1234
+ client = null;
1235
+ ready = false;
1236
+ if (c) {
1237
+ try {
1238
+ await c.destroy();
1239
+ }
1240
+ catch {
1241
+ /* already destroyed */
1242
+ }
1243
+ }
1244
+ };
1245
+ // Kick startup. `connectDiscord` resolves as soon as the FIRST connect (or
1246
+ // terminal failure) settles so the adapter's start() doesn't hang.
1247
+ let resolveInitial;
1248
+ const initial = new Promise((resolve) => {
1249
+ resolveInitial = resolve;
1250
+ });
1251
+ const origOnConnected = args.onConnected;
1252
+ const origOnTokenInvalid = args.onTokenInvalid;
1253
+ args.onConnected = () => {
1254
+ origOnConnected?.();
1255
+ resolveInitial();
1256
+ };
1257
+ args.onTokenInvalid = () => {
1258
+ origOnTokenInvalid?.();
1259
+ resolveInitial();
1260
+ };
1261
+ loopPromise = superviseLoop().catch((err) => {
1262
+ safeLog("discord supervise loop crashed", { error: err instanceof Error ? err.message : String(err) });
1263
+ });
1264
+ await Promise.race([initial, loopPromise.then(() => undefined)]);
1265
+ /* ── outbound + control surface ── */
1266
+ const requireLive = () => {
1267
+ if (tokenInvalid)
1268
+ throw new Error("Discord token is invalid — set a new bot token and restart.");
1269
+ if (!client)
1270
+ throw new Error("Discord channel is not started");
1271
+ return client;
1272
+ };
1273
+ /**
1274
+ * Resolve the channel to send into. For a thread send the threadId IS the
1275
+ * target channel (Discord threads are channels), so it wins over the base
1276
+ * channel; otherwise the conversation channel is fetched. Returns the
1277
+ * text-capable send channel or throws a clear error.
1278
+ */
1279
+ const resolveSendChannel = async (channel, threadId) => {
1280
+ const c = requireLive();
1281
+ const targetId = threadId || channel;
1282
+ const ch = await c.channels.fetch(targetId);
1283
+ if (!ch)
1284
+ throw new Error(`Discord: channel ${targetId} not found`);
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)) {
1289
+ throw new Error(`Discord: channel ${targetId} is not text-based`);
1290
+ }
1291
+ return ch;
1292
+ };
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 };
1313
+ }
1314
+ const sent = await ch.send(options);
1315
+ return { messageId: typeof sent.id === "string" ? sent.id : "" };
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
+ };
1338
+ const sendInteractive = async (channel, text, rows, opts) => {
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);
1378
+ }
1379
+ };
1380
+ const sendMedia = async (channel, media, opts) => {
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
+ }
1397
+ };
1398
+ const fetchMessage = async (channel, messageId) => {
1399
+ const ch = await resolveSendChannel(channel);
1400
+ const fetchFn = ch.messages?.fetch;
1401
+ if (typeof fetchFn !== "function")
1402
+ throw new Error("Discord: channel cannot fetch messages");
1403
+ const msg = await fetchFn.call(ch.messages, messageId);
1404
+ if (!msg)
1405
+ throw new Error(`Discord: message ${messageId} not found`);
1406
+ return msg;
1407
+ };
1408
+ const editMessageText = async (channel, messageId, text) => {
1409
+ const msg = await fetchMessage(channel, messageId);
1410
+ await msg.edit({ content: text });
1411
+ };
1412
+ const deleteMessage = async (channel, messageId) => {
1413
+ const msg = await fetchMessage(channel, messageId);
1414
+ await msg.delete();
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
+ };
1428
+ const react = async (channel, messageId, emoji) => {
1429
+ const name = emoji.trim();
1430
+ if (!name)
1431
+ return;
1432
+ try {
1433
+ const msg = await fetchMessage(channel, messageId);
1434
+ await msg.react(name);
1435
+ }
1436
+ catch (err) {
1437
+ // Reactions are cosmetic — a missing emoji / permission never blocks.
1438
+ safeLog("discord react failed (cosmetic)", { error: err instanceof Error ? err.message : String(err) });
1439
+ }
1440
+ };
1441
+ const removeOwnReactions = async (channel, messageId) => {
1442
+ try {
1443
+ const msg = await fetchMessage(channel, messageId);
1444
+ const cache = msg.reactions?.cache;
1445
+ const list = cache
1446
+ ? cache instanceof Map
1447
+ ? [...cache.values()]
1448
+ : [...cache]
1449
+ : [];
1450
+ const mine = list.filter((r) => r.me === true);
1451
+ // Nothing cached as ours → fall back to removeAll only if no other users'
1452
+ // reactions would be clobbered (we can't tell, so we DON'T removeAll here).
1453
+ for (const r of mine) {
1454
+ try {
1455
+ if (r.users?.remove)
1456
+ await r.users.remove(selfId ?? undefined);
1457
+ else if (r.remove)
1458
+ await r.remove();
1459
+ }
1460
+ catch (err) {
1461
+ safeLog("discord remove own reaction failed (cosmetic)", { error: err instanceof Error ? err.message : String(err) });
1462
+ }
1463
+ }
1464
+ }
1465
+ catch (err) {
1466
+ safeLog("discord removeOwnReactions failed (cosmetic)", { error: err instanceof Error ? err.message : String(err) });
1467
+ }
1468
+ };
1469
+ const registerCommands = async (commands) => {
1470
+ if (!commands || commands.length === 0)
1471
+ return;
1472
+ const c = client;
1473
+ const appId = selfId;
1474
+ const rest = c?.rest;
1475
+ if (!c || !appId || !rest || typeof rest.put !== "function")
1476
+ return;
1477
+ try {
1478
+ // Lazy-import Routes only on the production path; a test fake doesn't reach
1479
+ // here (no rest.put). The application-commands route is global.
1480
+ const discord = await import("discord.js");
1481
+ await rest.put(discord.Routes.applicationCommands(appId), { body: commands });
1482
+ safeLog("discord application commands registered", { count: commands.length });
1483
+ }
1484
+ catch (err) {
1485
+ safeLog("discord command registration failed (best-effort)", { error: err instanceof Error ? err.message : String(err) });
1486
+ }
1487
+ };
1488
+ const setComposing = async (channel, state) => {
1489
+ // Discord shows typing for ~10s or until the next message; we only fire it on
1490
+ // "composing" (there's no "stop typing" call). Best-effort + cosmetic.
1491
+ if (state !== "composing")
1492
+ return;
1493
+ try {
1494
+ const ch = await resolveSendChannel(channel);
1495
+ if (typeof ch.sendTyping === "function")
1496
+ await ch.sendTyping();
1497
+ }
1498
+ catch {
1499
+ /* cosmetic — missing permission / not live: ignore */
1500
+ }
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
+ };
1556
+ const close = async () => {
1557
+ closed = true;
1558
+ connected = false;
1559
+ ready = false;
1560
+ cancelReadyWatchdog();
1561
+ await teardownClient();
1562
+ try {
1563
+ await Promise.race([
1564
+ loopPromise ?? Promise.resolve(),
1565
+ new Promise((resolve) => setTimeout(resolve, 5_000).unref?.()),
1566
+ ]);
1567
+ }
1568
+ catch {
1569
+ /* loop already settled */
1570
+ }
1571
+ };
1572
+ return {
1573
+ selfId: () => selfId,
1574
+ selfName: () => selfName,
1575
+ connectedAt: () => connectedAtMs,
1576
+ lastEventAt: () => lastEventAtMs,
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),
1581
+ isTokenInvalid: () => tokenInvalid,
1582
+ sendText,
1583
+ sendInteractive,
1584
+ sendMedia,
1585
+ react,
1586
+ removeOwnReactions,
1587
+ editMessageText,
1588
+ deleteMessage,
1589
+ pinMessage,
1590
+ unpinMessage,
1591
+ registerCommands,
1592
+ setComposing,
1593
+ markRead: async () => { },
1594
+ applyPresence,
1595
+ createThreadFromMessage,
1596
+ close,
1597
+ };
1598
+ }
1599
+ export { DISCORD_MESSAGE_LIMIT };
1600
+ //# sourceMappingURL=connection.js.map