@spinabot/brigade 1.5.0 → 1.6.1
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 +4 -0
- package/dist/agents/channels/bundled-channel-metas.d.ts.map +1 -1
- package/dist/agents/channels/bundled-channel-metas.js +22 -0
- package/dist/agents/channels/bundled-channel-metas.js.map +1 -1
- package/dist/agents/channels/discord/account-config.d.ts +117 -0
- package/dist/agents/channels/discord/account-config.d.ts.map +1 -0
- package/dist/agents/channels/discord/account-config.js +260 -0
- package/dist/agents/channels/discord/account-config.js.map +1 -0
- package/dist/agents/channels/discord/adapter.d.ts +56 -0
- package/dist/agents/channels/discord/adapter.d.ts.map +1 -0
- package/dist/agents/channels/discord/adapter.js +526 -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/components.d.ts +97 -0
- package/dist/agents/channels/discord/components.d.ts.map +1 -0
- package/dist/agents/channels/discord/components.js +131 -0
- package/dist/agents/channels/discord/components.js.map +1 -0
- package/dist/agents/channels/discord/connection.d.ts +387 -0
- package/dist/agents/channels/discord/connection.d.ts.map +1 -0
- package/dist/agents/channels/discord/connection.js +786 -0
- package/dist/agents/channels/discord/connection.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 +55 -0
- package/dist/agents/channels/discord/format.d.ts.map +1 -0
- package/dist/agents/channels/discord/format.js +247 -0
- package/dist/agents/channels/discord/format.js.map +1 -0
- package/dist/agents/channels/discord/inbound-extras.d.ts +220 -0
- package/dist/agents/channels/discord/inbound-extras.d.ts.map +1 -0
- package/dist/agents/channels/discord/inbound-extras.js +351 -0
- package/dist/agents/channels/discord/inbound-extras.js.map +1 -0
- package/dist/agents/channels/discord/index.d.ts +14 -0
- package/dist/agents/channels/discord/index.d.ts.map +1 -0
- package/dist/agents/channels/discord/index.js +14 -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/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/plugin.d.ts +73 -0
- package/dist/agents/channels/discord/plugin.d.ts.map +1 -0
- package/dist/agents/channels/discord/plugin.js +303 -0
- package/dist/agents/channels/discord/plugin.js.map +1 -0
- package/dist/agents/channels/discord/probe.d.ts +93 -0
- package/dist/agents/channels/discord/probe.d.ts.map +1 -0
- package/dist/agents/channels/discord/probe.js +158 -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/inbound-pipeline.d.ts.map +1 -1
- package/dist/agents/channels/inbound-pipeline.js +65 -7
- package/dist/agents/channels/inbound-pipeline.js.map +1 -1
- package/dist/agents/channels/manager.d.ts.map +1 -1
- package/dist/agents/channels/manager.js +18 -0
- package/dist/agents/channels/manager.js.map +1 -1
- package/dist/agents/channels/sdk.d.ts +4 -0
- package/dist/agents/channels/sdk.d.ts.map +1 -1
- package/dist/agents/channels/sdk.js +4 -0
- package/dist/agents/channels/sdk.js.map +1 -1
- package/dist/agents/channels/slack/account-config.d.ts +172 -0
- package/dist/agents/channels/slack/account-config.d.ts.map +1 -0
- package/dist/agents/channels/slack/account-config.js +353 -0
- package/dist/agents/channels/slack/account-config.js.map +1 -0
- package/dist/agents/channels/slack/account-registry.d.ts +45 -0
- package/dist/agents/channels/slack/account-registry.d.ts.map +1 -0
- package/dist/agents/channels/slack/account-registry.js +58 -0
- package/dist/agents/channels/slack/account-registry.js.map +1 -0
- package/dist/agents/channels/slack/adapter.d.ts +66 -0
- package/dist/agents/channels/slack/adapter.d.ts.map +1 -0
- package/dist/agents/channels/slack/adapter.js +547 -0
- package/dist/agents/channels/slack/adapter.js.map +1 -0
- package/dist/agents/channels/slack/approval-authorize.d.ts +43 -0
- package/dist/agents/channels/slack/approval-authorize.d.ts.map +1 -0
- package/dist/agents/channels/slack/approval-authorize.js +71 -0
- package/dist/agents/channels/slack/approval-authorize.js.map +1 -0
- package/dist/agents/channels/slack/approval-native.d.ts +70 -0
- package/dist/agents/channels/slack/approval-native.d.ts.map +1 -0
- package/dist/agents/channels/slack/approval-native.js +85 -0
- package/dist/agents/channels/slack/approval-native.js.map +1 -0
- package/dist/agents/channels/slack/blocks.d.ts +125 -0
- package/dist/agents/channels/slack/blocks.d.ts.map +1 -0
- package/dist/agents/channels/slack/blocks.js +145 -0
- package/dist/agents/channels/slack/blocks.js.map +1 -0
- package/dist/agents/channels/slack/command-menu.d.ts +44 -0
- package/dist/agents/channels/slack/command-menu.d.ts.map +1 -0
- package/dist/agents/channels/slack/command-menu.js +66 -0
- package/dist/agents/channels/slack/command-menu.js.map +1 -0
- package/dist/agents/channels/slack/connection.d.ts +422 -0
- package/dist/agents/channels/slack/connection.d.ts.map +1 -0
- package/dist/agents/channels/slack/connection.js +1042 -0
- package/dist/agents/channels/slack/connection.js.map +1 -0
- package/dist/agents/channels/slack/directory-live.d.ts +129 -0
- package/dist/agents/channels/slack/directory-live.d.ts.map +1 -0
- package/dist/agents/channels/slack/directory-live.js +148 -0
- package/dist/agents/channels/slack/directory-live.js.map +1 -0
- package/dist/agents/channels/slack/draft-stream.d.ts +93 -0
- package/dist/agents/channels/slack/draft-stream.d.ts.map +1 -0
- package/dist/agents/channels/slack/draft-stream.js +218 -0
- package/dist/agents/channels/slack/draft-stream.js.map +1 -0
- package/dist/agents/channels/slack/format.d.ts +41 -0
- package/dist/agents/channels/slack/format.d.ts.map +1 -0
- package/dist/agents/channels/slack/format.js +271 -0
- package/dist/agents/channels/slack/format.js.map +1 -0
- package/dist/agents/channels/slack/inbound-extras.d.ts +179 -0
- package/dist/agents/channels/slack/inbound-extras.d.ts.map +1 -0
- package/dist/agents/channels/slack/inbound-extras.js +257 -0
- package/dist/agents/channels/slack/inbound-extras.js.map +1 -0
- package/dist/agents/channels/slack/index.d.ts +15 -0
- package/dist/agents/channels/slack/index.d.ts.map +1 -0
- package/dist/agents/channels/slack/index.js +15 -0
- package/dist/agents/channels/slack/index.js.map +1 -0
- package/dist/agents/channels/slack/media.d.ts +90 -0
- package/dist/agents/channels/slack/media.d.ts.map +1 -0
- package/dist/agents/channels/slack/media.js +215 -0
- package/dist/agents/channels/slack/media.js.map +1 -0
- package/dist/agents/channels/slack/module.d.ts +26 -0
- package/dist/agents/channels/slack/module.d.ts.map +1 -0
- package/dist/agents/channels/slack/module.js +67 -0
- package/dist/agents/channels/slack/module.js.map +1 -0
- package/dist/agents/channels/slack/plugin.d.ts +69 -0
- package/dist/agents/channels/slack/plugin.d.ts.map +1 -0
- package/dist/agents/channels/slack/plugin.js +318 -0
- package/dist/agents/channels/slack/plugin.js.map +1 -0
- package/dist/agents/channels/slack/probe.d.ts +72 -0
- package/dist/agents/channels/slack/probe.d.ts.map +1 -0
- package/dist/agents/channels/slack/probe.js +103 -0
- package/dist/agents/channels/slack/probe.js.map +1 -0
- package/dist/agents/channels/slack/proxy-agent.d.ts +30 -0
- package/dist/agents/channels/slack/proxy-agent.d.ts.map +1 -0
- package/dist/agents/channels/slack/proxy-agent.js +44 -0
- package/dist/agents/channels/slack/proxy-agent.js.map +1 -0
- package/dist/agents/channels/slack/reasoning-lane.d.ts +42 -0
- package/dist/agents/channels/slack/reasoning-lane.d.ts.map +1 -0
- package/dist/agents/channels/slack/reasoning-lane.js +68 -0
- package/dist/agents/channels/slack/reasoning-lane.js.map +1 -0
- package/dist/agents/channels/slack/user-directory.d.ts +69 -0
- package/dist/agents/channels/slack/user-directory.d.ts.map +1 -0
- package/dist/agents/channels/slack/user-directory.js +94 -0
- package/dist/agents/channels/slack/user-directory.js.map +1 -0
- package/dist/agents/channels/slack/webhook.d.ts +89 -0
- package/dist/agents/channels/slack/webhook.d.ts.map +1 -0
- package/dist/agents/channels/slack/webhook.js +228 -0
- package/dist/agents/channels/slack/webhook.js.map +1 -0
- package/dist/agents/channels/telegram/format.d.ts.map +1 -1
- package/dist/agents/channels/telegram/format.js +17 -1
- package/dist/agents/channels/telegram/format.js.map +1 -1
- package/dist/agents/channels/telegram/webhook.d.ts.map +1 -1
- package/dist/agents/channels/telegram/webhook.js +7 -1
- package/dist/agents/channels/telegram/webhook.js.map +1 -1
- package/dist/agents/extensions/modules/index.d.ts.map +1 -1
- package/dist/agents/extensions/modules/index.js +10 -0
- package/dist/agents/extensions/modules/index.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/buildstamp.json +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 +40 -5
- 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 +5 -1
|
@@ -0,0 +1,786 @@
|
|
|
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 { buildDiscordSenderName, discordChannelType, discordThreadId, extractDiscordMemberRoleIds, extractDiscordMentions, extractDiscordReplyContext, extractDiscordText, hasInboundMedia, isThreadChannel, resolveInboundAttachments, } from "./inbound-extras.js";
|
|
36
|
+
import { buildDiscordAttachment, downloadDiscordAttachment } from "./media.js";
|
|
37
|
+
/* ───────────────────────── reconnect backoff ───────────────────────── */
|
|
38
|
+
// Shares the neutral `nextBackoffDelay` curve with every other channel (see
|
|
39
|
+
// `channels/backoff.ts`), tuned to the SAME schedule WhatsApp + Telegram + Slack
|
|
40
|
+
// use (2s → 30s, ×1.8, ±25%). The constants live here so Discord owns its own
|
|
41
|
+
// knobs; the arithmetic is the shared helper's.
|
|
42
|
+
const RECONNECT_INITIAL_MS = 2_000;
|
|
43
|
+
const RECONNECT_MAX_MS = 30_000;
|
|
44
|
+
const RECONNECT_FACTOR = 1.8;
|
|
45
|
+
const RECONNECT_JITTER = 0.25;
|
|
46
|
+
const RECONNECT_MAX_ATTEMPTS = 12;
|
|
47
|
+
/**
|
|
48
|
+
* Jittered exponential backoff for reconnect attempt `attempt` (0-based). Thin
|
|
49
|
+
* wrapper over the neutral `nextBackoffDelay` helper — kept as a named export so
|
|
50
|
+
* `index.ts` + the connection tests have a stable entry point.
|
|
51
|
+
*/
|
|
52
|
+
export function discordBackoffDelay(attempt) {
|
|
53
|
+
return nextBackoffDelay({
|
|
54
|
+
attempt,
|
|
55
|
+
initialMs: RECONNECT_INITIAL_MS,
|
|
56
|
+
maxMs: RECONNECT_MAX_MS,
|
|
57
|
+
factor: RECONNECT_FACTOR,
|
|
58
|
+
jitter: RECONNECT_JITTER,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** Discord's hard per-message content limit (chars). Sends chunk under this. */
|
|
62
|
+
const DISCORD_MESSAGE_LIMIT = 2_000;
|
|
63
|
+
/**
|
|
64
|
+
* The SAFE default `allowedMentions` applied to every outbound Discord send.
|
|
65
|
+
* `parse: ["users", "roles"]` lets explicit `<@id>` / `<@&roleid>` mentions ping
|
|
66
|
+
* as intended, while the absence of `"everyone"` means an `@everyone` / `@here`
|
|
67
|
+
* that slipped into the content (agent text or a prompt injection) renders as
|
|
68
|
+
* text and notifies no one. `repliedUser: false` keeps a native reply from
|
|
69
|
+
* pinging the author it answers. A fresh object is returned per call so a send
|
|
70
|
+
* can't mutate the shared default.
|
|
71
|
+
*/
|
|
72
|
+
export function safeDiscordAllowedMentions() {
|
|
73
|
+
return { parse: ["users", "roles"], repliedUser: false };
|
|
74
|
+
}
|
|
75
|
+
/* ───────────────────────── error classification ───────────────────────── */
|
|
76
|
+
/** Pull a message string off any thrown shape. */
|
|
77
|
+
function errorText(err) {
|
|
78
|
+
if (!err)
|
|
79
|
+
return "";
|
|
80
|
+
if (typeof err === "string")
|
|
81
|
+
return err;
|
|
82
|
+
const e = err;
|
|
83
|
+
return e.message ?? String(err);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* A Discord auth failure → the token is wrong / revoked / reset. Re-tokening is
|
|
87
|
+
* the only fix; reconnecting with the same token loops forever. discord.js
|
|
88
|
+
* surfaces a bad token on login as a `TokenInvalid` error / a message containing
|
|
89
|
+
* "invalid token" / an "disallowed intents" privileged-intents rejection (which
|
|
90
|
+
* is also terminal until the operator fixes the bot's intent settings).
|
|
91
|
+
*/
|
|
92
|
+
export function isDiscordUnauthorized(err) {
|
|
93
|
+
const name = err?.name ?? "";
|
|
94
|
+
if (/TokenInvalid|DisallowedIntents/i.test(name))
|
|
95
|
+
return true;
|
|
96
|
+
const code = err?.code;
|
|
97
|
+
if (code === "TokenInvalid" || code === "DisallowedIntents")
|
|
98
|
+
return true;
|
|
99
|
+
return /invalid token|incorrect login|disallowed intents|used disallowed intents/i.test(errorText(err));
|
|
100
|
+
}
|
|
101
|
+
/** Strip a Discord token out of a string before it logs. */
|
|
102
|
+
export function redactDiscordToken(text, ...tokens) {
|
|
103
|
+
if (!text)
|
|
104
|
+
return text;
|
|
105
|
+
let out = text;
|
|
106
|
+
for (const token of tokens) {
|
|
107
|
+
if (token)
|
|
108
|
+
out = out.split(token).join("<redacted>");
|
|
109
|
+
}
|
|
110
|
+
// Discord bot tokens look like `<base64 id>.<base64 ts>.<secret>`; mask a
|
|
111
|
+
// plausible token fragment even if the exact token differs.
|
|
112
|
+
out = out.replace(/[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{20,}/g, "<redacted>");
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
/* ───────────────────────── the connection ───────────────────────── */
|
|
116
|
+
export async function connectDiscord(args) {
|
|
117
|
+
const accountId = args.accountId ?? "default";
|
|
118
|
+
const sleep = args.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms).unref?.()));
|
|
119
|
+
const safeLog = (msg, meta) => {
|
|
120
|
+
const redactedMsg = redactDiscordToken(msg, args.botToken);
|
|
121
|
+
if (!meta)
|
|
122
|
+
return args.log(redactedMsg);
|
|
123
|
+
const redactedMeta = {};
|
|
124
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
125
|
+
redactedMeta[k] = typeof v === "string" ? redactDiscordToken(v, args.botToken) : v;
|
|
126
|
+
}
|
|
127
|
+
args.log(redactedMsg, redactedMeta);
|
|
128
|
+
};
|
|
129
|
+
const proxyUrl = (args.proxyUrl ?? "").trim();
|
|
130
|
+
// ── lazy-load discord.js (production path only) ──
|
|
131
|
+
// `builders` resolves in this precedence: an injected `buildersFactory` wins
|
|
132
|
+
// (tests / a custom build); else the production path builds real discord.js
|
|
133
|
+
// builders; else (a `clientFactory` with no builders supplied) a pass-through
|
|
134
|
+
// fake that emits plain JSON. So a test injecting just a `clientFactory` gets
|
|
135
|
+
// the pass-through, and a real boot gets real discord.js builders.
|
|
136
|
+
let buildClient;
|
|
137
|
+
let builders = args.buildersFactory ? args.buildersFactory() : undefined;
|
|
138
|
+
if (args.clientFactory) {
|
|
139
|
+
const factory = args.clientFactory;
|
|
140
|
+
buildClient = (botToken) => factory(botToken, proxyUrl || undefined);
|
|
141
|
+
builders ??= {
|
|
142
|
+
buildAttachment: (p, name) => ({ attachment: p, name }),
|
|
143
|
+
buildComponentRows: (rows) => rows.map((row) => ({ components: row })),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
const discord = await import("discord.js");
|
|
148
|
+
const { Client, GatewayIntentBits, Partials } = discord;
|
|
149
|
+
// Optional proxy → a custom REST `makeRequest` via undici's ProxyAgent. A
|
|
150
|
+
// missing/malformed proxy must not wedge the channel (logged, ignored).
|
|
151
|
+
let rest;
|
|
152
|
+
if (proxyUrl) {
|
|
153
|
+
try {
|
|
154
|
+
const { ProxyAgent, fetch: undiciFetch } = await import("undici");
|
|
155
|
+
const dispatcher = new ProxyAgent(proxyUrl);
|
|
156
|
+
rest = {
|
|
157
|
+
makeRequest: ((url, init) => undiciFetch(url, { ...init, dispatcher })),
|
|
158
|
+
};
|
|
159
|
+
safeLog("discord routing through proxy", { account: accountId, proxy: maskProxyUrl(proxyUrl) });
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
safeLog("discord proxy setup failed — connecting directly", {
|
|
163
|
+
account: accountId,
|
|
164
|
+
proxy: maskProxyUrl(proxyUrl),
|
|
165
|
+
error: err instanceof Error ? err.message : String(err),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
buildClient = (_botToken) => new Client({
|
|
170
|
+
intents: [
|
|
171
|
+
GatewayIntentBits.Guilds,
|
|
172
|
+
GatewayIntentBits.GuildMessages,
|
|
173
|
+
GatewayIntentBits.MessageContent,
|
|
174
|
+
GatewayIntentBits.GuildMessageReactions,
|
|
175
|
+
GatewayIntentBits.DirectMessages,
|
|
176
|
+
GatewayIntentBits.DirectMessageReactions,
|
|
177
|
+
],
|
|
178
|
+
// Partials so DM channels + uncached reactions/messages still fire events.
|
|
179
|
+
partials: [Partials.Channel, Partials.Message, Partials.Reaction],
|
|
180
|
+
...(rest ? { rest: rest } : {}),
|
|
181
|
+
});
|
|
182
|
+
builders ??= {
|
|
183
|
+
buildAttachment(p, name) {
|
|
184
|
+
return new discord.AttachmentBuilder(p, { name });
|
|
185
|
+
},
|
|
186
|
+
buildComponentRows(rows) {
|
|
187
|
+
return rows.map((row) => {
|
|
188
|
+
const r = new discord.ActionRowBuilder();
|
|
189
|
+
for (const b of row) {
|
|
190
|
+
r.addComponents(new discord.ButtonBuilder().setCustomId(b.customId).setLabel(b.label).setStyle(b.style));
|
|
191
|
+
}
|
|
192
|
+
return r;
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
// `builders` is always assigned by here (every branch sets it); the non-null
|
|
198
|
+
// assertion documents that for the closures below.
|
|
199
|
+
const resolvedBuilders = builders;
|
|
200
|
+
// ── connection state ──
|
|
201
|
+
let selfId = null;
|
|
202
|
+
let selfName = null;
|
|
203
|
+
let connectedAtMs = null;
|
|
204
|
+
let lastEventAtMs = null;
|
|
205
|
+
const stampInboundEvent = () => {
|
|
206
|
+
lastEventAtMs = Date.now();
|
|
207
|
+
};
|
|
208
|
+
let connected = false;
|
|
209
|
+
let tokenInvalid = false;
|
|
210
|
+
let closed = false;
|
|
211
|
+
let reconnectAttempts = 0;
|
|
212
|
+
let client = null;
|
|
213
|
+
let loopPromise = null;
|
|
214
|
+
// Dedupe inbound events by id — a redelivered event after a reconnect must not
|
|
215
|
+
// double-run the agent. Per-connection lifetime.
|
|
216
|
+
const eventDedupe = createDedupeCache({ maxEntries: 10_000, ttlMs: 60 * 60 * 1_000 });
|
|
217
|
+
// Last inbound message id per channel — the target setComposing targets (Discord
|
|
218
|
+
// shows typing per-channel, so we just need the channel; the map is kept for
|
|
219
|
+
// parity / future per-message affordances).
|
|
220
|
+
const lastInboundChannel = new Set();
|
|
221
|
+
/** Token-resolver lookups primed from the cached client (best-effort, sync). */
|
|
222
|
+
const resolveLookups = (message) => {
|
|
223
|
+
// On a live message discord.js resolves mention display via the resolved
|
|
224
|
+
// collections; expandDiscordTokens falls back to the bare id when a name
|
|
225
|
+
// isn't cached. We pass resolvers that read the message's own resolved
|
|
226
|
+
// mention caches when present.
|
|
227
|
+
const users = new Map();
|
|
228
|
+
const mentionUsers = message.mentions?.users;
|
|
229
|
+
if (mentionUsers) {
|
|
230
|
+
const iter = mentionUsers instanceof Map ? mentionUsers.values() : mentionUsers;
|
|
231
|
+
for (const u of iter) {
|
|
232
|
+
if (typeof u?.id === "string")
|
|
233
|
+
users.set(u.id, (u.globalName || u.username || u.id));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { user: (id) => users.get(id) };
|
|
237
|
+
};
|
|
238
|
+
/** Normalize a discord.js message into the deferred-media inbound shape. */
|
|
239
|
+
const normalize = (message, opts) => {
|
|
240
|
+
const channelId = typeof message.channelId === "string" ? message.channelId : typeof message.channel?.id === "string" ? message.channel.id : "";
|
|
241
|
+
if (!channelId)
|
|
242
|
+
return null;
|
|
243
|
+
const resolve = resolveLookups(message);
|
|
244
|
+
const text = extractDiscordText(message, resolve);
|
|
245
|
+
const chatType = discordChannelType(message);
|
|
246
|
+
const threadId = discordThreadId(message);
|
|
247
|
+
const mentions = extractDiscordMentions(message, selfId ?? undefined);
|
|
248
|
+
const replyTo = extractDiscordReplyContext(message);
|
|
249
|
+
const fromName = buildDiscordSenderName(message);
|
|
250
|
+
const fromId = typeof message.author?.id === "string" ? message.author.id : channelId;
|
|
251
|
+
// Discord routes on guildId + member role ids (NOT teamId — that's Slack's
|
|
252
|
+
// workspace tier). A DM carries no guildId and no roles.
|
|
253
|
+
const guildId = typeof message.guildId === "string" ? message.guildId : undefined;
|
|
254
|
+
const memberRoleIds = guildId ? extractDiscordMemberRoleIds(message) : [];
|
|
255
|
+
const messageId = typeof message.id === "string" ? message.id : undefined;
|
|
256
|
+
const timestampMs = typeof message.createdTimestamp === "number" ? message.createdTimestamp : undefined;
|
|
257
|
+
// DEFERRED media — captured by reference, not downloaded. The thunk is only
|
|
258
|
+
// invoked by the pipeline after the access gate admits the sender.
|
|
259
|
+
const carriesMedia = hasInboundMedia(message);
|
|
260
|
+
const resolveMedia = carriesMedia
|
|
261
|
+
? async () => {
|
|
262
|
+
const atts = resolveInboundAttachments(message);
|
|
263
|
+
if (atts.length === 0)
|
|
264
|
+
return [];
|
|
265
|
+
const out = [];
|
|
266
|
+
for (const att of atts) {
|
|
267
|
+
const dl = await downloadDiscordAttachment({ attachment: att, log: safeLog });
|
|
268
|
+
if (dl)
|
|
269
|
+
out.push(dl);
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
: undefined;
|
|
274
|
+
return {
|
|
275
|
+
conversationId: channelId,
|
|
276
|
+
...(messageId ? { messageId } : {}),
|
|
277
|
+
...(timestampMs !== undefined ? { messageTimestampMs: timestampMs } : {}),
|
|
278
|
+
from: fromId,
|
|
279
|
+
...(fromName ? { fromName } : {}),
|
|
280
|
+
text,
|
|
281
|
+
chatType,
|
|
282
|
+
...(guildId ? { guildId } : {}),
|
|
283
|
+
...(memberRoleIds.length > 0 ? { memberRoleIds } : {}),
|
|
284
|
+
...(threadId ? { threadId } : {}),
|
|
285
|
+
...(mentions.length > 0 ? { mentions } : {}),
|
|
286
|
+
...(replyTo ? { replyTo } : {}),
|
|
287
|
+
...(opts?.edited ? { edited: true } : {}),
|
|
288
|
+
...(resolveMedia ? { resolveMedia } : {}),
|
|
289
|
+
raw: message,
|
|
290
|
+
};
|
|
291
|
+
};
|
|
292
|
+
/** Is this message one the bot itself authored (its own echo)? */
|
|
293
|
+
const isSelfAuthored = (message) => {
|
|
294
|
+
if (selfId && message.author?.id === selfId)
|
|
295
|
+
return true;
|
|
296
|
+
// A webhook / bot author with no resolvable user shouldn't loop us either,
|
|
297
|
+
// but only OUR own id is a definite echo; other bots are allowed through.
|
|
298
|
+
return false;
|
|
299
|
+
};
|
|
300
|
+
/** Handle a messageCreate / messageUpdate event. */
|
|
301
|
+
const handleMessage = (message, opts) => {
|
|
302
|
+
try {
|
|
303
|
+
stampInboundEvent();
|
|
304
|
+
// Skip the bot's own messages (echoes) — a bot must never reply to itself.
|
|
305
|
+
if (isSelfAuthored(message))
|
|
306
|
+
return;
|
|
307
|
+
// Skip a message authored by ANY bot/webhook to avoid bot-loops (parity with
|
|
308
|
+
// the conservative default; a human-only channel is the norm for Brigade).
|
|
309
|
+
if (message.author?.bot === true)
|
|
310
|
+
return;
|
|
311
|
+
const id = typeof message.id === "string" ? message.id : "";
|
|
312
|
+
if (!id)
|
|
313
|
+
return;
|
|
314
|
+
// Edits fold the edit timestamp into the key so a second edit still routes.
|
|
315
|
+
const editStamp = opts?.edited && typeof message.editedTimestamp === "number" ? message.editedTimestamp : "";
|
|
316
|
+
const key = opts?.edited ? `edit:${id}:${editStamp}` : id;
|
|
317
|
+
if (!eventDedupe.claim(key))
|
|
318
|
+
return; // already seen
|
|
319
|
+
const normalized = normalize(message, opts);
|
|
320
|
+
if (!normalized)
|
|
321
|
+
return;
|
|
322
|
+
args.onMessage(normalized);
|
|
323
|
+
lastInboundChannel.add(normalized.conversationId);
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
safeLog("discord inbound handler error", { error: err instanceof Error ? err.message : String(err) });
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
/**
|
|
330
|
+
* Normalize a reaction-add into the inbound shape. Surfaces the single added
|
|
331
|
+
* emoji, the actor, and the target message id. Reactions carry no text.
|
|
332
|
+
*/
|
|
333
|
+
const normalizeReaction = (reaction, user) => {
|
|
334
|
+
const msg = reaction.message;
|
|
335
|
+
const channel = typeof msg?.channelId === "string" ? msg.channelId : typeof msg?.channel?.id === "string" ? msg.channel.id : "";
|
|
336
|
+
const target = typeof msg?.id === "string" ? msg.id : "";
|
|
337
|
+
// A custom emoji surfaces as `name:id`; a unicode emoji as its char.
|
|
338
|
+
const emojiName = reaction.emoji?.name ?? "";
|
|
339
|
+
const emoji = reaction.emoji?.id ? `${emojiName}:${reaction.emoji.id}` : emojiName;
|
|
340
|
+
if (!channel || !target || !emoji)
|
|
341
|
+
return null;
|
|
342
|
+
const fromId = typeof user?.id === "string" ? user.id : channel;
|
|
343
|
+
if (selfId && fromId === selfId)
|
|
344
|
+
return null; // the bot's own reaction
|
|
345
|
+
if (user?.bot === true)
|
|
346
|
+
return null; // ignore other bots' reactions
|
|
347
|
+
const fromName = user?.username;
|
|
348
|
+
const guildId = typeof msg?.guildId === "string" ? msg.guildId : undefined;
|
|
349
|
+
return {
|
|
350
|
+
conversationId: channel,
|
|
351
|
+
from: fromId,
|
|
352
|
+
...(fromName ? { fromName } : {}),
|
|
353
|
+
text: "",
|
|
354
|
+
chatType: msg?.guildId ? "group" : "direct",
|
|
355
|
+
...(guildId ? { guildId } : {}),
|
|
356
|
+
reaction: { emojis: [emoji], targetMessageId: target },
|
|
357
|
+
raw: { reaction, user },
|
|
358
|
+
};
|
|
359
|
+
};
|
|
360
|
+
/** Handle a messageReactionAdd event. */
|
|
361
|
+
const handleReactionAdd = (reaction, user) => {
|
|
362
|
+
try {
|
|
363
|
+
stampInboundEvent();
|
|
364
|
+
const key = `react:${user?.id}:${reaction.emoji?.name ?? reaction.emoji?.id}:${reaction.message?.id}`;
|
|
365
|
+
if (!eventDedupe.claim(key))
|
|
366
|
+
return;
|
|
367
|
+
const normalized = normalizeReaction(reaction, user);
|
|
368
|
+
if (!normalized)
|
|
369
|
+
return;
|
|
370
|
+
args.onReaction?.(normalized);
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
safeLog("discord reaction handler error", { error: err instanceof Error ? err.message : String(err) });
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
/** Handle a messageReactionRemove event — release the add-dedupe key so a re-add re-routes. */
|
|
377
|
+
const handleReactionRemove = (reaction, user) => {
|
|
378
|
+
stampInboundEvent(); // liveness: a removal is still inbound traffic
|
|
379
|
+
eventDedupe.release(`react:${user?.id}:${reaction.emoji?.name ?? reaction.emoji?.id}:${reaction.message?.id}`);
|
|
380
|
+
};
|
|
381
|
+
/** Normalize a button-press interaction into the approval-callback inbound shape. */
|
|
382
|
+
const normalizeButton = (interaction) => {
|
|
383
|
+
const value = typeof interaction.customId === "string" ? interaction.customId : "";
|
|
384
|
+
if (!value)
|
|
385
|
+
return null;
|
|
386
|
+
const channel = typeof interaction.channelId === "string" ? interaction.channelId : typeof interaction.channel?.id === "string" ? interaction.channel.id : "";
|
|
387
|
+
const fromId = typeof interaction.user?.id === "string" ? interaction.user.id : channel;
|
|
388
|
+
if (!channel && !fromId)
|
|
389
|
+
return null;
|
|
390
|
+
const threadId = interaction.channel && isThreadChannel(interaction.channel) ? channel : undefined;
|
|
391
|
+
const fromName = interaction.user?.username;
|
|
392
|
+
return {
|
|
393
|
+
conversationId: channel || fromId,
|
|
394
|
+
from: fromId,
|
|
395
|
+
...(fromName ? { fromName } : {}),
|
|
396
|
+
text: "",
|
|
397
|
+
chatType: interaction.guildId ? "group" : "direct",
|
|
398
|
+
...(typeof interaction.guildId === "string" ? { guildId: interaction.guildId } : {}),
|
|
399
|
+
...(threadId ? { threadId } : {}),
|
|
400
|
+
callbackQuery: { data: value, callbackId: interaction.id ?? "" },
|
|
401
|
+
raw: interaction,
|
|
402
|
+
};
|
|
403
|
+
};
|
|
404
|
+
/**
|
|
405
|
+
* Normalize a slash-command interaction into an ordinary inbound message so the
|
|
406
|
+
* central command map (`/help`, `/status`, …) handles it. The command name is
|
|
407
|
+
* mapped to `/command` text.
|
|
408
|
+
*/
|
|
409
|
+
const normalizeSlash = (interaction) => {
|
|
410
|
+
const command = typeof interaction.commandName === "string" ? interaction.commandName : "";
|
|
411
|
+
const channel = typeof interaction.channelId === "string" ? interaction.channelId : "";
|
|
412
|
+
const fromId = typeof interaction.user?.id === "string" ? interaction.user.id : channel;
|
|
413
|
+
if (!command || !channel)
|
|
414
|
+
return null;
|
|
415
|
+
const fromName = interaction.user?.username;
|
|
416
|
+
return {
|
|
417
|
+
conversationId: channel,
|
|
418
|
+
from: fromId,
|
|
419
|
+
...(fromName ? { fromName } : {}),
|
|
420
|
+
text: `/${command}`,
|
|
421
|
+
chatType: interaction.guildId ? "group" : "direct",
|
|
422
|
+
...(typeof interaction.guildId === "string" ? { guildId: interaction.guildId } : {}),
|
|
423
|
+
raw: interaction,
|
|
424
|
+
};
|
|
425
|
+
};
|
|
426
|
+
/** Handle an interactionCreate event (button press OR slash command). */
|
|
427
|
+
const handleInteraction = (interaction) => {
|
|
428
|
+
try {
|
|
429
|
+
stampInboundEvent();
|
|
430
|
+
if (typeof interaction.isButton === "function" && interaction.isButton()) {
|
|
431
|
+
// Ack the press silently first so Discord doesn't show "interaction
|
|
432
|
+
// failed"; then route the normalized inbound.
|
|
433
|
+
void interaction.deferUpdate?.().catch(() => { });
|
|
434
|
+
const normalized = normalizeButton(interaction);
|
|
435
|
+
if (normalized)
|
|
436
|
+
args.onCallbackQuery?.(normalized);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (typeof interaction.isChatInputCommand === "function" && interaction.isChatInputCommand()) {
|
|
440
|
+
// Ack the command ephemerally so the client spinner clears; the real
|
|
441
|
+
// reply is delivered by the pipeline as a normal channel message.
|
|
442
|
+
void interaction.reply?.({ content: "On it.", ephemeral: true }).catch(() => { });
|
|
443
|
+
const normalized = normalizeSlash(interaction);
|
|
444
|
+
if (normalized)
|
|
445
|
+
args.onMessage(normalized);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
safeLog("discord interaction handler error", { error: err instanceof Error ? err.message : String(err) });
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
/* ── event wiring ── */
|
|
454
|
+
const wireClient = (c) => {
|
|
455
|
+
c.on("messageCreate", ((message) => handleMessage(message)));
|
|
456
|
+
c.on("messageUpdate", ((_old, updated) => {
|
|
457
|
+
// messageUpdate fires for non-content edits too (embeds resolving, pins);
|
|
458
|
+
// only route when there's content to act on.
|
|
459
|
+
if (updated && typeof updated === "object")
|
|
460
|
+
handleMessage(updated, { edited: true });
|
|
461
|
+
}));
|
|
462
|
+
c.on("messageDelete", (() => {
|
|
463
|
+
// A deleted message carries no routable content — just stamp liveness.
|
|
464
|
+
stampInboundEvent();
|
|
465
|
+
}));
|
|
466
|
+
c.on("messageReactionAdd", ((reaction, user) => handleReactionAdd(reaction, user)));
|
|
467
|
+
c.on("messageReactionRemove", ((reaction, user) => handleReactionRemove(reaction, user)));
|
|
468
|
+
c.on("interactionCreate", ((interaction) => handleInteraction(interaction)));
|
|
469
|
+
// A privileged-intents (4014) or auth-failed (4004) Gateway CLOSE is terminal:
|
|
470
|
+
// discord.js would otherwise loop reconnect attempts forever while health
|
|
471
|
+
// stayed "connected". Flip the token-invalid flag so health goes "logged-out"
|
|
472
|
+
// and the operator is told to fix intents / re-token. Any other close code
|
|
473
|
+
// (e.g. 1006) is a normal transient drop discord.js recovers from on its own.
|
|
474
|
+
c.on("shardDisconnect", ((closeEvent) => {
|
|
475
|
+
stampInboundEvent(); // a disconnect is still gateway traffic (liveness)
|
|
476
|
+
const code = closeEvent?.code;
|
|
477
|
+
if (code !== 4014 && code !== 4004)
|
|
478
|
+
return;
|
|
479
|
+
if (tokenInvalid)
|
|
480
|
+
return;
|
|
481
|
+
tokenInvalid = true;
|
|
482
|
+
connected = false;
|
|
483
|
+
safeLog("discord gateway closed with a terminal code — re-token / fix intents required", { code });
|
|
484
|
+
args.onTokenInvalid?.();
|
|
485
|
+
}));
|
|
486
|
+
// A token revoked / Gateway error mid-session surfaces on `error`; mark the
|
|
487
|
+
// token invalid on an auth-class error so health flips to "logged-out".
|
|
488
|
+
c.on("error", ((err) => {
|
|
489
|
+
if (!isDiscordUnauthorized(err)) {
|
|
490
|
+
safeLog("discord client error", { error: err instanceof Error ? err.message : String(err) });
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (tokenInvalid)
|
|
494
|
+
return;
|
|
495
|
+
tokenInvalid = true;
|
|
496
|
+
connected = false;
|
|
497
|
+
safeLog("discord token rejected mid-session — re-token required");
|
|
498
|
+
args.onTokenInvalid?.();
|
|
499
|
+
}));
|
|
500
|
+
};
|
|
501
|
+
/* ── bootstrap + supervise ── */
|
|
502
|
+
/** Build the client, wire events, login, cache identity. */
|
|
503
|
+
const startOnce = async () => {
|
|
504
|
+
const c = buildClient(args.botToken);
|
|
505
|
+
client = c;
|
|
506
|
+
wireClient(c);
|
|
507
|
+
// `clientReady` fires once the Gateway handshake + initial guild sync settle.
|
|
508
|
+
c.once("clientReady", (() => {
|
|
509
|
+
selfId = typeof c.user?.id === "string" ? c.user.id : null;
|
|
510
|
+
selfName = typeof c.user?.username === "string" ? c.user.username : null;
|
|
511
|
+
}));
|
|
512
|
+
// login() rejects on an invalid token (terminal); resolves once the Gateway
|
|
513
|
+
// is identifying. We treat a resolved login as connected and read the cached
|
|
514
|
+
// user id (clientReady may fire just after login resolves).
|
|
515
|
+
await c.login(args.botToken);
|
|
516
|
+
selfId = selfId ?? (typeof c.user?.id === "string" ? c.user.id : null);
|
|
517
|
+
selfName = selfName ?? (typeof c.user?.username === "string" ? c.user.username : null);
|
|
518
|
+
};
|
|
519
|
+
/**
|
|
520
|
+
* The supervise loop — login, and on a transient setup failure reconnect with
|
|
521
|
+
* backoff. discord.js auto-reconnects the Gateway internally once logged in, so
|
|
522
|
+
* this loop mainly guards the initial login. A terminal auth error stops it.
|
|
523
|
+
*/
|
|
524
|
+
const superviseLoop = async () => {
|
|
525
|
+
while (!closed && !tokenInvalid) {
|
|
526
|
+
try {
|
|
527
|
+
await startOnce();
|
|
528
|
+
}
|
|
529
|
+
catch (err) {
|
|
530
|
+
if (isDiscordUnauthorized(err)) {
|
|
531
|
+
tokenInvalid = true;
|
|
532
|
+
connected = false;
|
|
533
|
+
safeLog("discord token rejected — re-token required; not connecting");
|
|
534
|
+
args.onTokenInvalid?.();
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (closed)
|
|
538
|
+
return;
|
|
539
|
+
const delay = discordBackoffDelay(reconnectAttempts);
|
|
540
|
+
reconnectAttempts += 1;
|
|
541
|
+
if (reconnectAttempts > RECONNECT_MAX_ATTEMPTS) {
|
|
542
|
+
safeLog("discord setup attempts exhausted — giving up until restart", { attempts: reconnectAttempts });
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
safeLog("discord setup failed — retrying", {
|
|
546
|
+
attempt: reconnectAttempts,
|
|
547
|
+
delayMs: delay,
|
|
548
|
+
error: err instanceof Error ? err.message : String(err),
|
|
549
|
+
});
|
|
550
|
+
// Tear down the half-built client before retrying.
|
|
551
|
+
await teardownClient();
|
|
552
|
+
await sleep(delay);
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (closed) {
|
|
556
|
+
await teardownClient();
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
connected = true;
|
|
560
|
+
connectedAtMs = Date.now();
|
|
561
|
+
reconnectAttempts = 0;
|
|
562
|
+
safeLog("discord connected", { account: accountId, self: selfName ? `@${selfName}` : selfId });
|
|
563
|
+
args.onConnected?.();
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
const teardownClient = async () => {
|
|
568
|
+
const c = client;
|
|
569
|
+
client = null;
|
|
570
|
+
if (c) {
|
|
571
|
+
try {
|
|
572
|
+
await c.destroy();
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
/* already destroyed */
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
// Kick startup. `connectDiscord` resolves as soon as the FIRST connect (or
|
|
580
|
+
// terminal failure) settles so the adapter's start() doesn't hang.
|
|
581
|
+
let resolveInitial;
|
|
582
|
+
const initial = new Promise((resolve) => {
|
|
583
|
+
resolveInitial = resolve;
|
|
584
|
+
});
|
|
585
|
+
const origOnConnected = args.onConnected;
|
|
586
|
+
const origOnTokenInvalid = args.onTokenInvalid;
|
|
587
|
+
args.onConnected = () => {
|
|
588
|
+
origOnConnected?.();
|
|
589
|
+
resolveInitial();
|
|
590
|
+
};
|
|
591
|
+
args.onTokenInvalid = () => {
|
|
592
|
+
origOnTokenInvalid?.();
|
|
593
|
+
resolveInitial();
|
|
594
|
+
};
|
|
595
|
+
loopPromise = superviseLoop().catch((err) => {
|
|
596
|
+
safeLog("discord supervise loop crashed", { error: err instanceof Error ? err.message : String(err) });
|
|
597
|
+
});
|
|
598
|
+
await Promise.race([initial, loopPromise.then(() => undefined)]);
|
|
599
|
+
/* ── outbound + control surface ── */
|
|
600
|
+
const requireLive = () => {
|
|
601
|
+
if (tokenInvalid)
|
|
602
|
+
throw new Error("Discord token is invalid — set a new bot token and restart.");
|
|
603
|
+
if (!client)
|
|
604
|
+
throw new Error("Discord channel is not started");
|
|
605
|
+
return client;
|
|
606
|
+
};
|
|
607
|
+
/**
|
|
608
|
+
* Resolve the channel to send into. For a thread send the threadId IS the
|
|
609
|
+
* target channel (Discord threads are channels), so it wins over the base
|
|
610
|
+
* channel; otherwise the conversation channel is fetched. Returns the
|
|
611
|
+
* text-capable send channel or throws a clear error.
|
|
612
|
+
*/
|
|
613
|
+
const resolveSendChannel = async (channel, threadId) => {
|
|
614
|
+
const c = requireLive();
|
|
615
|
+
const targetId = threadId || channel;
|
|
616
|
+
const ch = await c.channels.fetch(targetId);
|
|
617
|
+
if (!ch)
|
|
618
|
+
throw new Error(`Discord: channel ${targetId} not found`);
|
|
619
|
+
if (typeof ch.isTextBased === "function" && !ch.isTextBased()) {
|
|
620
|
+
throw new Error(`Discord: channel ${targetId} is not text-based`);
|
|
621
|
+
}
|
|
622
|
+
return ch;
|
|
623
|
+
};
|
|
624
|
+
const sendText = async (channel, text, opts) => {
|
|
625
|
+
const ch = await resolveSendChannel(channel, opts?.threadId);
|
|
626
|
+
// SAFE allowed-mentions on EVERY send: explicit user/role pings still notify,
|
|
627
|
+
// but a stray `@everyone`/`@here` (agent text or prompt injection) can't
|
|
628
|
+
// mass-ping, and a reply won't ping the author it answers.
|
|
629
|
+
const options = { content: text, allowedMentions: safeDiscordAllowedMentions() };
|
|
630
|
+
// Native reply target — reply under the message being answered (only when not
|
|
631
|
+
// threading, since a thread send is already scoped).
|
|
632
|
+
if (opts?.replyToMessageId && !opts?.threadId) {
|
|
633
|
+
options.reply = { messageReference: opts.replyToMessageId, failIfNotExists: false };
|
|
634
|
+
}
|
|
635
|
+
const sent = await ch.send(options);
|
|
636
|
+
return { messageId: typeof sent.id === "string" ? sent.id : "" };
|
|
637
|
+
};
|
|
638
|
+
const sendInteractive = async (channel, text, rows, opts) => {
|
|
639
|
+
const ch = await resolveSendChannel(channel, opts?.threadId);
|
|
640
|
+
const components = resolvedBuilders.buildComponentRows(rows);
|
|
641
|
+
const options = { content: text, components, allowedMentions: safeDiscordAllowedMentions() };
|
|
642
|
+
if (opts?.replyToMessageId && !opts?.threadId) {
|
|
643
|
+
options.reply = { messageReference: opts.replyToMessageId, failIfNotExists: false };
|
|
644
|
+
}
|
|
645
|
+
const sent = await ch.send(options);
|
|
646
|
+
return { messageId: typeof sent.id === "string" ? sent.id : "" };
|
|
647
|
+
};
|
|
648
|
+
const sendMedia = async (channel, media, opts) => {
|
|
649
|
+
const ch = await resolveSendChannel(channel, opts?.threadId);
|
|
650
|
+
// validateOutboundMediaPath runs inside buildDiscordAttachment (throws on a
|
|
651
|
+
// refused path).
|
|
652
|
+
const att = buildDiscordAttachment(media);
|
|
653
|
+
const file = resolvedBuilders.buildAttachment(att.path, att.name);
|
|
654
|
+
const options = { files: [file], allowedMentions: safeDiscordAllowedMentions() };
|
|
655
|
+
if (att.caption)
|
|
656
|
+
options.content = att.caption;
|
|
657
|
+
await ch.send(options);
|
|
658
|
+
};
|
|
659
|
+
const fetchMessage = async (channel, messageId) => {
|
|
660
|
+
const ch = await resolveSendChannel(channel);
|
|
661
|
+
const fetchFn = ch.messages?.fetch;
|
|
662
|
+
if (typeof fetchFn !== "function")
|
|
663
|
+
throw new Error("Discord: channel cannot fetch messages");
|
|
664
|
+
const msg = await fetchFn.call(ch.messages, messageId);
|
|
665
|
+
if (!msg)
|
|
666
|
+
throw new Error(`Discord: message ${messageId} not found`);
|
|
667
|
+
return msg;
|
|
668
|
+
};
|
|
669
|
+
const editMessageText = async (channel, messageId, text) => {
|
|
670
|
+
const msg = await fetchMessage(channel, messageId);
|
|
671
|
+
await msg.edit({ content: text });
|
|
672
|
+
};
|
|
673
|
+
const deleteMessage = async (channel, messageId) => {
|
|
674
|
+
const msg = await fetchMessage(channel, messageId);
|
|
675
|
+
await msg.delete();
|
|
676
|
+
};
|
|
677
|
+
const react = async (channel, messageId, emoji) => {
|
|
678
|
+
const name = emoji.trim();
|
|
679
|
+
if (!name)
|
|
680
|
+
return;
|
|
681
|
+
try {
|
|
682
|
+
const msg = await fetchMessage(channel, messageId);
|
|
683
|
+
await msg.react(name);
|
|
684
|
+
}
|
|
685
|
+
catch (err) {
|
|
686
|
+
// Reactions are cosmetic — a missing emoji / permission never blocks.
|
|
687
|
+
safeLog("discord react failed (cosmetic)", { error: err instanceof Error ? err.message : String(err) });
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
const removeOwnReactions = async (channel, messageId) => {
|
|
691
|
+
try {
|
|
692
|
+
const msg = await fetchMessage(channel, messageId);
|
|
693
|
+
const cache = msg.reactions?.cache;
|
|
694
|
+
const list = cache
|
|
695
|
+
? cache instanceof Map
|
|
696
|
+
? [...cache.values()]
|
|
697
|
+
: [...cache]
|
|
698
|
+
: [];
|
|
699
|
+
const mine = list.filter((r) => r.me === true);
|
|
700
|
+
// Nothing cached as ours → fall back to removeAll only if no other users'
|
|
701
|
+
// reactions would be clobbered (we can't tell, so we DON'T removeAll here).
|
|
702
|
+
for (const r of mine) {
|
|
703
|
+
try {
|
|
704
|
+
if (r.users?.remove)
|
|
705
|
+
await r.users.remove(selfId ?? undefined);
|
|
706
|
+
else if (r.remove)
|
|
707
|
+
await r.remove();
|
|
708
|
+
}
|
|
709
|
+
catch (err) {
|
|
710
|
+
safeLog("discord remove own reaction failed (cosmetic)", { error: err instanceof Error ? err.message : String(err) });
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
catch (err) {
|
|
715
|
+
safeLog("discord removeOwnReactions failed (cosmetic)", { error: err instanceof Error ? err.message : String(err) });
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
const registerCommands = async (commands) => {
|
|
719
|
+
if (!commands || commands.length === 0)
|
|
720
|
+
return;
|
|
721
|
+
const c = client;
|
|
722
|
+
const appId = selfId;
|
|
723
|
+
const rest = c?.rest;
|
|
724
|
+
if (!c || !appId || !rest || typeof rest.put !== "function")
|
|
725
|
+
return;
|
|
726
|
+
try {
|
|
727
|
+
// Lazy-import Routes only on the production path; a test fake doesn't reach
|
|
728
|
+
// here (no rest.put). The application-commands route is global.
|
|
729
|
+
const discord = await import("discord.js");
|
|
730
|
+
await rest.put(discord.Routes.applicationCommands(appId), { body: commands });
|
|
731
|
+
safeLog("discord application commands registered", { count: commands.length });
|
|
732
|
+
}
|
|
733
|
+
catch (err) {
|
|
734
|
+
safeLog("discord command registration failed (best-effort)", { error: err instanceof Error ? err.message : String(err) });
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
const setComposing = async (channel, state) => {
|
|
738
|
+
// Discord shows typing for ~10s or until the next message; we only fire it on
|
|
739
|
+
// "composing" (there's no "stop typing" call). Best-effort + cosmetic.
|
|
740
|
+
if (state !== "composing")
|
|
741
|
+
return;
|
|
742
|
+
try {
|
|
743
|
+
const ch = await resolveSendChannel(channel);
|
|
744
|
+
if (typeof ch.sendTyping === "function")
|
|
745
|
+
await ch.sendTyping();
|
|
746
|
+
}
|
|
747
|
+
catch {
|
|
748
|
+
/* cosmetic — missing permission / not live: ignore */
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
const close = async () => {
|
|
752
|
+
closed = true;
|
|
753
|
+
connected = false;
|
|
754
|
+
await teardownClient();
|
|
755
|
+
try {
|
|
756
|
+
await Promise.race([
|
|
757
|
+
loopPromise ?? Promise.resolve(),
|
|
758
|
+
new Promise((resolve) => setTimeout(resolve, 5_000).unref?.()),
|
|
759
|
+
]);
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
/* loop already settled */
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
return {
|
|
766
|
+
selfId: () => selfId,
|
|
767
|
+
selfName: () => selfName,
|
|
768
|
+
connectedAt: () => connectedAtMs,
|
|
769
|
+
lastEventAt: () => lastEventAtMs,
|
|
770
|
+
isConnected: () => connected,
|
|
771
|
+
isTokenInvalid: () => tokenInvalid,
|
|
772
|
+
sendText,
|
|
773
|
+
sendInteractive,
|
|
774
|
+
sendMedia,
|
|
775
|
+
react,
|
|
776
|
+
removeOwnReactions,
|
|
777
|
+
editMessageText,
|
|
778
|
+
deleteMessage,
|
|
779
|
+
registerCommands,
|
|
780
|
+
setComposing,
|
|
781
|
+
markRead: async () => { },
|
|
782
|
+
close,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
export { DISCORD_MESSAGE_LIMIT };
|
|
786
|
+
//# sourceMappingURL=connection.js.map
|