@spinabot/brigade 1.5.0 → 1.6.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/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/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 +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/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 +5 -0
- package/dist/agents/extensions/modules/index.js.map +1 -1
- package/dist/buildstamp.json +1 -1
- package/dist/core/server.d.ts.map +1 -1
- package/dist/core/server.js +24 -5
- package/dist/core/server.js.map +1 -1
- package/package.json +4 -1
|
@@ -0,0 +1,1042 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack connection (Socket Mode inbound + Web API outbound).
|
|
3
|
+
*
|
|
4
|
+
* The Brigade analogue of `telegram/connection.ts`, distilled to Slack's two
|
|
5
|
+
* SDKs. `@slack/web-api` (`WebClient`) drives every OUTBOUND call (post / update
|
|
6
|
+
* / delete / react / upload / open-DM); `@slack/socket-mode`
|
|
7
|
+
* (`SocketModeClient`) opens the INBOUND events websocket (no public URL needed
|
|
8
|
+
* — the local-first default, analogous to Telegram long-polling). Both are
|
|
9
|
+
* lazy-imported here (`await import(...)` inside `connectSlack`) so a non-Slack
|
|
10
|
+
* boot never pays for them. Types are `type`-only so the static import never
|
|
11
|
+
* pulls the runtime in.
|
|
12
|
+
*
|
|
13
|
+
* Lifecycle:
|
|
14
|
+
* - `auth.test()` BOOTSTRAPS the connection — it both proves the bot token
|
|
15
|
+
* (an `invalid_auth` surfaces here → terminal) and caches the bot's
|
|
16
|
+
* `user_id` + `team_id` (the group ACL needs the bot's own user id to detect
|
|
17
|
+
* `<@bot>` mentions + to filter the bot's own echoes; without it group
|
|
18
|
+
* messages never reach the agent and the bot could reply to itself).
|
|
19
|
+
* - The SocketModeClient subscribes message / app_mention / reaction /
|
|
20
|
+
* interactive (block_actions) / slash_commands events. Each handler ACKs the
|
|
21
|
+
* envelope FIRST (Slack redelivers an un-acked event), then normalizes the
|
|
22
|
+
* payload into a `SlackInboundMessage` and routes it via `onMessage` /
|
|
23
|
+
* `onCallbackQuery` / `onReaction`. File bytes are downloaded via a DEFERRED
|
|
24
|
+
* `resolveMedia` thunk — only after the central access gate admits the
|
|
25
|
+
* sender (mirrors WhatsApp/Telegram).
|
|
26
|
+
* - The SocketModeClient auto-reconnects internally; we SUPERVISE the initial
|
|
27
|
+
* `.start()` with the SAME backoff curve as Telegram (2s → 30s, ×1.8, ±25%)
|
|
28
|
+
* and go terminal on an auth error (the only fix is a new token).
|
|
29
|
+
* - Events are de-duplicated by `ts` / `client_msg_id` (a redelivered envelope
|
|
30
|
+
* after a reconnect must not double-run the agent).
|
|
31
|
+
*
|
|
32
|
+
* Two transport modes share ONE normalize + dedupe + dispatch surface: Socket
|
|
33
|
+
* Mode (default) and Events API (HTTP webhook). The webhook route calls
|
|
34
|
+
* {@link SlackConnection.feedEvent} with each POSTed event, which runs the same
|
|
35
|
+
* handlers the socket uses.
|
|
36
|
+
*/
|
|
37
|
+
import { createDedupeCache, nextBackoffDelay, } from "../sdk.js";
|
|
38
|
+
import { buildSlackSenderName, expandSlackTokens, extractSlackMentions, extractSlackReplyContext, extractSlackText, hasInboundMedia, resolveInboundFiles, slackChannelType, slackThreadId, unescapeSlackEntities, } from "./inbound-extras.js";
|
|
39
|
+
import { maskProxyUrl } from "./account-config.js";
|
|
40
|
+
import { downloadSlackFile, uploadSlackFile } from "./media.js";
|
|
41
|
+
import { buildSlackProxyAgent } from "./proxy-agent.js";
|
|
42
|
+
import { createSlackUserDirectory } from "./user-directory.js";
|
|
43
|
+
/* ───────────────────────── reconnect backoff ───────────────────────── */
|
|
44
|
+
// Shares the neutral `nextBackoffDelay` curve with every other channel (see
|
|
45
|
+
// `channels/backoff.ts`), tuned to the SAME schedule WhatsApp + Telegram use
|
|
46
|
+
// (2s → 30s, ×1.8, ±25%). The constants live here so Slack owns its own knobs;
|
|
47
|
+
// the arithmetic is the shared helper's.
|
|
48
|
+
const RECONNECT_INITIAL_MS = 2_000;
|
|
49
|
+
const RECONNECT_MAX_MS = 30_000;
|
|
50
|
+
const RECONNECT_FACTOR = 1.8;
|
|
51
|
+
const RECONNECT_JITTER = 0.25;
|
|
52
|
+
const RECONNECT_MAX_ATTEMPTS = 12;
|
|
53
|
+
/**
|
|
54
|
+
* Jittered exponential backoff for reconnect attempt `attempt` (0-based). Thin
|
|
55
|
+
* wrapper over the neutral `nextBackoffDelay` helper — kept as a named export so
|
|
56
|
+
* `index.ts` and the connection tests have a stable entry point.
|
|
57
|
+
*/
|
|
58
|
+
export function slackBackoffDelay(attempt) {
|
|
59
|
+
return nextBackoffDelay({
|
|
60
|
+
attempt,
|
|
61
|
+
initialMs: RECONNECT_INITIAL_MS,
|
|
62
|
+
maxMs: RECONNECT_MAX_MS,
|
|
63
|
+
factor: RECONNECT_FACTOR,
|
|
64
|
+
jitter: RECONNECT_JITTER,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/** Slack's hard limit on a single message body is 40k; we chunk well under it. */
|
|
68
|
+
const SLACK_MESSAGE_LIMIT = 8_000;
|
|
69
|
+
/**
|
|
70
|
+
* Emoji reacted onto the user's last message as a "working…" affordance. Slack
|
|
71
|
+
* has no bot typing-indicator API, so `setComposing` emulates it with this
|
|
72
|
+
* reaction (added while the agent works, removed when idle) — same trick the
|
|
73
|
+
* reference Slack channel uses.
|
|
74
|
+
*/
|
|
75
|
+
const TYPING_REACTION = "hourglass_flowing_sand";
|
|
76
|
+
/**
|
|
77
|
+
* Thread-history backfill bounds. When the bot is @-mentioned into a
|
|
78
|
+
* pre-existing thread it has the parent ts but none of the prior text, so the
|
|
79
|
+
* first inbound on that thread fetches up to {@link THREAD_BACKFILL_LIMIT}
|
|
80
|
+
* messages via `conversations.replies` and folds the ones PRECEDING the current
|
|
81
|
+
* message into `replyTo.body` as a compact excerpt. The excerpt is hard-capped
|
|
82
|
+
* at {@link THREAD_BACKFILL_MAX_CHARS} so a long thread can't blow up the prompt
|
|
83
|
+
* (the central pipeline ALSO slices `replyTo.body` to 200, but we bound here too
|
|
84
|
+
* so we never carry more than needed). Best-effort: any fetch error is swallowed
|
|
85
|
+
* and delivery proceeds with `body` undefined.
|
|
86
|
+
*/
|
|
87
|
+
const THREAD_BACKFILL_LIMIT = 15;
|
|
88
|
+
const THREAD_BACKFILL_MAX_CHARS = 1_200;
|
|
89
|
+
/* ───────────────────────── error classification ───────────────────────── */
|
|
90
|
+
/** Pull a Slack `error` code off any thrown shape (WebClient error or raw). */
|
|
91
|
+
function errorCode(err) {
|
|
92
|
+
if (!err || typeof err !== "object")
|
|
93
|
+
return "";
|
|
94
|
+
const e = err;
|
|
95
|
+
return e.data?.error ?? e.error ?? "";
|
|
96
|
+
}
|
|
97
|
+
/** Description / message text off any thrown shape. */
|
|
98
|
+
function errorText(err) {
|
|
99
|
+
if (!err)
|
|
100
|
+
return "";
|
|
101
|
+
if (typeof err === "string")
|
|
102
|
+
return err;
|
|
103
|
+
const e = err;
|
|
104
|
+
return e.data?.error ?? e.message ?? String(err);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The non-recoverable Slack auth error codes — the token is wrong / revoked /
|
|
108
|
+
* expired / the app was uninstalled or lost a required scope. Re-tokening is the
|
|
109
|
+
* only fix; reconnecting with the same token loops forever. Mirrors the
|
|
110
|
+
* reference Slack channel's terminal set.
|
|
111
|
+
*/
|
|
112
|
+
const SLACK_UNAUTHORIZED_CODES = [
|
|
113
|
+
"invalid_auth",
|
|
114
|
+
"not_authed",
|
|
115
|
+
"account_inactive",
|
|
116
|
+
"token_revoked",
|
|
117
|
+
"token_expired",
|
|
118
|
+
"invalid_token",
|
|
119
|
+
"org_login_required",
|
|
120
|
+
"missing_scope",
|
|
121
|
+
];
|
|
122
|
+
/** An auth failure → the token is wrong / revoked / expired / scope-stripped (terminal). */
|
|
123
|
+
export function isSlackUnauthorized(err) {
|
|
124
|
+
const code = errorCode(err);
|
|
125
|
+
if (code && SLACK_UNAUTHORIZED_CODES.includes(code))
|
|
126
|
+
return true;
|
|
127
|
+
// SocketModeClient throws an UnrecoverableSocketModeStartError on a bad app token.
|
|
128
|
+
const name = err?.name ?? "";
|
|
129
|
+
if (/UnrecoverableSocketModeStartError/i.test(name))
|
|
130
|
+
return true;
|
|
131
|
+
const pattern = new RegExp(SLACK_UNAUTHORIZED_CODES.join("|"), "i");
|
|
132
|
+
return pattern.test(errorText(err));
|
|
133
|
+
}
|
|
134
|
+
/** Strip a Slack token (`xoxb-…`/`xapp-…`/`xoxp-…`) out of a string before it logs. */
|
|
135
|
+
export function redactSlackToken(text, ...tokens) {
|
|
136
|
+
if (!text)
|
|
137
|
+
return text;
|
|
138
|
+
let out = text;
|
|
139
|
+
for (const token of tokens) {
|
|
140
|
+
if (token)
|
|
141
|
+
out = out.split(token).join("<redacted>");
|
|
142
|
+
}
|
|
143
|
+
// Catch any `xox?-…` / `xapp-…` fragment even if the exact token differs.
|
|
144
|
+
out = out.replace(/x(?:ox[bpoa]|app)-[A-Za-z0-9-]{6,}/g, "<redacted>");
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
/** Convert a Slack message `ts` ("1700000000.000200") to epoch ms. */
|
|
148
|
+
function tsToEpochMs(ts) {
|
|
149
|
+
if (typeof ts !== "string")
|
|
150
|
+
return undefined;
|
|
151
|
+
const secs = Number.parseFloat(ts);
|
|
152
|
+
return Number.isFinite(secs) && secs > 0 ? Math.round(secs * 1000) : undefined;
|
|
153
|
+
}
|
|
154
|
+
/* ───────────────────────── the connection ───────────────────────── */
|
|
155
|
+
export async function connectSlack(args) {
|
|
156
|
+
const accountId = args.accountId ?? "default";
|
|
157
|
+
const mode = args.mode === "events" ? "events" : "socket";
|
|
158
|
+
const sleep = args.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms).unref?.()));
|
|
159
|
+
const safeLog = (msg, meta) => {
|
|
160
|
+
// Defensively redact both tokens from any message + string meta values.
|
|
161
|
+
const redactedMsg = redactSlackToken(msg, args.botToken, args.appToken ?? "");
|
|
162
|
+
if (!meta)
|
|
163
|
+
return args.log(redactedMsg);
|
|
164
|
+
const redactedMeta = {};
|
|
165
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
166
|
+
redactedMeta[k] = typeof v === "string" ? redactSlackToken(v, args.botToken, args.appToken ?? "") : v;
|
|
167
|
+
}
|
|
168
|
+
args.log(redactedMsg, redactedMeta);
|
|
169
|
+
};
|
|
170
|
+
// ── resolve proxy (optional) ──
|
|
171
|
+
// A configured proxy reroutes EVERY Slack API call (auth.test / sends) + the
|
|
172
|
+
// Socket Mode websocket through the proxy — the fix for networks where
|
|
173
|
+
// `slack.com` is blocked. Both Slack SDKs accept a Node `http.Agent`; we build
|
|
174
|
+
// the matching one (http(s) → https-proxy-agent, socks → socks-proxy-agent)
|
|
175
|
+
// and hand it to the client constructors. No proxy → the agent stays undefined
|
|
176
|
+
// and the clients are built exactly as before.
|
|
177
|
+
const proxyUrl = (args.proxyUrl ?? "").trim();
|
|
178
|
+
let proxyAgent;
|
|
179
|
+
if (proxyUrl) {
|
|
180
|
+
const buildAgent = args.proxyAgentFactory ?? buildSlackProxyAgent;
|
|
181
|
+
try {
|
|
182
|
+
proxyAgent = await buildAgent(proxyUrl);
|
|
183
|
+
safeLog("slack routing through proxy", { account: accountId, proxy: maskProxyUrl(proxyUrl) });
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
// A malformed proxy URL / missing module must not wedge the channel.
|
|
187
|
+
safeLog("slack proxy setup failed — connecting directly", {
|
|
188
|
+
account: accountId,
|
|
189
|
+
proxy: maskProxyUrl(proxyUrl),
|
|
190
|
+
error: err instanceof Error ? err.message : String(err),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// ── lazy-load the Slack SDKs (production path only) ──
|
|
195
|
+
let buildWebClient;
|
|
196
|
+
let buildSocket;
|
|
197
|
+
if (args.webClientFactory) {
|
|
198
|
+
const factory = args.webClientFactory;
|
|
199
|
+
buildWebClient = (botToken) => factory(botToken, proxyAgent);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
const { WebClient } = await import("@slack/web-api");
|
|
203
|
+
buildWebClient = (botToken) => (proxyAgent
|
|
204
|
+
? new WebClient(botToken, { agent: proxyAgent })
|
|
205
|
+
: new WebClient(botToken));
|
|
206
|
+
}
|
|
207
|
+
if (mode === "socket") {
|
|
208
|
+
if (args.socketModeFactory) {
|
|
209
|
+
const factory = args.socketModeFactory;
|
|
210
|
+
buildSocket = (appToken) => factory(appToken, proxyAgent);
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
const { SocketModeClient } = await import("@slack/socket-mode");
|
|
214
|
+
buildSocket = (appToken) => (proxyAgent
|
|
215
|
+
? new SocketModeClient({ appToken, clientOptions: { agent: proxyAgent } })
|
|
216
|
+
: new SocketModeClient({ appToken }));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
buildSocket = null;
|
|
221
|
+
}
|
|
222
|
+
// ── connection state ──
|
|
223
|
+
let selfId = null;
|
|
224
|
+
let selfName = null;
|
|
225
|
+
let teamIdValue = null;
|
|
226
|
+
let connectedAtMs = null;
|
|
227
|
+
// Epoch ms of the most recent inbound event of any kind (liveness signal —
|
|
228
|
+
// see SlackConnection.lastEventAt). Stamped at the entry of every inbound
|
|
229
|
+
// handler so it covers BOTH the socket and the webhook (feedEvent) paths.
|
|
230
|
+
let lastEventAtMs = null;
|
|
231
|
+
const stampInboundEvent = () => {
|
|
232
|
+
lastEventAtMs = Date.now();
|
|
233
|
+
};
|
|
234
|
+
let connected = false;
|
|
235
|
+
let tokenInvalid = false;
|
|
236
|
+
let closed = false;
|
|
237
|
+
let reconnectAttempts = 0;
|
|
238
|
+
let web = null;
|
|
239
|
+
let socket = null;
|
|
240
|
+
let loopPromise = null;
|
|
241
|
+
// Dedupe inbound events by `ts` / `client_msg_id` — a redelivered envelope
|
|
242
|
+
// after a reconnect must not double-run the agent. Per-connection lifetime.
|
|
243
|
+
const eventDedupe = createDedupeCache({ maxEntries: 10_000, ttlMs: 60 * 60 * 1_000 });
|
|
244
|
+
// Last inbound message `ts` per channel — the target the typing affordance
|
|
245
|
+
// reacts to. Updated when a user message routes; read by `setComposing`.
|
|
246
|
+
const lastInboundTs = new Map();
|
|
247
|
+
// Threads whose history has already been backfilled into `replyTo.body`
|
|
248
|
+
// (keyed `channel:thread_ts`). The FIRST reply the bot sees on a thread it
|
|
249
|
+
// was mentioned into pulls the prior messages; subsequent replies on the
|
|
250
|
+
// SAME thread skip the fetch (the agent already has — or will accumulate —
|
|
251
|
+
// the context via the session transcript). Per-connection lifetime, same
|
|
252
|
+
// posture as `lastInboundTs`.
|
|
253
|
+
const backfilledThreads = new Set();
|
|
254
|
+
// Background id→display-name directory (built lazily once `web` is live, in
|
|
255
|
+
// BOTH socket + events mode). Resolves Slack user ids to human names so the
|
|
256
|
+
// agent sees "Alex" instead of "U07ABC" in the sender name, `<@…>` mentions,
|
|
257
|
+
// and reaction notes. Non-blocking: prime() warms the cache off the hot path,
|
|
258
|
+
// resolveNameSync() reads whatever is cached (see user-directory.ts).
|
|
259
|
+
let userDirectory = null;
|
|
260
|
+
const ensureDirectory = () => {
|
|
261
|
+
if (!userDirectory && web)
|
|
262
|
+
userDirectory = createSlackUserDirectory({ web, log: safeLog });
|
|
263
|
+
return userDirectory;
|
|
264
|
+
};
|
|
265
|
+
/** Normalize one Slack message event into the deferred-media inbound shape. */
|
|
266
|
+
const normalize = (event, teamId, opts) => {
|
|
267
|
+
// An edit (message_changed) carries the new message under `message`; the
|
|
268
|
+
// channel + timestamps live on the OUTER envelope.
|
|
269
|
+
const inner = event.subtype === "message_changed" && event.message ? event.message : event;
|
|
270
|
+
const channel = typeof event.channel === "string" ? event.channel : typeof inner.channel === "string" ? inner.channel : "";
|
|
271
|
+
if (!channel)
|
|
272
|
+
return null;
|
|
273
|
+
// Background id→name directory: read whatever is cached for THIS message,
|
|
274
|
+
// then prime the sender + everyone they @-mentioned so the names resolve
|
|
275
|
+
// next time (first contact shows the id; later messages show the name).
|
|
276
|
+
const dir = ensureDirectory();
|
|
277
|
+
const resolveName = dir ? (id) => dir.resolveNameSync(id) : undefined;
|
|
278
|
+
const text = extractSlackText(event, resolveName);
|
|
279
|
+
const chatType = slackChannelType({ channel_type: event.channel_type, channel });
|
|
280
|
+
const threadId = slackThreadId(inner);
|
|
281
|
+
const mentions = extractSlackMentions(event, selfId ?? undefined);
|
|
282
|
+
const replyTo = extractSlackReplyContext(event);
|
|
283
|
+
const fromName = buildSlackSenderName(event, resolveName);
|
|
284
|
+
const fromId = typeof inner.user === "string" ? inner.user : typeof inner.bot_id === "string" ? inner.bot_id : channel;
|
|
285
|
+
if (dir) {
|
|
286
|
+
dir.prime(typeof inner.user === "string" ? inner.user : undefined);
|
|
287
|
+
for (const id of mentions)
|
|
288
|
+
dir.prime(id);
|
|
289
|
+
}
|
|
290
|
+
const ts = typeof inner.ts === "string" ? inner.ts : typeof event.ts === "string" ? event.ts : undefined;
|
|
291
|
+
// DEFERRED media — captured by reference, not downloaded. The thunk is only
|
|
292
|
+
// invoked by the pipeline after the access gate admits the sender.
|
|
293
|
+
const carriesMedia = hasInboundMedia(event);
|
|
294
|
+
const resolveMedia = carriesMedia
|
|
295
|
+
? async () => {
|
|
296
|
+
const files = resolveInboundFiles(event);
|
|
297
|
+
if (files.length === 0)
|
|
298
|
+
return [];
|
|
299
|
+
const out = [];
|
|
300
|
+
for (const file of files) {
|
|
301
|
+
const att = await downloadSlackFile({ file, token: args.botToken, log: safeLog });
|
|
302
|
+
if (att)
|
|
303
|
+
out.push(att);
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
: undefined;
|
|
308
|
+
return {
|
|
309
|
+
conversationId: channel,
|
|
310
|
+
...(ts ? { messageId: ts } : {}),
|
|
311
|
+
...(tsToEpochMs(ts) !== undefined ? { messageTimestampMs: tsToEpochMs(ts) } : {}),
|
|
312
|
+
from: fromId,
|
|
313
|
+
...(fromName ? { fromName } : {}),
|
|
314
|
+
text,
|
|
315
|
+
chatType,
|
|
316
|
+
...(teamId ? { teamId } : {}),
|
|
317
|
+
...(threadId ? { threadId } : {}),
|
|
318
|
+
...(mentions.length > 0 ? { mentions } : {}),
|
|
319
|
+
...(replyTo ? { replyTo } : {}),
|
|
320
|
+
...(opts?.edited ? { edited: true } : {}),
|
|
321
|
+
...(resolveMedia ? { resolveMedia } : {}),
|
|
322
|
+
raw: event,
|
|
323
|
+
};
|
|
324
|
+
};
|
|
325
|
+
/**
|
|
326
|
+
* A stable dedupe key for a message event, keyed on CHANNEL + ts (NOT
|
|
327
|
+
* client_msg_id). A channel @-mention is delivered TWICE — once as a `message`
|
|
328
|
+
* event (carries `client_msg_id`) and once as an `app_mention` event (no
|
|
329
|
+
* client_msg_id, only `ts`). Keying on client_msg_id gave the two events
|
|
330
|
+
* different keys, so the agent ran/replied/billed twice. Keying on
|
|
331
|
+
* `channel:ts` collapses them (both share the same channel + ts). For an edit
|
|
332
|
+
* the per-edit `edited.ts` is folded in so a SECOND edit of the same message
|
|
333
|
+
* (same `ts`, new `edited.ts`) still routes instead of being dropped.
|
|
334
|
+
*/
|
|
335
|
+
const messageDedupeKey = (event) => {
|
|
336
|
+
const inner = event.subtype === "message_changed" && event.message ? event.message : event;
|
|
337
|
+
const channel = typeof event.channel === "string" ? event.channel : typeof inner.channel === "string" ? inner.channel : "";
|
|
338
|
+
const ts = typeof inner.ts === "string" ? inner.ts : typeof event.ts === "string" ? event.ts : "";
|
|
339
|
+
if (!ts)
|
|
340
|
+
return undefined;
|
|
341
|
+
if (event.subtype === "message_changed") {
|
|
342
|
+
const editTs = typeof inner.edited?.ts === "string" ? inner.edited.ts : "";
|
|
343
|
+
return `edit:${channel}:${ts}:${editTs}`;
|
|
344
|
+
}
|
|
345
|
+
return `${channel}:${ts}`;
|
|
346
|
+
};
|
|
347
|
+
/** Is this event one the bot itself authored (its own echo)? */
|
|
348
|
+
const isSelfAuthored = (event) => {
|
|
349
|
+
const inner = event.subtype === "message_changed" && event.message ? event.message : event;
|
|
350
|
+
if (selfId && inner.user === selfId)
|
|
351
|
+
return true;
|
|
352
|
+
// A bot-posted message (our own outbound) carries bot_id and no user.
|
|
353
|
+
if (inner.bot_id && !inner.user)
|
|
354
|
+
return true;
|
|
355
|
+
return false;
|
|
356
|
+
};
|
|
357
|
+
/**
|
|
358
|
+
* Fetch the prior messages of `threadId` and format the ones PRECEDING
|
|
359
|
+
* `currentTs` into a compact excerpt for `replyTo.body`. Best-effort: returns
|
|
360
|
+
* undefined on any failure (no `conversations.replies` slice, fetch error,
|
|
361
|
+
* empty thread) so the caller always proceeds to deliver. Each line is
|
|
362
|
+
* `name: text` — the sender name resolved from the background user directory
|
|
363
|
+
* when cached (else the raw id), the text token-expanded the same way inbound
|
|
364
|
+
* text is. The bot's own messages are kept (they're part of the thread the
|
|
365
|
+
* agent walked into). Bounded to {@link THREAD_BACKFILL_MAX_CHARS}.
|
|
366
|
+
*/
|
|
367
|
+
const fetchThreadExcerpt = async (channel, threadId, currentTs) => {
|
|
368
|
+
const w = web;
|
|
369
|
+
const repliesFn = w?.conversations.replies;
|
|
370
|
+
if (!w || typeof repliesFn !== "function")
|
|
371
|
+
return undefined;
|
|
372
|
+
let res;
|
|
373
|
+
try {
|
|
374
|
+
res = await repliesFn.call(w.conversations, { channel, ts: threadId, limit: THREAD_BACKFILL_LIMIT });
|
|
375
|
+
}
|
|
376
|
+
catch (err) {
|
|
377
|
+
safeLog("slack thread backfill failed (best-effort)", {
|
|
378
|
+
error: err instanceof Error ? err.message : String(err),
|
|
379
|
+
});
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
if (!res?.ok || !Array.isArray(res.messages) || res.messages.length === 0)
|
|
383
|
+
return undefined;
|
|
384
|
+
const dir = ensureDirectory();
|
|
385
|
+
const lines = [];
|
|
386
|
+
for (const m of res.messages) {
|
|
387
|
+
// Only messages BEFORE the one we're routing — the current message is the
|
|
388
|
+
// agent's input, not prior context. A reply event always carries a ts, so
|
|
389
|
+
// when currentTs is set we stop at it; missing ts entries are skipped.
|
|
390
|
+
const mts = typeof m.ts === "string" ? m.ts : "";
|
|
391
|
+
if (currentTs && mts && mts >= currentTs)
|
|
392
|
+
continue;
|
|
393
|
+
const rawText = typeof m.text === "string" ? m.text : "";
|
|
394
|
+
if (!rawText)
|
|
395
|
+
continue;
|
|
396
|
+
const resolveName = dir ? (id) => dir.resolveNameSync(id) : undefined;
|
|
397
|
+
const text = unescapeSlackEntities(expandSlackTokens(rawText, resolveName)).replace(/\s+/g, " ").trim();
|
|
398
|
+
if (!text)
|
|
399
|
+
continue;
|
|
400
|
+
const userId = typeof m.user === "string" ? m.user : "";
|
|
401
|
+
if (dir && userId)
|
|
402
|
+
dir.prime(userId);
|
|
403
|
+
const name = (userId && dir ? dir.resolveNameSync(userId) : undefined) ||
|
|
404
|
+
userId ||
|
|
405
|
+
(typeof m.bot_id === "string" ? m.bot_id : "") ||
|
|
406
|
+
"unknown";
|
|
407
|
+
lines.push(`${name}: ${text}`);
|
|
408
|
+
}
|
|
409
|
+
if (lines.length === 0)
|
|
410
|
+
return undefined;
|
|
411
|
+
let excerpt = lines.join("\n");
|
|
412
|
+
if (excerpt.length > THREAD_BACKFILL_MAX_CHARS) {
|
|
413
|
+
// Keep the TAIL (most recent context) — trim from the front.
|
|
414
|
+
excerpt = `…${excerpt.slice(excerpt.length - THREAD_BACKFILL_MAX_CHARS)}`;
|
|
415
|
+
}
|
|
416
|
+
return excerpt;
|
|
417
|
+
};
|
|
418
|
+
/**
|
|
419
|
+
* Handle a `message` / `app_mention` event. ACK is done by the socket handler
|
|
420
|
+
* BEFORE this runs. Filters the bot's own echoes + system subtypes, dedupes,
|
|
421
|
+
* normalizes, and routes through `onMessage` (an edit flagged `edited`).
|
|
422
|
+
*
|
|
423
|
+
* ASYNC because of the thread-history backfill: `normalize` stays SYNC, but
|
|
424
|
+
* when the inbound is a threaded reply (a thread we haven't backfilled yet)
|
|
425
|
+
* this fetches the prior thread messages via `conversations.replies` and folds
|
|
426
|
+
* them into `normalized.replyTo.body` BEFORE handing off to `onMessage`, so the
|
|
427
|
+
* agent sees the conversation it was just @-mentioned into. Runs PRE-central-ACL
|
|
428
|
+
* (same posture as the reference channel); the fetch uses the bot token on a
|
|
429
|
+
* thread the bot is already in, and is fully best-effort + bounded so it can
|
|
430
|
+
* never block (or fail) delivery.
|
|
431
|
+
*/
|
|
432
|
+
const handleMessageEvent = async (event, teamId) => {
|
|
433
|
+
try {
|
|
434
|
+
// Liveness: any inbound message event proves the connection is alive,
|
|
435
|
+
// even one we ultimately skip (echo / system subtype).
|
|
436
|
+
stampInboundEvent();
|
|
437
|
+
// message_deleted carries no routable content (the agent can't act on a
|
|
438
|
+
// vanished message); log-free skip.
|
|
439
|
+
if (event.subtype === "message_deleted")
|
|
440
|
+
return;
|
|
441
|
+
// Skip the bot's own messages (echoes) — a bot must never reply to itself.
|
|
442
|
+
if (isSelfAuthored(event))
|
|
443
|
+
return;
|
|
444
|
+
// Skip system / bot-integration subtypes that aren't user messages
|
|
445
|
+
// (channel_join, bot_message, etc.) — but ALLOW message_changed (an edit).
|
|
446
|
+
const subtype = event.subtype;
|
|
447
|
+
if (subtype && subtype !== "message_changed" && subtype !== "file_share" && subtype !== "thread_broadcast") {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const dedupeKey = messageDedupeKey(event);
|
|
451
|
+
if (dedupeKey && !eventDedupe.claim(dedupeKey))
|
|
452
|
+
return; // already seen
|
|
453
|
+
const normalized = normalize(event, teamId, { edited: subtype === "message_changed" });
|
|
454
|
+
if (!normalized)
|
|
455
|
+
return;
|
|
456
|
+
// Thread-history backfill — only when this is a threaded reply that quotes
|
|
457
|
+
// a parent (replyTo present) AND we haven't already backfilled this thread.
|
|
458
|
+
// `extractSlackReplyContext` leaves `body` undefined; we fill it with a
|
|
459
|
+
// compact excerpt of the prior messages so the agent has the prior context.
|
|
460
|
+
if (normalized.threadId && normalized.replyTo && !normalized.replyTo.body) {
|
|
461
|
+
const threadKey = `${normalized.conversationId}:${normalized.threadId}`;
|
|
462
|
+
if (!backfilledThreads.has(threadKey)) {
|
|
463
|
+
backfilledThreads.add(threadKey);
|
|
464
|
+
const excerpt = await fetchThreadExcerpt(normalized.conversationId, normalized.threadId, normalized.messageId);
|
|
465
|
+
if (excerpt)
|
|
466
|
+
normalized.replyTo = { ...normalized.replyTo, body: excerpt };
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
args.onMessage(normalized);
|
|
470
|
+
// Remember the user's last message ts so setComposing can react to it.
|
|
471
|
+
if (normalized.messageId)
|
|
472
|
+
lastInboundTs.set(normalized.conversationId, normalized.messageId);
|
|
473
|
+
}
|
|
474
|
+
catch (err) {
|
|
475
|
+
safeLog("slack inbound handler error", { error: err instanceof Error ? err.message : String(err) });
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
/**
|
|
479
|
+
* Normalize a `reaction_added` event into the inbound shape. Surfaces the
|
|
480
|
+
* single added emoji, the actor, and the target message ts. Reactions carry no
|
|
481
|
+
* text. Returns null when the reaction wasn't on a message or was self-authored.
|
|
482
|
+
*/
|
|
483
|
+
const normalizeReaction = (event, teamId) => {
|
|
484
|
+
const item = event.item;
|
|
485
|
+
if (!item || item.type !== "message")
|
|
486
|
+
return null;
|
|
487
|
+
const channel = typeof item.channel === "string" ? item.channel : "";
|
|
488
|
+
const target = typeof item.ts === "string" ? item.ts : "";
|
|
489
|
+
const emoji = typeof event.reaction === "string" ? event.reaction : "";
|
|
490
|
+
if (!channel || !target || !emoji)
|
|
491
|
+
return null;
|
|
492
|
+
const fromId = typeof event.user === "string" ? event.user : channel;
|
|
493
|
+
if (selfId && fromId === selfId)
|
|
494
|
+
return null; // the bot's own reaction
|
|
495
|
+
// Resolve the reactor's display name (background-primed) so the synthesized
|
|
496
|
+
// note reads "Alex reacted :+1:" instead of the raw id.
|
|
497
|
+
const dir = ensureDirectory();
|
|
498
|
+
if (dir && typeof event.user === "string")
|
|
499
|
+
dir.prime(event.user);
|
|
500
|
+
const fromName = dir && typeof event.user === "string" ? dir.resolveNameSync(event.user) : undefined;
|
|
501
|
+
return {
|
|
502
|
+
conversationId: channel,
|
|
503
|
+
from: fromId,
|
|
504
|
+
...(fromName ? { fromName } : {}),
|
|
505
|
+
text: "",
|
|
506
|
+
// A reaction event doesn't carry channel_type; infer from the id prefix.
|
|
507
|
+
chatType: channel.startsWith("D") ? "direct" : "group",
|
|
508
|
+
...(teamId ? { teamId } : {}),
|
|
509
|
+
reaction: { emojis: [emoji], targetMessageId: target },
|
|
510
|
+
raw: event,
|
|
511
|
+
};
|
|
512
|
+
};
|
|
513
|
+
/** Handle a `reaction_added` event → normalize + route through `onReaction`. */
|
|
514
|
+
const handleReactionEvent = (event, teamId) => {
|
|
515
|
+
try {
|
|
516
|
+
stampInboundEvent();
|
|
517
|
+
// Dedupe on actor+emoji+target so a redelivery doesn't double-route.
|
|
518
|
+
const key = `react:${event.user}:${event.reaction}:${event.item?.ts}`;
|
|
519
|
+
if (!eventDedupe.claim(key))
|
|
520
|
+
return;
|
|
521
|
+
const normalized = normalizeReaction(event, teamId);
|
|
522
|
+
if (!normalized)
|
|
523
|
+
return;
|
|
524
|
+
args.onReaction?.(normalized);
|
|
525
|
+
}
|
|
526
|
+
catch (err) {
|
|
527
|
+
safeLog("slack reaction handler error", { error: err instanceof Error ? err.message : String(err) });
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
/**
|
|
531
|
+
* Normalize a `block_actions` interactive press into the inbound shape the
|
|
532
|
+
* central pipeline routes to the approval-callback path. The pressed button's
|
|
533
|
+
* opaque `value` rides on `callbackQuery.data`; `conversationId` / `from` /
|
|
534
|
+
* `threadId` come from the interaction payload so the pending-approval lookup
|
|
535
|
+
* keys on the SAME peer the prompt was sent to.
|
|
536
|
+
*/
|
|
537
|
+
const normalizeInteractive = (payload) => {
|
|
538
|
+
if (payload.type !== "block_actions")
|
|
539
|
+
return null;
|
|
540
|
+
const actions = Array.isArray(payload.actions) ? payload.actions : [];
|
|
541
|
+
// Pull the first action carrying a value — the central pipeline decodes it.
|
|
542
|
+
const value = actions.map((a) => (typeof a?.value === "string" ? a.value : "")).find((v) => v) ?? "";
|
|
543
|
+
if (!value)
|
|
544
|
+
return null;
|
|
545
|
+
const channel = typeof payload.channel?.id === "string" ? payload.channel.id : "";
|
|
546
|
+
const fromId = typeof payload.user?.id === "string" ? payload.user.id : channel;
|
|
547
|
+
if (!channel && !fromId)
|
|
548
|
+
return null;
|
|
549
|
+
const threadId = typeof payload.message?.thread_ts === "string" ? payload.message.thread_ts : undefined;
|
|
550
|
+
const fromName = payload.user?.username ?? payload.user?.name;
|
|
551
|
+
return {
|
|
552
|
+
conversationId: channel || fromId,
|
|
553
|
+
from: fromId,
|
|
554
|
+
...(fromName ? { fromName } : {}),
|
|
555
|
+
text: "",
|
|
556
|
+
chatType: channel.startsWith("D") ? "direct" : "group",
|
|
557
|
+
...(typeof payload.team?.id === "string" ? { teamId: payload.team.id } : {}),
|
|
558
|
+
...(threadId ? { threadId } : {}),
|
|
559
|
+
callbackQuery: { data: value, callbackId: payload.message?.ts ?? "" },
|
|
560
|
+
raw: payload,
|
|
561
|
+
};
|
|
562
|
+
};
|
|
563
|
+
/** Handle a `block_actions` interaction → normalize + route through `onCallbackQuery`. */
|
|
564
|
+
const handleInteractive = (payload) => {
|
|
565
|
+
try {
|
|
566
|
+
stampInboundEvent();
|
|
567
|
+
const normalized = normalizeInteractive(payload);
|
|
568
|
+
if (!normalized)
|
|
569
|
+
return;
|
|
570
|
+
args.onCallbackQuery?.(normalized);
|
|
571
|
+
}
|
|
572
|
+
catch (err) {
|
|
573
|
+
safeLog("slack interactive handler error", { error: err instanceof Error ? err.message : String(err) });
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
/**
|
|
577
|
+
* Handle a `slash_commands` event → route as an ordinary inbound message so
|
|
578
|
+
* the central command map (`/help`, `/status`, …) handles it. The command +
|
|
579
|
+
* its args are joined into the text (`/status foo` → `/status foo`). Slack
|
|
580
|
+
* already acked the slash command in the socket handler.
|
|
581
|
+
*/
|
|
582
|
+
const handleSlashCommand = (payload) => {
|
|
583
|
+
try {
|
|
584
|
+
stampInboundEvent();
|
|
585
|
+
const command = typeof payload.command === "string" ? payload.command : "";
|
|
586
|
+
const text = typeof payload.text === "string" ? payload.text : "";
|
|
587
|
+
const channel = typeof payload.channel_id === "string" ? payload.channel_id : "";
|
|
588
|
+
const fromId = typeof payload.user_id === "string" ? payload.user_id : channel;
|
|
589
|
+
if (!command || !channel)
|
|
590
|
+
return;
|
|
591
|
+
const body = text ? `${command} ${text}` : command;
|
|
592
|
+
args.onMessage({
|
|
593
|
+
conversationId: channel,
|
|
594
|
+
from: fromId,
|
|
595
|
+
...(payload.user_name ? { fromName: payload.user_name } : {}),
|
|
596
|
+
text: body,
|
|
597
|
+
chatType: channel.startsWith("D") ? "direct" : "group",
|
|
598
|
+
...(typeof payload.team_id === "string" ? { teamId: payload.team_id } : {}),
|
|
599
|
+
raw: payload,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
catch (err) {
|
|
603
|
+
safeLog("slack slash handler error", { error: err instanceof Error ? err.message : String(err) });
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
/* ── socket event wiring ── */
|
|
607
|
+
/** Subscribe the SocketModeClient to the events Brigade consumes. */
|
|
608
|
+
const wireSocket = (s) => {
|
|
609
|
+
// events_api events are emitted under their event TYPE name (see
|
|
610
|
+
// SocketModeClient.onWebSocketMessage). ACK FIRST — Slack redelivers an
|
|
611
|
+
// un-acked envelope — then route. The team id rides on the envelope `body`.
|
|
612
|
+
const onEvent = async (a) => {
|
|
613
|
+
try {
|
|
614
|
+
await a.ack();
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
/* ack is best-effort; routing still proceeds */
|
|
618
|
+
}
|
|
619
|
+
const teamId = typeof a.body?.team_id === "string" ? a.body.team_id : undefined;
|
|
620
|
+
const event = (a.event ?? a.body?.event);
|
|
621
|
+
if (!event)
|
|
622
|
+
return;
|
|
623
|
+
await handleMessageEvent(event, teamId);
|
|
624
|
+
};
|
|
625
|
+
const onReaction = async (a) => {
|
|
626
|
+
try {
|
|
627
|
+
await a.ack();
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
/* best-effort */
|
|
631
|
+
}
|
|
632
|
+
const teamId = typeof a.body?.team_id === "string" ? a.body.team_id : undefined;
|
|
633
|
+
const event = (a.event ?? a.body?.event);
|
|
634
|
+
if (event)
|
|
635
|
+
handleReactionEvent(event, teamId);
|
|
636
|
+
};
|
|
637
|
+
s.on("message", (a) => void onEvent(a));
|
|
638
|
+
s.on("app_mention", (a) => void onEvent(a));
|
|
639
|
+
s.on("reaction_added", (a) => void onReaction(a));
|
|
640
|
+
s.on("reaction_removed", (a) => {
|
|
641
|
+
// A removal isn't routed (nothing to act on) but is acked so Slack stops
|
|
642
|
+
// redelivering it. CRUCIALLY we also RELEASE the add-dedupe key so a later
|
|
643
|
+
// re-add of the same emoji by the same user (add→remove→add) re-claims and
|
|
644
|
+
// routes — without this the re-add is silently dropped as a "redelivery".
|
|
645
|
+
stampInboundEvent(); // liveness: a removal is still inbound traffic
|
|
646
|
+
const args2 = a;
|
|
647
|
+
void args2.ack?.().catch(() => { });
|
|
648
|
+
const event = (args2.event ?? args2.body?.event);
|
|
649
|
+
if (event) {
|
|
650
|
+
eventDedupe.release(`react:${event.user}:${event.reaction}:${event.item?.ts}`);
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
s.on("interactive", (a) => {
|
|
654
|
+
const args2 = a;
|
|
655
|
+
void args2.ack?.().catch(() => { });
|
|
656
|
+
if (args2.body)
|
|
657
|
+
handleInteractive(args2.body);
|
|
658
|
+
});
|
|
659
|
+
s.on("slash_commands", (a) => {
|
|
660
|
+
const args2 = a;
|
|
661
|
+
// Slash commands ack with an empty body (or a response) to clear the
|
|
662
|
+
// client spinner; we ack empty + route the command through the pipeline.
|
|
663
|
+
void args2.ack?.().catch(() => { });
|
|
664
|
+
if (args2.body)
|
|
665
|
+
handleSlashCommand(args2.body);
|
|
666
|
+
});
|
|
667
|
+
// Terminal-auth awareness mid-session. A token revoked AFTER connect surfaces
|
|
668
|
+
// on `error` / `unable_to_socket_mode_start` (NOT reliably on `disconnected`,
|
|
669
|
+
// which @slack/socket-mode emits with NO argument — so the old
|
|
670
|
+
// disconnected-only hook was dead and a revoked token left health stuck at
|
|
671
|
+
// "disconnected" forever). We bind all three and mark the token invalid on
|
|
672
|
+
// any auth-class error so health flips to "logged-out" and the operator is
|
|
673
|
+
// prompted to re-token.
|
|
674
|
+
const markTokenInvalidIfAuth = (err, where) => {
|
|
675
|
+
if (!isSlackUnauthorized(err))
|
|
676
|
+
return;
|
|
677
|
+
if (tokenInvalid)
|
|
678
|
+
return; // already terminal — don't re-fire
|
|
679
|
+
tokenInvalid = true;
|
|
680
|
+
connected = false;
|
|
681
|
+
safeLog(`slack ${where} — token rejected; re-token required`);
|
|
682
|
+
args.onTokenInvalid?.();
|
|
683
|
+
};
|
|
684
|
+
s.on("disconnected", (err) => markTokenInvalidIfAuth(err, "socket disconnected"));
|
|
685
|
+
s.on("error", (e) => markTokenInvalidIfAuth(e, "socket error"));
|
|
686
|
+
s.on("unable_to_socket_mode_start", (e) => markTokenInvalidIfAuth(e, "unable to start socket mode"));
|
|
687
|
+
};
|
|
688
|
+
/* ── bootstrap + supervise ── */
|
|
689
|
+
/** Run `auth.test`, cache identity, build + start the socket. */
|
|
690
|
+
const startOnce = async () => {
|
|
691
|
+
const w = buildWebClient(args.botToken);
|
|
692
|
+
web = w;
|
|
693
|
+
// auth.test first — both proves the bot token (invalid_auth surfaces here)
|
|
694
|
+
// and caches the bot user id + team id the group ACL + echo filter need.
|
|
695
|
+
const auth = await w.auth.test();
|
|
696
|
+
if (!auth?.ok) {
|
|
697
|
+
const code = auth?.error ?? "auth_failed";
|
|
698
|
+
const err = new Error(code);
|
|
699
|
+
err.data = { error: code };
|
|
700
|
+
throw err;
|
|
701
|
+
}
|
|
702
|
+
selfId = typeof auth.user_id === "string" ? auth.user_id : null;
|
|
703
|
+
selfName = typeof auth.user === "string" ? auth.user : null;
|
|
704
|
+
teamIdValue = typeof auth.team_id === "string" ? auth.team_id : null;
|
|
705
|
+
if (mode === "socket") {
|
|
706
|
+
if (!args.appToken) {
|
|
707
|
+
throw new Error("Slack socket mode needs an app-level token (xapp-…) — set channels.slack.appToken.");
|
|
708
|
+
}
|
|
709
|
+
const s = buildSocket(args.appToken);
|
|
710
|
+
wireSocket(s);
|
|
711
|
+
socket = s;
|
|
712
|
+
await s.start();
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
/**
|
|
716
|
+
* The supervise loop — start, and on a transient setup failure reconnect with
|
|
717
|
+
* backoff. The SocketModeClient auto-reconnects internally once started, so
|
|
718
|
+
* this loop mainly guards the initial connect (auth.test + socket start). A
|
|
719
|
+
* terminal auth error stops it (the only fix is a new token).
|
|
720
|
+
*/
|
|
721
|
+
const superviseLoop = async () => {
|
|
722
|
+
while (!closed && !tokenInvalid) {
|
|
723
|
+
try {
|
|
724
|
+
await startOnce();
|
|
725
|
+
}
|
|
726
|
+
catch (err) {
|
|
727
|
+
if (isSlackUnauthorized(err)) {
|
|
728
|
+
tokenInvalid = true;
|
|
729
|
+
connected = false;
|
|
730
|
+
safeLog("slack token rejected — re-token required; not connecting");
|
|
731
|
+
args.onTokenInvalid?.();
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (closed)
|
|
735
|
+
return;
|
|
736
|
+
const delay = slackBackoffDelay(reconnectAttempts);
|
|
737
|
+
reconnectAttempts += 1;
|
|
738
|
+
if (reconnectAttempts > RECONNECT_MAX_ATTEMPTS) {
|
|
739
|
+
safeLog("slack setup attempts exhausted — giving up until restart", { attempts: reconnectAttempts });
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
safeLog("slack setup failed — retrying", {
|
|
743
|
+
attempt: reconnectAttempts,
|
|
744
|
+
delayMs: delay,
|
|
745
|
+
error: err instanceof Error ? err.message : String(err),
|
|
746
|
+
});
|
|
747
|
+
await sleep(delay);
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
// close() may have fired during the async startOnce() — bail before we
|
|
751
|
+
// commit to a live socket we'd otherwise have to tear down.
|
|
752
|
+
if (closed) {
|
|
753
|
+
await teardownSocket();
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
// Connected. Reset backoff, announce. The socket now self-supervises; we
|
|
757
|
+
// resolve and let the internal reconnect handle transient drops.
|
|
758
|
+
connected = true;
|
|
759
|
+
connectedAtMs = Date.now();
|
|
760
|
+
reconnectAttempts = 0;
|
|
761
|
+
safeLog("slack connected", { account: accountId, self: selfName ? `@${selfName}` : selfId, team: teamIdValue });
|
|
762
|
+
args.onConnected?.();
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
const teardownSocket = async () => {
|
|
767
|
+
const s = socket;
|
|
768
|
+
socket = null;
|
|
769
|
+
if (s) {
|
|
770
|
+
try {
|
|
771
|
+
await s.disconnect();
|
|
772
|
+
}
|
|
773
|
+
catch {
|
|
774
|
+
/* already disconnected */
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
// In events mode there is no socket loop — just bootstrap auth.test once so
|
|
779
|
+
// the bot identity is cached and outbound works; inbound arrives via feedEvent.
|
|
780
|
+
const startEventsMode = async () => {
|
|
781
|
+
try {
|
|
782
|
+
await startOnce();
|
|
783
|
+
if (closed)
|
|
784
|
+
return;
|
|
785
|
+
connected = true;
|
|
786
|
+
connectedAtMs = Date.now();
|
|
787
|
+
reconnectAttempts = 0;
|
|
788
|
+
safeLog("slack events-mode ready — inbound via gateway route", { account: accountId });
|
|
789
|
+
args.onConnected?.();
|
|
790
|
+
}
|
|
791
|
+
catch (err) {
|
|
792
|
+
if (isSlackUnauthorized(err)) {
|
|
793
|
+
tokenInvalid = true;
|
|
794
|
+
connected = false;
|
|
795
|
+
safeLog("slack token rejected — re-token required; events mode not started");
|
|
796
|
+
args.onTokenInvalid?.();
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
safeLog("slack events-mode setup failed", { error: err instanceof Error ? err.message : String(err) });
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
// Kick the right startup path. `connectSlack` resolves as soon as the FIRST
|
|
803
|
+
// connect (or terminal failure) settles so the adapter's start() doesn't hang.
|
|
804
|
+
let resolveInitial;
|
|
805
|
+
const initial = new Promise((resolve) => {
|
|
806
|
+
resolveInitial = resolve;
|
|
807
|
+
});
|
|
808
|
+
const origOnConnected = args.onConnected;
|
|
809
|
+
const origOnTokenInvalid = args.onTokenInvalid;
|
|
810
|
+
args.onConnected = () => {
|
|
811
|
+
origOnConnected?.();
|
|
812
|
+
resolveInitial();
|
|
813
|
+
};
|
|
814
|
+
args.onTokenInvalid = () => {
|
|
815
|
+
origOnTokenInvalid?.();
|
|
816
|
+
resolveInitial();
|
|
817
|
+
};
|
|
818
|
+
loopPromise = (mode === "events" ? startEventsMode() : superviseLoop()).catch((err) => {
|
|
819
|
+
safeLog("slack supervise loop crashed", { error: err instanceof Error ? err.message : String(err) });
|
|
820
|
+
});
|
|
821
|
+
// Don't block start() indefinitely — resolve once connected OR after the loop
|
|
822
|
+
// settles (terminal failure), whichever comes first.
|
|
823
|
+
await Promise.race([initial, loopPromise.then(() => undefined)]);
|
|
824
|
+
/* ── outbound + control surface ── */
|
|
825
|
+
const requireLive = () => {
|
|
826
|
+
if (tokenInvalid)
|
|
827
|
+
throw new Error("Slack token is invalid — set a new bot token and restart.");
|
|
828
|
+
if (!web)
|
|
829
|
+
throw new Error("Slack channel is not started");
|
|
830
|
+
return web;
|
|
831
|
+
};
|
|
832
|
+
/** Throw a clear error when a Web API call returns `{ ok: false }`. */
|
|
833
|
+
const expectOk = (res, op) => {
|
|
834
|
+
if (!res?.ok) {
|
|
835
|
+
const code = res?.error ?? "unknown_error";
|
|
836
|
+
throw new Error(`Slack ${op} failed: ${code}`);
|
|
837
|
+
}
|
|
838
|
+
return res;
|
|
839
|
+
};
|
|
840
|
+
const sendText = async (channel, text, opts) => {
|
|
841
|
+
const w = requireLive();
|
|
842
|
+
const params = { channel, text, mrkdwn: true };
|
|
843
|
+
// thread_ts: an explicit threadId wins; else a native reply target threads
|
|
844
|
+
// the reply under the message being answered.
|
|
845
|
+
const threadTs = opts?.threadId ?? opts?.replyToMessageId;
|
|
846
|
+
if (threadTs)
|
|
847
|
+
params.thread_ts = threadTs;
|
|
848
|
+
if (opts?.linkPreview === false) {
|
|
849
|
+
params.unfurl_links = false;
|
|
850
|
+
params.unfurl_media = false;
|
|
851
|
+
}
|
|
852
|
+
const res = expectOk(await w.chat.postMessage(params), "postMessage");
|
|
853
|
+
return { messageId: res.ts ?? "" };
|
|
854
|
+
};
|
|
855
|
+
const sendInteractive = async (channel, text, blocks, opts) => {
|
|
856
|
+
const w = requireLive();
|
|
857
|
+
const params = { channel, text, blocks, mrkdwn: true };
|
|
858
|
+
const threadTs = opts?.threadId ?? opts?.replyToMessageId;
|
|
859
|
+
if (threadTs)
|
|
860
|
+
params.thread_ts = threadTs;
|
|
861
|
+
const res = expectOk(await w.chat.postMessage(params), "postMessage");
|
|
862
|
+
return { messageId: res.ts ?? "" };
|
|
863
|
+
};
|
|
864
|
+
const sendMedia = async (channel, media, opts) => {
|
|
865
|
+
const w = requireLive();
|
|
866
|
+
await uploadSlackFile({
|
|
867
|
+
client: w,
|
|
868
|
+
channelId: channel,
|
|
869
|
+
media,
|
|
870
|
+
...(opts?.threadId ? { threadId: opts.threadId } : {}),
|
|
871
|
+
});
|
|
872
|
+
};
|
|
873
|
+
const react = async (channel, messageId, emoji) => {
|
|
874
|
+
const w = requireLive();
|
|
875
|
+
const name = emoji.replace(/:/g, "").trim();
|
|
876
|
+
if (!name)
|
|
877
|
+
return;
|
|
878
|
+
try {
|
|
879
|
+
await w.reactions.add({ channel, timestamp: messageId, name });
|
|
880
|
+
}
|
|
881
|
+
catch (err) {
|
|
882
|
+
// `already_reacted` is benign; other errors are cosmetic for a reaction.
|
|
883
|
+
if (errorCode(err) === "already_reacted")
|
|
884
|
+
return;
|
|
885
|
+
safeLog("slack react failed (cosmetic)", { error: err instanceof Error ? err.message : String(err) });
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
const removeReaction = async (channel, messageId, emoji) => {
|
|
889
|
+
const w = requireLive();
|
|
890
|
+
const name = emoji.replace(/:/g, "").trim();
|
|
891
|
+
if (!name)
|
|
892
|
+
return;
|
|
893
|
+
try {
|
|
894
|
+
await w.reactions.remove({ channel, timestamp: messageId, name });
|
|
895
|
+
}
|
|
896
|
+
catch (err) {
|
|
897
|
+
if (errorCode(err) === "no_reaction")
|
|
898
|
+
return;
|
|
899
|
+
safeLog("slack remove reaction failed (cosmetic)", { error: err instanceof Error ? err.message : String(err) });
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
const removeOwnReactions = async (channel, messageId) => {
|
|
903
|
+
const w = requireLive();
|
|
904
|
+
const getFn = w.reactions.get;
|
|
905
|
+
if (typeof getFn !== "function")
|
|
906
|
+
return; // no slice → nothing we can clear
|
|
907
|
+
let reactions = [];
|
|
908
|
+
try {
|
|
909
|
+
const res = await getFn.call(w.reactions, { channel, timestamp: messageId });
|
|
910
|
+
reactions = Array.isArray(res?.message?.reactions) ? res.message.reactions : [];
|
|
911
|
+
}
|
|
912
|
+
catch (err) {
|
|
913
|
+
// A read failure is cosmetic — nothing to clear if we can't see them.
|
|
914
|
+
safeLog("slack reactions.get failed (cosmetic)", { error: err instanceof Error ? err.message : String(err) });
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
const self = selfId;
|
|
918
|
+
for (const r of reactions) {
|
|
919
|
+
const name = typeof r.name === "string" ? r.name : "";
|
|
920
|
+
// Only the bot's OWN reactions are ours to clear — a user's reaction on
|
|
921
|
+
// the same message is left untouched. When we don't know our own id
|
|
922
|
+
// (never connected), skip rather than risk removing someone else's.
|
|
923
|
+
if (!name || !self || !Array.isArray(r.users) || !r.users.includes(self))
|
|
924
|
+
continue;
|
|
925
|
+
try {
|
|
926
|
+
await w.reactions.remove({ channel, timestamp: messageId, name });
|
|
927
|
+
}
|
|
928
|
+
catch (err) {
|
|
929
|
+
if (errorCode(err) === "no_reaction")
|
|
930
|
+
continue;
|
|
931
|
+
safeLog("slack remove own reaction failed (cosmetic)", { error: err instanceof Error ? err.message : String(err) });
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
const editMessageText = async (channel, messageId, text, _opts) => {
|
|
936
|
+
const w = requireLive();
|
|
937
|
+
expectOk(await w.chat.update({ channel, ts: messageId, text, mrkdwn: true }), "chat.update");
|
|
938
|
+
};
|
|
939
|
+
const deleteMessage = async (channel, messageId) => {
|
|
940
|
+
const w = requireLive();
|
|
941
|
+
expectOk(await w.chat.delete({ channel, ts: messageId }), "chat.delete");
|
|
942
|
+
};
|
|
943
|
+
const openDirectMessage = async (userId) => {
|
|
944
|
+
const w = requireLive();
|
|
945
|
+
const res = expectOk(await w.conversations.open({ users: userId }), "conversations.open");
|
|
946
|
+
const id = res.channel?.id;
|
|
947
|
+
if (!id)
|
|
948
|
+
throw new Error("Slack: conversations.open returned no channel id");
|
|
949
|
+
return id;
|
|
950
|
+
};
|
|
951
|
+
const feedEvent = (kind, payload) => {
|
|
952
|
+
// Stamp liveness for EVERY webhook-fed event up front — even a
|
|
953
|
+
// reaction_removed (handled below by releasing a dedupe key, not a handler)
|
|
954
|
+
// is inbound traffic that proves the events route is alive.
|
|
955
|
+
stampInboundEvent();
|
|
956
|
+
if (kind === "interactive") {
|
|
957
|
+
handleInteractive(payload);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
if (kind === "slash") {
|
|
961
|
+
handleSlashCommand(payload);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
// An events-API outer envelope wraps the event under `event`; the team id is
|
|
965
|
+
// the top-level `team_id`.
|
|
966
|
+
const env = payload;
|
|
967
|
+
const event = env.event ?? payload;
|
|
968
|
+
const teamId = typeof env.team_id === "string" ? env.team_id : undefined;
|
|
969
|
+
const type = event?.type;
|
|
970
|
+
if (type === "reaction_added") {
|
|
971
|
+
handleReactionEvent(event, teamId);
|
|
972
|
+
}
|
|
973
|
+
else if (type === "reaction_removed") {
|
|
974
|
+
// Mirror the socket path: a removal isn't routed, but it RELEASES the
|
|
975
|
+
// add-dedupe key so a later re-add (add→remove→add) re-claims + routes.
|
|
976
|
+
const re = event;
|
|
977
|
+
eventDedupe.release(`react:${re.user}:${re.reaction}:${re.item?.ts}`);
|
|
978
|
+
}
|
|
979
|
+
else {
|
|
980
|
+
// Fire-and-forget: handleMessageEvent is async (thread backfill) but
|
|
981
|
+
// feedEvent is the sync webhook entry point. The handler swallows its own
|
|
982
|
+
// errors; guard the promise so an unexpected rejection can't go unhandled.
|
|
983
|
+
void handleMessageEvent(event, teamId).catch(() => { });
|
|
984
|
+
}
|
|
985
|
+
};
|
|
986
|
+
const close = async () => {
|
|
987
|
+
closed = true;
|
|
988
|
+
connected = false;
|
|
989
|
+
await teardownSocket();
|
|
990
|
+
try {
|
|
991
|
+
await Promise.race([
|
|
992
|
+
loopPromise ?? Promise.resolve(),
|
|
993
|
+
new Promise((resolve) => setTimeout(resolve, 5_000).unref?.()),
|
|
994
|
+
]);
|
|
995
|
+
}
|
|
996
|
+
catch {
|
|
997
|
+
/* loop already settled */
|
|
998
|
+
}
|
|
999
|
+
};
|
|
1000
|
+
return {
|
|
1001
|
+
selfId: () => selfId,
|
|
1002
|
+
selfName: () => selfName,
|
|
1003
|
+
teamId: () => teamIdValue,
|
|
1004
|
+
connectedAt: () => connectedAtMs,
|
|
1005
|
+
lastEventAt: () => lastEventAtMs,
|
|
1006
|
+
isConnected: () => connected,
|
|
1007
|
+
isTokenInvalid: () => tokenInvalid,
|
|
1008
|
+
sendText,
|
|
1009
|
+
sendInteractive,
|
|
1010
|
+
sendMedia,
|
|
1011
|
+
react,
|
|
1012
|
+
removeReaction,
|
|
1013
|
+
removeOwnReactions,
|
|
1014
|
+
editMessageText,
|
|
1015
|
+
deleteMessage,
|
|
1016
|
+
openDirectMessage,
|
|
1017
|
+
feedEvent,
|
|
1018
|
+
mode: () => mode,
|
|
1019
|
+
setComposing: async (channel, state) => {
|
|
1020
|
+
// Slack has no bot typing API; emulate it — react ⏳ to the user's last
|
|
1021
|
+
// message while the agent works, remove it when idle. Best-effort +
|
|
1022
|
+
// cosmetic: a failure (no scope / already-reacted / not live) never blocks.
|
|
1023
|
+
const ts = lastInboundTs.get(channel);
|
|
1024
|
+
const w = web;
|
|
1025
|
+
if (!ts || !w)
|
|
1026
|
+
return;
|
|
1027
|
+
try {
|
|
1028
|
+
if (state === "composing")
|
|
1029
|
+
await w.reactions.add({ channel, timestamp: ts, name: TYPING_REACTION });
|
|
1030
|
+
else
|
|
1031
|
+
await w.reactions.remove({ channel, timestamp: ts, name: TYPING_REACTION });
|
|
1032
|
+
}
|
|
1033
|
+
catch {
|
|
1034
|
+
/* cosmetic — already_reacted / no_reaction / missing scope: ignore */
|
|
1035
|
+
}
|
|
1036
|
+
},
|
|
1037
|
+
markRead: async () => { },
|
|
1038
|
+
close,
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
export { SLACK_MESSAGE_LIMIT };
|
|
1042
|
+
//# sourceMappingURL=connection.js.map
|