@adaptic/maestro 1.10.0 → 1.10.2
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/.env.example +9 -0
- package/bin/maestro.mjs +92 -0
- package/framework-features.json +10 -0
- package/package.json +1 -1
- package/scripts/cadence/launchd-socket-mode-wrapper.sh +95 -0
- package/scripts/healthcheck.sh +15 -9
- package/scripts/local-triggers/generate-plists.sh +15 -0
- package/scripts/local-triggers/generate-plists.test.mjs +19 -7
- package/scripts/poller/slack-socket-mode.mjs +739 -0
- package/scripts/poller/slack-socket-mode.test.mjs +688 -0
- package/scripts/setup/init-slack-socket-mode.mjs +260 -0
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// =============================================================================
|
|
3
|
+
// Slack Socket Mode Listener — realtime DM + @mention ingestion
|
|
4
|
+
// =============================================================================
|
|
5
|
+
//
|
|
6
|
+
// Replaces the 60s `slack-poller.mjs` and the 5s Railway webhook relay polling
|
|
7
|
+
// for DM / @mention ingestion. Holds a persistent WebSocket to Slack's
|
|
8
|
+
// Socket Mode endpoint and translates inbound message events to the same
|
|
9
|
+
// inbox-item shape that `slack-poller.mjs` produces, then hands them to
|
|
10
|
+
// `writeInboxItem` from `utils.mjs` (preserving the existing contract).
|
|
11
|
+
//
|
|
12
|
+
// Why Socket Mode over polling
|
|
13
|
+
// ----------------------------
|
|
14
|
+
// 1. Realtime — events arrive in <500ms instead of the 60s poll cycle.
|
|
15
|
+
// 2. No rate-limit pressure — one persistent socket vs. ~25 API calls/cycle.
|
|
16
|
+
// 3. No public webhook endpoint required — Slack pushes via outbound WSS.
|
|
17
|
+
// 4. Aligns with Slack's recommended pattern for self-hosted agents.
|
|
18
|
+
//
|
|
19
|
+
// Requirements
|
|
20
|
+
// ------------
|
|
21
|
+
// - SLACK_APP_LEVEL_TOKEN (xapp-…) with `connections:write` scope
|
|
22
|
+
// - SLACK_BOT_TOKEN (xoxb-…) for the optional API enrichment fallbacks
|
|
23
|
+
// - Socket Mode enabled in the Slack app config
|
|
24
|
+
// - Event subscriptions enabled with: message.channels, message.groups,
|
|
25
|
+
// message.im, message.mpim, app_mention
|
|
26
|
+
//
|
|
27
|
+
// Run: node scripts/poller/slack-socket-mode.mjs
|
|
28
|
+
// Install: launchd plist with KeepAlive (SuccessfulExit:false, Crashed:true).
|
|
29
|
+
//
|
|
30
|
+
// Module exports a programmable surface (createSocketModeClient,
|
|
31
|
+
// eventToInboxItem, etc.) so the test file can exercise translation +
|
|
32
|
+
// reconnect logic without touching the network. The CLI invocation
|
|
33
|
+
// (process.argv[1]) starts the daemon directly.
|
|
34
|
+
// =============================================================================
|
|
35
|
+
|
|
36
|
+
import { existsSync, readFileSync, mkdirSync, appendFileSync } from "node:fs";
|
|
37
|
+
import { dirname, join, resolve } from "node:path";
|
|
38
|
+
import { fileURLToPath } from "node:url";
|
|
39
|
+
|
|
40
|
+
import { writeInboxItem, resolvePrivilege, resolveName, AGENT_REPO_DIR } from "./utils.mjs";
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Constants
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
47
|
+
const __dirname = dirname(__filename);
|
|
48
|
+
|
|
49
|
+
// Slack endpoint for opening a Socket Mode WSS connection.
|
|
50
|
+
const APPS_CONNECTIONS_OPEN_URL = "https://slack.com/api/apps.connections.open";
|
|
51
|
+
|
|
52
|
+
// Backoff defaults — exponential with cap. The first retry waits 1s, then
|
|
53
|
+
// doubles each failure up to 60s. Reset on a clean `hello`.
|
|
54
|
+
export const RECONNECT_INITIAL_MS = 1_000;
|
|
55
|
+
export const RECONNECT_MAX_MS = 60_000;
|
|
56
|
+
|
|
57
|
+
// How often we check .emergency-stop and refresh keep-alive state. Slack
|
|
58
|
+
// sends `disconnect` (type: "disconnect") ~30 min into a session asking
|
|
59
|
+
// us to reconnect; we just close + reconnect on the next tick.
|
|
60
|
+
export const SUPERVISOR_INTERVAL_MS = 5_000;
|
|
61
|
+
|
|
62
|
+
// Slack's recommended ping interval. The WS server sends `pings`; we
|
|
63
|
+
// respond with `pong`. Most node ws implementations handle this for us,
|
|
64
|
+
// but we also enqueue a heartbeat to surface stalls.
|
|
65
|
+
export const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
66
|
+
export const HEARTBEAT_TIMEOUT_MS = 90_000;
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Env loader (mirrors slack-events-server.mjs — no dotenv dep)
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read .env from the agent root and merge into process.env (existing keys
|
|
74
|
+
* win — we don't clobber values the operator deliberately set in their
|
|
75
|
+
* shell). No-op when .env is missing.
|
|
76
|
+
*/
|
|
77
|
+
export function loadEnvFromFile(agentRoot) {
|
|
78
|
+
const envPath = join(agentRoot, ".env");
|
|
79
|
+
if (!existsSync(envPath)) return;
|
|
80
|
+
const body = readFileSync(envPath, "utf-8");
|
|
81
|
+
for (const line of body.split("\n")) {
|
|
82
|
+
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
|
83
|
+
if (!m) continue;
|
|
84
|
+
let val = m[2].trim();
|
|
85
|
+
if ((val.startsWith('"') && val.endsWith('"')) ||
|
|
86
|
+
(val.startsWith("'") && val.endsWith("'"))) {
|
|
87
|
+
val = val.slice(1, -1);
|
|
88
|
+
}
|
|
89
|
+
if (process.env[m[1]] === undefined || process.env[m[1]] === "") {
|
|
90
|
+
process.env[m[1]] = val;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Logging — JSONL to logs/polling/<date>-slack-socket.jsonl
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Append a structured log line. Best-effort; logging failures must never
|
|
101
|
+
* take the listener down. The log path mirrors the existing poller layout
|
|
102
|
+
* so downstream tooling (doctor, log rotation) keeps working unchanged.
|
|
103
|
+
*/
|
|
104
|
+
export function logLine(agentRoot, entry) {
|
|
105
|
+
try {
|
|
106
|
+
const dir = join(agentRoot, "logs", "polling");
|
|
107
|
+
mkdirSync(dir, { recursive: true });
|
|
108
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
109
|
+
const file = join(dir, `${today}-slack-socket.jsonl`);
|
|
110
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n";
|
|
111
|
+
appendFileSync(file, line);
|
|
112
|
+
} catch {
|
|
113
|
+
/* logging must never propagate */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function info(agentRoot, message, data = {}) {
|
|
118
|
+
console.log(`[slack-socket] ${message}`);
|
|
119
|
+
logLine(agentRoot, { level: "info", message, ...data });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function warn(agentRoot, message, data = {}) {
|
|
123
|
+
console.warn(`[slack-socket] ${message}`);
|
|
124
|
+
logLine(agentRoot, { level: "warn", message, ...data });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function error(agentRoot, message, data = {}) {
|
|
128
|
+
console.error(`[slack-socket] ${message}`);
|
|
129
|
+
logLine(agentRoot, { level: "error", message, ...data });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Agent identity + known-agents registry
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Load `config/agent.json` and return a canonical identity object with safe
|
|
138
|
+
* defaults so callers never need to null-check.
|
|
139
|
+
*/
|
|
140
|
+
export function loadAgentIdentity(agentRoot) {
|
|
141
|
+
try {
|
|
142
|
+
const agent = JSON.parse(readFileSync(join(agentRoot, "config/agent.json"), "utf-8"));
|
|
143
|
+
return {
|
|
144
|
+
firstName: agent.firstName || "Agent",
|
|
145
|
+
fullName: agent.fullName || "Agent",
|
|
146
|
+
slackMemberId: agent.slackMemberId || "",
|
|
147
|
+
principalSlackId: agent.principal?.slackMemberId || "",
|
|
148
|
+
};
|
|
149
|
+
} catch {
|
|
150
|
+
return { firstName: "Agent", fullName: "Agent", slackMemberId: "", principalSlackId: "" };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Load peer agent Slack IDs from `config/known-agents.json` (or maestro
|
|
156
|
+
* install fallback). Excludes the running agent itself. Used by the
|
|
157
|
+
* filter that drops messages from other agents — so e.g. Sophie's Slack
|
|
158
|
+
* traffic doesn't trigger Ravi's daemon.
|
|
159
|
+
*/
|
|
160
|
+
export function loadPeerSlackIds(agentRoot, ownSlackId) {
|
|
161
|
+
const candidates = [
|
|
162
|
+
join(agentRoot, "config/known-agents.json"),
|
|
163
|
+
join(process.env.HOME || "/", "maestro/config/known-agents.json"),
|
|
164
|
+
];
|
|
165
|
+
for (const path of candidates) {
|
|
166
|
+
try {
|
|
167
|
+
const raw = readFileSync(path, "utf-8");
|
|
168
|
+
const reg = JSON.parse(raw);
|
|
169
|
+
const ids = new Set();
|
|
170
|
+
for (const a of reg.agents || []) {
|
|
171
|
+
if (a?.slackId && a.slackId !== ownSlackId) ids.add(a.slackId);
|
|
172
|
+
}
|
|
173
|
+
if (ids.size > 0) return ids;
|
|
174
|
+
} catch {
|
|
175
|
+
/* try next */
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return new Set();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Slack envelope filtering — DM, @mention, thread reply
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Decide whether a Slack event envelope is interesting to this agent.
|
|
187
|
+
*
|
|
188
|
+
* Returns { keep: boolean, reason: string }. The reason exists so the
|
|
189
|
+
* test suite (and ops triage) can assert exactly *why* an event was
|
|
190
|
+
* dropped or kept. Keep this function pure — no I/O, no globals beyond
|
|
191
|
+
* the identity/peers arguments.
|
|
192
|
+
*/
|
|
193
|
+
export function shouldKeepEvent(envelope, { ownSlackId, peerSlackIds }) {
|
|
194
|
+
if (!envelope || typeof envelope !== "object") {
|
|
195
|
+
return { keep: false, reason: "envelope-missing" };
|
|
196
|
+
}
|
|
197
|
+
if (envelope.type !== "events_api") {
|
|
198
|
+
return { keep: false, reason: `envelope-type-${envelope.type}` };
|
|
199
|
+
}
|
|
200
|
+
const event = envelope.payload?.event;
|
|
201
|
+
if (!event) return { keep: false, reason: "no-event" };
|
|
202
|
+
|
|
203
|
+
// Only message events. app_mention events come as `type: app_mention`
|
|
204
|
+
// but Slack also delivers a parallel `type: message` for the same text,
|
|
205
|
+
// so we normalise on the message handler and detect mentions inline.
|
|
206
|
+
// Allow `thread_broadcast` (a thread reply also broadcast to channel)
|
|
207
|
+
// through as a normal message; drop everything else.
|
|
208
|
+
if (event.type !== "message") return { keep: false, reason: `event-type-${event.type}` };
|
|
209
|
+
if (event.subtype && event.subtype !== "thread_broadcast" && event.subtype !== "file_share") {
|
|
210
|
+
return { keep: false, reason: `event-subtype-${event.subtype}` };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Drop bot messages and the agent's own messages.
|
|
214
|
+
if (event.bot_id) return { keep: false, reason: "bot-message" };
|
|
215
|
+
if (event.user && ownSlackId && event.user === ownSlackId) {
|
|
216
|
+
return { keep: false, reason: "self-message" };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Drop messages from peer agents — they're not customers and replying
|
|
220
|
+
// would loop two daemons forever.
|
|
221
|
+
if (event.user && peerSlackIds && peerSlackIds.has(event.user)) {
|
|
222
|
+
return { keep: false, reason: "peer-agent" };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// DM channels start with "D"; keep all of them.
|
|
226
|
+
const isDm = typeof event.channel === "string" && event.channel.startsWith("D");
|
|
227
|
+
if (isDm) return { keep: true, reason: "dm" };
|
|
228
|
+
|
|
229
|
+
// Channel messages — only keep if the agent is @-mentioned.
|
|
230
|
+
const text = typeof event.text === "string" ? event.text : "";
|
|
231
|
+
const mentionPattern = ownSlackId ? new RegExp(`<@${ownSlackId}(?:\\|[^>]*)?>`) : null;
|
|
232
|
+
if (mentionPattern && mentionPattern.test(text)) {
|
|
233
|
+
return { keep: true, reason: "mention" };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { keep: false, reason: "no-mention-no-dm" };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// Translation — Slack envelope → poller-compatible inbox item
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Convert a Slack Socket Mode envelope to the same inbox-item shape that
|
|
245
|
+
* `slack-poller.mjs` produces. Pure function; all I/O happens at the caller.
|
|
246
|
+
*
|
|
247
|
+
* The output schema mirrors `slack-poller.mjs` exactly so `writeInboxItem`
|
|
248
|
+
* + the downstream daemon pipeline keep working without changes:
|
|
249
|
+
* - `id` = ts with the dot replaced by a dash (matches poller convention)
|
|
250
|
+
* - `service` = "slack"
|
|
251
|
+
* - `channel` = friendly channel label ("dm/<sender-name>" for DMs,
|
|
252
|
+
* channel id for channels — channel name resolution requires an API
|
|
253
|
+
* call we'd rather avoid on the hot path; the daemon downstream can
|
|
254
|
+
* fill in if needed)
|
|
255
|
+
* - `priority_signals.mentions_agent` = true for DMs (1:1 context), true
|
|
256
|
+
* when the regex matched, false otherwise.
|
|
257
|
+
*/
|
|
258
|
+
export function eventToInboxItem(envelope, { ownSlackId, principalSlackId, agentFirstName }) {
|
|
259
|
+
const event = envelope.payload.event;
|
|
260
|
+
const userId = event.user || "";
|
|
261
|
+
const text = event.text || "";
|
|
262
|
+
const ts = event.ts || "";
|
|
263
|
+
const channelId = event.channel || "";
|
|
264
|
+
const threadTs = event.thread_ts || "";
|
|
265
|
+
const isReply = !!threadTs && threadTs !== ts;
|
|
266
|
+
const isDm = typeof channelId === "string" && channelId.startsWith("D");
|
|
267
|
+
|
|
268
|
+
// Friendly labels. We can't resolve a channel name without an API call
|
|
269
|
+
// we'd rather not make per event; the agent's downstream tooling already
|
|
270
|
+
// handles channel-id → name resolution where needed.
|
|
271
|
+
const senderName = resolveName(userId);
|
|
272
|
+
const channelLabel = isDm ? `dm/${senderName}` : channelId;
|
|
273
|
+
const subject = isDm ? `DM from ${senderName}` : `#${channelLabel}`;
|
|
274
|
+
|
|
275
|
+
// Priority signal detection mirrors slack-poller.mjs.
|
|
276
|
+
const isCeo = !!principalSlackId && userId === principalSlackId;
|
|
277
|
+
const isUrgent = /\b(urgent|emergency|asap|blocker|critical)\b/i.test(text);
|
|
278
|
+
const mentionPattern = ownSlackId ? new RegExp(`<@${ownSlackId}(?:\\|[^>]*)?>`) : null;
|
|
279
|
+
const explicitlyMentioned = mentionPattern ? mentionPattern.test(text) : false;
|
|
280
|
+
const nameMention = !!agentFirstName && text.toLowerCase().includes(agentFirstName.toLowerCase());
|
|
281
|
+
const mentionsAgent = isDm || explicitlyMentioned || nameMention;
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
id: ts.replace(".", "-"),
|
|
285
|
+
service: "slack",
|
|
286
|
+
channel: channelLabel,
|
|
287
|
+
channel_id: channelId,
|
|
288
|
+
sender: senderName,
|
|
289
|
+
sender_privilege: resolvePrivilege(userId),
|
|
290
|
+
timestamp: new Date(parseFloat(ts) * 1000).toISOString(),
|
|
291
|
+
subject,
|
|
292
|
+
content: text,
|
|
293
|
+
thread_id: threadTs || "",
|
|
294
|
+
thread_context: null, // Socket Mode doesn't include thread history.
|
|
295
|
+
is_reply: isReply,
|
|
296
|
+
priority_signals: {
|
|
297
|
+
from_ceo: isCeo,
|
|
298
|
+
tagged_urgent: isUrgent,
|
|
299
|
+
contains_deadline: false,
|
|
300
|
+
mentions_agent: mentionsAgent,
|
|
301
|
+
},
|
|
302
|
+
raw_ref: `slack:${channelId}:${ts}`,
|
|
303
|
+
source: "socket-mode",
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// Slack `apps.connections.open` — get a fresh WSS URL
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Call apps.connections.open with the app-level token to obtain a WSS URL.
|
|
313
|
+
* Returns `{ url }` on success or `{ error }` on failure. The URL is
|
|
314
|
+
* single-use and expires quickly — call this every time we open a socket.
|
|
315
|
+
*/
|
|
316
|
+
export async function openSocketConnection({ appToken, fetchFn = fetch }) {
|
|
317
|
+
if (!appToken) {
|
|
318
|
+
return { error: "missing-app-token" };
|
|
319
|
+
}
|
|
320
|
+
let response;
|
|
321
|
+
try {
|
|
322
|
+
response = await fetchFn(APPS_CONNECTIONS_OPEN_URL, {
|
|
323
|
+
method: "POST",
|
|
324
|
+
headers: {
|
|
325
|
+
Authorization: `Bearer ${appToken}`,
|
|
326
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
} catch (err) {
|
|
330
|
+
return { error: `network: ${err.message}` };
|
|
331
|
+
}
|
|
332
|
+
let body;
|
|
333
|
+
try {
|
|
334
|
+
body = await response.json();
|
|
335
|
+
} catch (err) {
|
|
336
|
+
return { error: `parse: ${err.message}` };
|
|
337
|
+
}
|
|
338
|
+
if (!body.ok) {
|
|
339
|
+
return { error: body.error || "unknown-error" };
|
|
340
|
+
}
|
|
341
|
+
if (!body.url) {
|
|
342
|
+
return { error: "no-url-in-response" };
|
|
343
|
+
}
|
|
344
|
+
return { url: body.url };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// Reconnect supervisor — exponential back-off with jitter
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Compute the next reconnect delay using exponential back-off plus a small
|
|
353
|
+
* (0-25%) random jitter to avoid thundering-herd if multiple agents
|
|
354
|
+
* reconnect simultaneously.
|
|
355
|
+
*/
|
|
356
|
+
export function nextBackoffMs(currentMs, max = RECONNECT_MAX_MS) {
|
|
357
|
+
const doubled = Math.min(max, Math.max(RECONNECT_INITIAL_MS, currentMs * 2));
|
|
358
|
+
const jitter = Math.floor(Math.random() * 0.25 * doubled);
|
|
359
|
+
return Math.min(max, doubled + jitter);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
// Socket Mode client — the main orchestration loop
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Create a Socket Mode client. The client is *programmable*: every external
|
|
368
|
+
* dependency (fetch, WebSocket constructor, clock, write-inbox-item) is
|
|
369
|
+
* injectable so the test suite can run end-to-end without ever touching
|
|
370
|
+
* the network or the file system.
|
|
371
|
+
*
|
|
372
|
+
* @param {object} opts
|
|
373
|
+
* @param {string} opts.agentRoot
|
|
374
|
+
* @param {string} opts.appToken xapp-… app-level token
|
|
375
|
+
* @param {object} opts.identity loadAgentIdentity() result
|
|
376
|
+
* @param {Set<string>} opts.peerSlackIds
|
|
377
|
+
* @param {Function} [opts.fetchFn] defaults to global fetch
|
|
378
|
+
* @param {Function} [opts.WebSocketCtor] defaults to global WebSocket
|
|
379
|
+
* @param {Function} [opts.writeInbox] defaults to writeInboxItem
|
|
380
|
+
* @param {Function} [opts.now] defaults to Date.now
|
|
381
|
+
* @param {Function} [opts.setTimeout] defaults to globalThis.setTimeout
|
|
382
|
+
* @param {Function} [opts.clearTimeout] defaults to globalThis.clearTimeout
|
|
383
|
+
*/
|
|
384
|
+
export function createSocketModeClient(opts) {
|
|
385
|
+
const {
|
|
386
|
+
agentRoot,
|
|
387
|
+
appToken,
|
|
388
|
+
identity,
|
|
389
|
+
peerSlackIds,
|
|
390
|
+
fetchFn = fetch,
|
|
391
|
+
WebSocketCtor = globalThis.WebSocket,
|
|
392
|
+
writeInbox = writeInboxItem,
|
|
393
|
+
now = Date.now,
|
|
394
|
+
setTimeout: setTimeoutFn = globalThis.setTimeout,
|
|
395
|
+
clearTimeout: clearTimeoutFn = globalThis.clearTimeout,
|
|
396
|
+
} = opts;
|
|
397
|
+
|
|
398
|
+
if (!WebSocketCtor) {
|
|
399
|
+
throw new Error(
|
|
400
|
+
"Socket Mode requires a WebSocket implementation. Use Node 22+ (global WebSocket) or pass WebSocketCtor.",
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const state = {
|
|
405
|
+
socket: null,
|
|
406
|
+
backoffMs: RECONNECT_INITIAL_MS,
|
|
407
|
+
stopped: false,
|
|
408
|
+
reconnectTimer: null,
|
|
409
|
+
heartbeatTimer: null,
|
|
410
|
+
lastPingAt: 0,
|
|
411
|
+
stats: {
|
|
412
|
+
connects: 0,
|
|
413
|
+
reconnects: 0,
|
|
414
|
+
eventsReceived: 0,
|
|
415
|
+
eventsKept: 0,
|
|
416
|
+
eventsDropped: 0,
|
|
417
|
+
acksSent: 0,
|
|
418
|
+
lastDisconnectReason: null,
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// -------------------------------------------------------------------------
|
|
423
|
+
// ack — Slack requires every envelope to be acked back over the same socket.
|
|
424
|
+
// -------------------------------------------------------------------------
|
|
425
|
+
function ack(envelopeId, ws) {
|
|
426
|
+
if (!envelopeId || !ws || ws.readyState !== 1 /* WebSocket.OPEN */) return;
|
|
427
|
+
try {
|
|
428
|
+
ws.send(JSON.stringify({ envelope_id: envelopeId }));
|
|
429
|
+
state.stats.acksSent++;
|
|
430
|
+
} catch (err) {
|
|
431
|
+
warn(agentRoot, `ack send failed: ${err.message}`, { envelopeId });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// -------------------------------------------------------------------------
|
|
436
|
+
// handleEnvelope — translate + persist via writeInbox.
|
|
437
|
+
// -------------------------------------------------------------------------
|
|
438
|
+
function handleEnvelope(envelope, ws) {
|
|
439
|
+
state.stats.eventsReceived++;
|
|
440
|
+
|
|
441
|
+
// Slack sends multiple envelope types over the same socket: hello,
|
|
442
|
+
// disconnect, events_api, slash_commands, interactive, etc. We only
|
|
443
|
+
// care about the events_api stream for inbox ingestion.
|
|
444
|
+
if (envelope.type === "hello") {
|
|
445
|
+
info(agentRoot, "hello — connected to Slack Socket Mode", {
|
|
446
|
+
num_connections: envelope.num_connections,
|
|
447
|
+
debug_info: envelope.debug_info,
|
|
448
|
+
});
|
|
449
|
+
state.backoffMs = RECONNECT_INITIAL_MS; // reset on a clean hello
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (envelope.type === "disconnect") {
|
|
453
|
+
info(agentRoot, "disconnect requested by Slack — will reconnect", {
|
|
454
|
+
reason: envelope.reason,
|
|
455
|
+
});
|
|
456
|
+
state.stats.lastDisconnectReason = envelope.reason || "slack-requested";
|
|
457
|
+
// Slack always wants an ack on this, but most importantly we should
|
|
458
|
+
// close + reconnect to pick up the new server-side endpoint.
|
|
459
|
+
try { ws.close(); } catch { /* */ }
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Every other envelope type needs an ack — do this BEFORE any
|
|
464
|
+
// potentially-slow processing so Slack doesn't re-deliver.
|
|
465
|
+
if (envelope.envelope_id) ack(envelope.envelope_id, ws);
|
|
466
|
+
|
|
467
|
+
const decision = shouldKeepEvent(envelope, {
|
|
468
|
+
ownSlackId: identity.slackMemberId,
|
|
469
|
+
peerSlackIds,
|
|
470
|
+
});
|
|
471
|
+
if (!decision.keep) {
|
|
472
|
+
state.stats.eventsDropped++;
|
|
473
|
+
logLine(agentRoot, { level: "debug", message: "event-dropped", reason: decision.reason });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
let item;
|
|
478
|
+
try {
|
|
479
|
+
item = eventToInboxItem(envelope, {
|
|
480
|
+
ownSlackId: identity.slackMemberId,
|
|
481
|
+
principalSlackId: identity.principalSlackId,
|
|
482
|
+
agentFirstName: identity.firstName,
|
|
483
|
+
});
|
|
484
|
+
} catch (err) {
|
|
485
|
+
error(agentRoot, `translation failed: ${err.message}`, { stack: err.stack });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
writeInbox("slack", item);
|
|
491
|
+
state.stats.eventsKept++;
|
|
492
|
+
info(agentRoot, "inbox-item-written", {
|
|
493
|
+
id: item.id,
|
|
494
|
+
channel: item.channel,
|
|
495
|
+
sender: item.sender,
|
|
496
|
+
is_reply: item.is_reply,
|
|
497
|
+
mentions_agent: item.priority_signals.mentions_agent,
|
|
498
|
+
});
|
|
499
|
+
} catch (err) {
|
|
500
|
+
error(agentRoot, `writeInbox failed: ${err.message}`, { stack: err.stack, id: item.id });
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// -------------------------------------------------------------------------
|
|
505
|
+
// connect — single attempt; on close, schedule a reconnect.
|
|
506
|
+
// -------------------------------------------------------------------------
|
|
507
|
+
async function connect() {
|
|
508
|
+
if (state.stopped) return;
|
|
509
|
+
if (existsSync(join(agentRoot, ".emergency-stop"))) {
|
|
510
|
+
info(agentRoot, ".emergency-stop present — refusing to connect");
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const open = await openSocketConnection({ appToken, fetchFn });
|
|
515
|
+
if (open.error) {
|
|
516
|
+
state.stats.lastDisconnectReason = `open-failed:${open.error}`;
|
|
517
|
+
error(agentRoot, `apps.connections.open failed: ${open.error}`);
|
|
518
|
+
scheduleReconnect();
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
info(agentRoot, "opening WebSocket", { url_prefix: open.url.slice(0, 64) });
|
|
522
|
+
|
|
523
|
+
let ws;
|
|
524
|
+
try {
|
|
525
|
+
ws = new WebSocketCtor(open.url);
|
|
526
|
+
} catch (err) {
|
|
527
|
+
error(agentRoot, `WebSocket construction failed: ${err.message}`);
|
|
528
|
+
scheduleReconnect();
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
state.socket = ws;
|
|
532
|
+
state.stats.connects++;
|
|
533
|
+
if (state.stats.connects > 1) state.stats.reconnects++;
|
|
534
|
+
|
|
535
|
+
// Both event-emitter and addEventListener APIs work on global WebSocket;
|
|
536
|
+
// use addEventListener so a `ws` polyfill from a future test injection
|
|
537
|
+
// doesn't bite us.
|
|
538
|
+
const onOpen = () => {
|
|
539
|
+
state.lastPingAt = now();
|
|
540
|
+
};
|
|
541
|
+
const onMessage = (event) => {
|
|
542
|
+
const raw = typeof event === "string" ? event : (event?.data ?? "");
|
|
543
|
+
if (!raw) return;
|
|
544
|
+
let envelope;
|
|
545
|
+
try {
|
|
546
|
+
envelope = JSON.parse(typeof raw === "string" ? raw : raw.toString());
|
|
547
|
+
} catch (err) {
|
|
548
|
+
warn(agentRoot, `non-JSON frame: ${err.message}`, { raw_snippet: String(raw).slice(0, 200) });
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
handleEnvelope(envelope, ws);
|
|
553
|
+
} catch (err) {
|
|
554
|
+
error(agentRoot, `handleEnvelope threw: ${err.message}`, { stack: err.stack });
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
const onError = (event) => {
|
|
558
|
+
warn(agentRoot, `socket error: ${event?.message || "unknown"}`);
|
|
559
|
+
};
|
|
560
|
+
const onClose = (event) => {
|
|
561
|
+
const code = event?.code ?? 0;
|
|
562
|
+
const reason = event?.reason ?? "";
|
|
563
|
+
info(agentRoot, "socket closed", { code, reason });
|
|
564
|
+
state.socket = null;
|
|
565
|
+
scheduleReconnect();
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
ws.addEventListener?.("open", onOpen);
|
|
569
|
+
ws.addEventListener?.("message", onMessage);
|
|
570
|
+
ws.addEventListener?.("error", onError);
|
|
571
|
+
ws.addEventListener?.("close", onClose);
|
|
572
|
+
// The `ws` npm package uses Node's EventEmitter; addEventListener may
|
|
573
|
+
// be missing. Fall back to `on` if present.
|
|
574
|
+
if (typeof ws.addEventListener !== "function" && typeof ws.on === "function") {
|
|
575
|
+
ws.on("open", onOpen);
|
|
576
|
+
ws.on("message", (data) => onMessage({ data }));
|
|
577
|
+
ws.on("error", (err) => onError({ message: err?.message }));
|
|
578
|
+
ws.on("close", (code, reason) => onClose({ code, reason: String(reason ?? "") }));
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// -------------------------------------------------------------------------
|
|
583
|
+
// scheduleReconnect — exponential back-off; resets on each hello.
|
|
584
|
+
// -------------------------------------------------------------------------
|
|
585
|
+
function scheduleReconnect() {
|
|
586
|
+
if (state.stopped) return;
|
|
587
|
+
if (state.reconnectTimer) return; // already scheduled
|
|
588
|
+
state.backoffMs = nextBackoffMs(state.backoffMs);
|
|
589
|
+
info(agentRoot, `reconnect in ${state.backoffMs}ms`, { backoff_ms: state.backoffMs });
|
|
590
|
+
state.reconnectTimer = setTimeoutFn(() => {
|
|
591
|
+
state.reconnectTimer = null;
|
|
592
|
+
connect().catch((err) => {
|
|
593
|
+
error(agentRoot, `connect rejected: ${err.message}`, { stack: err.stack });
|
|
594
|
+
scheduleReconnect();
|
|
595
|
+
});
|
|
596
|
+
}, state.backoffMs);
|
|
597
|
+
if (state.reconnectTimer && typeof state.reconnectTimer.unref === "function") {
|
|
598
|
+
state.reconnectTimer.unref();
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// -------------------------------------------------------------------------
|
|
603
|
+
// supervisor — checks .emergency-stop, prunes stale sockets.
|
|
604
|
+
//
|
|
605
|
+
// Timers are unref()-ed so they don't pin the Node event loop alive on
|
|
606
|
+
// test exit. In production the WebSocket itself holds the loop open,
|
|
607
|
+
// and the supervisor's job is purely to detect .emergency-stop while
|
|
608
|
+
// the listener is otherwise idle.
|
|
609
|
+
// -------------------------------------------------------------------------
|
|
610
|
+
function startSupervisor() {
|
|
611
|
+
state.heartbeatTimer = setTimeoutFn(function tick() {
|
|
612
|
+
if (state.stopped) return;
|
|
613
|
+
// Emergency-stop short-circuit. Exit 0 so KeepAlive(SuccessfulExit:false)
|
|
614
|
+
// refuses to restart us.
|
|
615
|
+
if (existsSync(join(agentRoot, ".emergency-stop"))) {
|
|
616
|
+
info(agentRoot, ".emergency-stop detected — exiting 0");
|
|
617
|
+
stop();
|
|
618
|
+
process.exit(0);
|
|
619
|
+
}
|
|
620
|
+
state.heartbeatTimer = setTimeoutFn(tick, SUPERVISOR_INTERVAL_MS);
|
|
621
|
+
if (state.heartbeatTimer && typeof state.heartbeatTimer.unref === "function") {
|
|
622
|
+
state.heartbeatTimer.unref();
|
|
623
|
+
}
|
|
624
|
+
}, SUPERVISOR_INTERVAL_MS);
|
|
625
|
+
if (state.heartbeatTimer && typeof state.heartbeatTimer.unref === "function") {
|
|
626
|
+
state.heartbeatTimer.unref();
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// -------------------------------------------------------------------------
|
|
631
|
+
// start / stop lifecycle
|
|
632
|
+
// -------------------------------------------------------------------------
|
|
633
|
+
async function start() {
|
|
634
|
+
state.stopped = false;
|
|
635
|
+
startSupervisor();
|
|
636
|
+
await connect();
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function stop() {
|
|
640
|
+
state.stopped = true;
|
|
641
|
+
if (state.reconnectTimer) {
|
|
642
|
+
clearTimeoutFn(state.reconnectTimer);
|
|
643
|
+
state.reconnectTimer = null;
|
|
644
|
+
}
|
|
645
|
+
if (state.heartbeatTimer) {
|
|
646
|
+
clearTimeoutFn(state.heartbeatTimer);
|
|
647
|
+
state.heartbeatTimer = null;
|
|
648
|
+
}
|
|
649
|
+
if (state.socket) {
|
|
650
|
+
try { state.socket.close(); } catch { /* */ }
|
|
651
|
+
state.socket = null;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function getStats() {
|
|
656
|
+
return { ...state.stats };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function getState() {
|
|
660
|
+
return {
|
|
661
|
+
connected: !!state.socket && state.socket.readyState === 1,
|
|
662
|
+
backoffMs: state.backoffMs,
|
|
663
|
+
stopped: state.stopped,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return { start, stop, getStats, getState, _internals: { handleEnvelope, scheduleReconnect, state } };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// ---------------------------------------------------------------------------
|
|
671
|
+
// CLI entry point
|
|
672
|
+
// ---------------------------------------------------------------------------
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Boot the daemon when invoked as a script (not when imported by tests).
|
|
676
|
+
*/
|
|
677
|
+
async function main() {
|
|
678
|
+
const agentRoot = process.env.AGENT_ROOT || process.env.AGENT_DIR || AGENT_REPO_DIR || resolve(__dirname, "..", "..");
|
|
679
|
+
|
|
680
|
+
// Emergency-stop check before anything else (mirror maestro-daemon.mjs).
|
|
681
|
+
if (existsSync(join(agentRoot, ".emergency-stop"))) {
|
|
682
|
+
console.error("[slack-socket] .emergency-stop flag present — refusing to start.");
|
|
683
|
+
console.error("[slack-socket] Lift with: scripts/resume-operations.sh");
|
|
684
|
+
// Hold for a moment so KeepAlive's throttle has something to throttle.
|
|
685
|
+
await new Promise((r) => setTimeout(r, 30_000));
|
|
686
|
+
process.exit(0);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
loadEnvFromFile(agentRoot);
|
|
690
|
+
|
|
691
|
+
const appToken = process.env.SLACK_APP_LEVEL_TOKEN || "";
|
|
692
|
+
if (!appToken || !appToken.startsWith("xapp-")) {
|
|
693
|
+
error(agentRoot, "SLACK_APP_LEVEL_TOKEN missing or malformed (must start with xapp-). " +
|
|
694
|
+
"Run: node scripts/setup/init-slack-socket-mode.mjs");
|
|
695
|
+
process.exit(78); // EX_CONFIG
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const identity = loadAgentIdentity(agentRoot);
|
|
699
|
+
const peerSlackIds = loadPeerSlackIds(agentRoot, identity.slackMemberId);
|
|
700
|
+
|
|
701
|
+
info(agentRoot, "starting Socket Mode listener", {
|
|
702
|
+
agent: identity.firstName,
|
|
703
|
+
slack_id: identity.slackMemberId || "(unset)",
|
|
704
|
+
peers: peerSlackIds.size,
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
const client = createSocketModeClient({ agentRoot, appToken, identity, peerSlackIds });
|
|
708
|
+
const shutdown = (signal) => {
|
|
709
|
+
info(agentRoot, `received ${signal}, shutting down`);
|
|
710
|
+
client.stop();
|
|
711
|
+
setTimeout(() => process.exit(0), 250);
|
|
712
|
+
};
|
|
713
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
714
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
715
|
+
process.on("unhandledRejection", (reason) => {
|
|
716
|
+
error(agentRoot, `unhandled rejection: ${reason?.message || reason}`, {
|
|
717
|
+
stack: reason?.stack,
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
process.on("uncaughtException", (err) => {
|
|
721
|
+
error(agentRoot, `uncaught exception: ${err.message}`, { stack: err.stack });
|
|
722
|
+
// Let launchd restart us — non-zero exit triggers KeepAlive.Crashed.
|
|
723
|
+
setTimeout(() => process.exit(1), 250);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
await client.start();
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Only run main() when this file is executed directly (not when imported
|
|
730
|
+
// as a module by the test suite). The `process.argv[1]` check matches the
|
|
731
|
+
// pattern used elsewhere in the framework.
|
|
732
|
+
const isDirectInvocation = process.argv[1] && resolve(process.argv[1]) === __filename;
|
|
733
|
+
if (isDirectInvocation) {
|
|
734
|
+
main().catch((err) => {
|
|
735
|
+
console.error(`[slack-socket] fatal: ${err.message}`);
|
|
736
|
+
console.error(err.stack);
|
|
737
|
+
process.exit(1);
|
|
738
|
+
});
|
|
739
|
+
}
|