@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.
- package/dist/agents/agent-loop.d.ts.map +1 -1
- package/dist/agents/agent-loop.js +51 -1
- package/dist/agents/agent-loop.js.map +1 -1
- package/dist/agents/channels/bundled-channel-metas.d.ts +2 -0
- package/dist/agents/channels/bundled-channel-metas.d.ts.map +1 -1
- package/dist/agents/channels/bundled-channel-metas.js +11 -0
- package/dist/agents/channels/bundled-channel-metas.js.map +1 -1
- package/dist/agents/channels/discord/account-config.d.ts +177 -0
- package/dist/agents/channels/discord/account-config.d.ts.map +1 -0
- package/dist/agents/channels/discord/account-config.js +349 -0
- package/dist/agents/channels/discord/account-config.js.map +1 -0
- package/dist/agents/channels/discord/adapter.d.ts +79 -0
- package/dist/agents/channels/discord/adapter.d.ts.map +1 -0
- package/dist/agents/channels/discord/adapter.js +693 -0
- package/dist/agents/channels/discord/adapter.js.map +1 -0
- package/dist/agents/channels/discord/approval-authorize.d.ts +43 -0
- package/dist/agents/channels/discord/approval-authorize.d.ts.map +1 -0
- package/dist/agents/channels/discord/approval-authorize.js +71 -0
- package/dist/agents/channels/discord/approval-authorize.js.map +1 -0
- package/dist/agents/channels/discord/approval-native.d.ts +68 -0
- package/dist/agents/channels/discord/approval-native.d.ts.map +1 -0
- package/dist/agents/channels/discord/approval-native.js +81 -0
- package/dist/agents/channels/discord/approval-native.js.map +1 -0
- package/dist/agents/channels/discord/command-menu.d.ts +49 -0
- package/dist/agents/channels/discord/command-menu.d.ts.map +1 -0
- package/dist/agents/channels/discord/command-menu.js +73 -0
- package/dist/agents/channels/discord/command-menu.js.map +1 -0
- package/dist/agents/channels/discord/component-blocks.d.ts +108 -0
- package/dist/agents/channels/discord/component-blocks.d.ts.map +1 -0
- package/dist/agents/channels/discord/component-blocks.js +113 -0
- package/dist/agents/channels/discord/component-blocks.js.map +1 -0
- package/dist/agents/channels/discord/components.d.ts +175 -0
- package/dist/agents/channels/discord/components.d.ts.map +1 -0
- package/dist/agents/channels/discord/components.js +220 -0
- package/dist/agents/channels/discord/components.js.map +1 -0
- package/dist/agents/channels/discord/connection.d.ts +570 -0
- package/dist/agents/channels/discord/connection.d.ts.map +1 -0
- package/dist/agents/channels/discord/connection.js +1600 -0
- package/dist/agents/channels/discord/connection.js.map +1 -0
- package/dist/agents/channels/discord/directory-cache.d.ts +47 -0
- package/dist/agents/channels/discord/directory-cache.d.ts.map +1 -0
- package/dist/agents/channels/discord/directory-cache.js +131 -0
- package/dist/agents/channels/discord/directory-cache.js.map +1 -0
- package/dist/agents/channels/discord/directory-live.d.ts +61 -0
- package/dist/agents/channels/discord/directory-live.d.ts.map +1 -0
- package/dist/agents/channels/discord/directory-live.js +140 -0
- package/dist/agents/channels/discord/directory-live.js.map +1 -0
- package/dist/agents/channels/discord/draft-stream.d.ts +92 -0
- package/dist/agents/channels/discord/draft-stream.d.ts.map +1 -0
- package/dist/agents/channels/discord/draft-stream.js +213 -0
- package/dist/agents/channels/discord/draft-stream.js.map +1 -0
- package/dist/agents/channels/discord/format.d.ts +70 -0
- package/dist/agents/channels/discord/format.d.ts.map +1 -0
- package/dist/agents/channels/discord/format.js +303 -0
- package/dist/agents/channels/discord/format.js.map +1 -0
- package/dist/agents/channels/discord/guilds.d.ts +25 -0
- package/dist/agents/channels/discord/guilds.d.ts.map +1 -0
- package/dist/agents/channels/discord/guilds.js +46 -0
- package/dist/agents/channels/discord/guilds.js.map +1 -0
- package/dist/agents/channels/discord/inbound-extras.d.ts +377 -0
- package/dist/agents/channels/discord/inbound-extras.d.ts.map +1 -0
- package/dist/agents/channels/discord/inbound-extras.js +589 -0
- package/dist/agents/channels/discord/inbound-extras.js.map +1 -0
- package/dist/agents/channels/discord/index.d.ts +21 -0
- package/dist/agents/channels/discord/index.d.ts.map +1 -0
- package/dist/agents/channels/discord/index.js +21 -0
- package/dist/agents/channels/discord/index.js.map +1 -0
- package/dist/agents/channels/discord/media.d.ts +85 -0
- package/dist/agents/channels/discord/media.d.ts.map +1 -0
- package/dist/agents/channels/discord/media.js +242 -0
- package/dist/agents/channels/discord/media.js.map +1 -0
- package/dist/agents/channels/discord/modal-registry.d.ts +89 -0
- package/dist/agents/channels/discord/modal-registry.d.ts.map +1 -0
- package/dist/agents/channels/discord/modal-registry.js +104 -0
- package/dist/agents/channels/discord/modal-registry.js.map +1 -0
- package/dist/agents/channels/discord/modals.d.ts +100 -0
- package/dist/agents/channels/discord/modals.d.ts.map +1 -0
- package/dist/agents/channels/discord/modals.js +124 -0
- package/dist/agents/channels/discord/modals.js.map +1 -0
- package/dist/agents/channels/discord/module.d.ts +15 -0
- package/dist/agents/channels/discord/module.d.ts.map +1 -0
- package/dist/agents/channels/discord/module.js +22 -0
- package/dist/agents/channels/discord/module.js.map +1 -0
- package/dist/agents/channels/discord/permission-audit.d.ts +43 -0
- package/dist/agents/channels/discord/permission-audit.d.ts.map +1 -0
- package/dist/agents/channels/discord/permission-audit.js +192 -0
- package/dist/agents/channels/discord/permission-audit.js.map +1 -0
- package/dist/agents/channels/discord/plugin.d.ts +89 -0
- package/dist/agents/channels/discord/plugin.d.ts.map +1 -0
- package/dist/agents/channels/discord/plugin.js +372 -0
- package/dist/agents/channels/discord/plugin.js.map +1 -0
- package/dist/agents/channels/discord/probe.d.ts +115 -0
- package/dist/agents/channels/discord/probe.d.ts.map +1 -0
- package/dist/agents/channels/discord/probe.js +193 -0
- package/dist/agents/channels/discord/probe.js.map +1 -0
- package/dist/agents/channels/discord/reasoning-lane.d.ts +42 -0
- package/dist/agents/channels/discord/reasoning-lane.d.ts.map +1 -0
- package/dist/agents/channels/discord/reasoning-lane.js +68 -0
- package/dist/agents/channels/discord/reasoning-lane.js.map +1 -0
- package/dist/agents/channels/discord/rest-actions.d.ts +346 -0
- package/dist/agents/channels/discord/rest-actions.d.ts.map +1 -0
- package/dist/agents/channels/discord/rest-actions.js +559 -0
- package/dist/agents/channels/discord/rest-actions.js.map +1 -0
- package/dist/agents/channels/discord/rest-components.d.ts +122 -0
- package/dist/agents/channels/discord/rest-components.d.ts.map +1 -0
- package/dist/agents/channels/discord/rest-components.js +243 -0
- package/dist/agents/channels/discord/rest-components.js.map +1 -0
- package/dist/agents/channels/discord/security-audit.d.ts +29 -0
- package/dist/agents/channels/discord/security-audit.d.ts.map +1 -0
- package/dist/agents/channels/discord/security-audit.js +94 -0
- package/dist/agents/channels/discord/security-audit.js.map +1 -0
- package/dist/agents/channels/discord/security-doctor.d.ts +43 -0
- package/dist/agents/channels/discord/security-doctor.d.ts.map +1 -0
- package/dist/agents/channels/discord/security-doctor.js +83 -0
- package/dist/agents/channels/discord/security-doctor.js.map +1 -0
- package/dist/agents/channels/discord/status-issues.d.ts +37 -0
- package/dist/agents/channels/discord/status-issues.d.ts.map +1 -0
- package/dist/agents/channels/discord/status-issues.js +66 -0
- package/dist/agents/channels/discord/status-issues.js.map +1 -0
- package/dist/agents/channels/discord/subagent-thread-binding-store.d.ts +57 -0
- package/dist/agents/channels/discord/subagent-thread-binding-store.d.ts.map +1 -0
- package/dist/agents/channels/discord/subagent-thread-binding-store.js +98 -0
- package/dist/agents/channels/discord/subagent-thread-binding-store.js.map +1 -0
- package/dist/agents/channels/discord/subagent-thread-binding.d.ts +95 -0
- package/dist/agents/channels/discord/subagent-thread-binding.d.ts.map +1 -0
- package/dist/agents/channels/discord/subagent-thread-binding.js +208 -0
- package/dist/agents/channels/discord/subagent-thread-binding.js.map +1 -0
- package/dist/agents/channels/discord/system-events.d.ts +31 -0
- package/dist/agents/channels/discord/system-events.d.ts.map +1 -0
- package/dist/agents/channels/discord/system-events.js +74 -0
- package/dist/agents/channels/discord/system-events.js.map +1 -0
- package/dist/agents/channels/general-callback.d.ts +12 -0
- package/dist/agents/channels/general-callback.d.ts.map +1 -1
- package/dist/agents/channels/general-callback.js +18 -0
- package/dist/agents/channels/general-callback.js.map +1 -1
- package/dist/agents/channels/inbound-pipeline.d.ts.map +1 -1
- package/dist/agents/channels/inbound-pipeline.js +70 -10
- package/dist/agents/channels/inbound-pipeline.js.map +1 -1
- package/dist/agents/channels/sdk.d.ts +2 -0
- package/dist/agents/channels/sdk.d.ts.map +1 -1
- package/dist/agents/channels/sdk.js +2 -0
- package/dist/agents/channels/sdk.js.map +1 -1
- package/dist/agents/extensions/modules/index.d.ts.map +1 -1
- package/dist/agents/extensions/modules/index.js +5 -0
- package/dist/agents/extensions/modules/index.js.map +1 -1
- package/dist/agents/extensions/types.d.ts +7 -0
- package/dist/agents/extensions/types.d.ts.map +1 -1
- package/dist/agents/extensions/types.js.map +1 -1
- package/dist/agents/subagent-announce-delivery.d.ts +10 -0
- package/dist/agents/subagent-announce-delivery.d.ts.map +1 -1
- package/dist/agents/subagent-announce-delivery.js +1 -0
- package/dist/agents/subagent-announce-delivery.js.map +1 -1
- package/dist/agents/subagent-completion-bridge.d.ts.map +1 -1
- package/dist/agents/subagent-completion-bridge.js +81 -0
- package/dist/agents/subagent-completion-bridge.js.map +1 -1
- package/dist/agents/subagent-spawn.d.ts.map +1 -1
- package/dist/agents/subagent-spawn.js +57 -4
- package/dist/agents/subagent-spawn.js.map +1 -1
- package/dist/agents/tools/cron-tool.d.ts.map +1 -1
- package/dist/agents/tools/cron-tool.js +4 -1
- package/dist/agents/tools/cron-tool.js.map +1 -1
- package/dist/agents/tools/discord-action-tool.d.ts +224 -0
- package/dist/agents/tools/discord-action-tool.d.ts.map +1 -0
- package/dist/agents/tools/discord-action-tool.js +848 -0
- package/dist/agents/tools/discord-action-tool.js.map +1 -0
- package/dist/agents/tools/registry.d.ts.map +1 -1
- package/dist/agents/tools/registry.js +21 -0
- package/dist/agents/tools/registry.js.map +1 -1
- package/dist/agents/tools/sessions/index.d.ts +8 -0
- package/dist/agents/tools/sessions/index.d.ts.map +1 -1
- package/dist/agents/tools/sessions/index.js +15 -3
- package/dist/agents/tools/sessions/index.js.map +1 -1
- package/dist/buildstamp.json +1 -1
- package/dist/cli/commands/channels.d.ts +2 -0
- package/dist/cli/commands/channels.d.ts.map +1 -1
- package/dist/cli/commands/channels.js +58 -1
- package/dist/cli/commands/channels.js.map +1 -1
- package/dist/core/auth-bridge.d.ts +1 -0
- package/dist/core/auth-bridge.d.ts.map +1 -1
- package/dist/core/auth-bridge.js +46 -1
- package/dist/core/auth-bridge.js.map +1 -1
- package/dist/core/server.d.ts.map +1 -1
- package/dist/core/server.js +18 -2
- package/dist/core/server.js.map +1 -1
- package/dist/cron/isolated-agent/run-executor.d.ts +11 -0
- package/dist/cron/isolated-agent/run-executor.d.ts.map +1 -1
- package/dist/cron/isolated-agent/run-executor.js +20 -4
- package/dist/cron/isolated-agent/run-executor.js.map +1 -1
- package/dist/cron/types.d.ts +8 -0
- package/dist/cron/types.d.ts.map +1 -1
- package/dist/system-prompt/assembler.d.ts.map +1 -1
- package/dist/system-prompt/assembler.js +4 -2
- package/dist/system-prompt/assembler.js.map +1 -1
- 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
|