hivecode-mcp 0.12.4 → 0.13.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/hive-slack.js ADDED
@@ -0,0 +1,1809 @@
1
+ // hive-slack — the room speaks to Slack, and Slack speaks back into the room.
2
+ //
3
+ // node hive-slack.js "<relay|room>" --channel C123 [--name SlackBridge] [--dry-run]
4
+ // env: SLACK_BOT_TOKEN (xoxb-…), optional HIVE_SLACK_API to point at a different base URL
5
+ //
6
+ // WHY OUT AND NOT IN. A team will not move its conversation into our control room, and asking
7
+ // them to is how the integration dies quietly. So Slack stays where the humans are: the room
8
+ // posts there, and a reply in the thread comes back into the room. Nobody has to open a new tab
9
+ // to find out what happened overnight.
10
+ //
11
+ // ONE THREAD PER TASK. This is the decision the whole integration lives or dies on. Everything
12
+ // about a task -- its claims, its gate result, its completion -- goes into that task's own
13
+ // thread, and only the opening and the outcome reach the channel itself. Untasked room chat
14
+ // shares one room thread (__room__) so chatter never floods the main channel either.
15
+ //
16
+ // The thread map lives in ROOM STATE (or the caller's persistent store for makeSlackThreader),
17
+ // not in this process. A bridge that restarts must land its next update in the same Slack
18
+ // thread, or a task's history splits in half and the thread stops being worth reading.
19
+ //
20
+ // Error codes Q630-Q636 are this module's:
21
+ // Q630 SLACK_BOT_TOKEN missing, invalid, or revoked at preflight
22
+ // Q631 --channel missing, not found, or the bot has not been invited (/invite @Hivecode)
23
+ // Q632 a Slack bridge with this name is already running in the room
24
+ // Q634 SLACK_APP_TOKEN (xapp-…) invalid or missing connections:write for Socket Mode
25
+ // Q635 multiple concurrent Socket Mode processes on same app token
26
+ // Q636 Slack API unreachable (network, DNS, timeout; token not rejected)
27
+
28
+ import WebSocket from 'ws'
29
+ import { startSync, parseLink } from './sync.js'
30
+ import { scrub } from './hive-webhook.js'
31
+
32
+ const DEFAULT_API = 'https://slack.com/api'
33
+ const ROOM_THREAD_KEY = '__room__'
34
+ const CURSOR_KEY = '__cursor__'
35
+ const MAX_SEEN_EVENTS = 500
36
+
37
+ export const USAGE = `usage:
38
+ SLACK_BOT_TOKEN=xoxb-… npx -y hivecode-mcp@latest slack start "<relay|room>" --channel <id> [--name SlackBridge] [--allow <U123,U456>] [--dry-run] [--start-retry <ms>]
39
+ SLACK_BOT_TOKEN=xoxb-… [SLACK_APP_TOKEN=xapp-…] npx -y hivecode-mcp@latest slack check --channel <id> [--agent] [--timeout <ms>] [--start-retry <ms>]
40
+ SLACK_BOT_TOKEN=xoxb-… SLACK_APP_TOKEN=xapp-… npx -y hivecode-mcp@latest slack agent "<relay|room>" --channel <id> [--name <name>] [--allow <U123,U456>]
41
+ SLACK_SIGNING_SECRET=… SLACK_BOT_TOKEN=xoxb-… npx -y hivecode-mcp@latest slack mcp-server "<relay|room>" [--port 8790] [--auth slack|none] [--allow <U123,U456>] [--trust-proxy]
42
+ `
43
+
44
+ export const NON_RETRYABLE = new Set([
45
+ 'invalid_auth',
46
+ 'token_revoked',
47
+ 'account_inactive',
48
+ 'not_authed',
49
+ 'not_in_channel',
50
+ 'channel_not_found',
51
+ 'is_archived',
52
+ 'missing_scope',
53
+ 'ekm_access_denied',
54
+ ])
55
+
56
+ function extractSlackError(err) {
57
+ if (!err) return ''
58
+ if (err.slackError) return String(err.slackError)
59
+ if (err.code && typeof err.code === 'string') return err.code
60
+ const m = /slack [^:]+:\s*([a-z0-9_]+)/i.exec(err.message || '')
61
+ return m ? m[1].toLowerCase() : ''
62
+ }
63
+
64
+ function formatSlackWhy(err) {
65
+ if (!err) return 'unknown_error'
66
+ const code = extractSlackError(err) || err.message || 'unknown_error'
67
+ if (code === 'missing_scope' && err.needed) return `missing_scope: needs ${err.needed}`
68
+ return code
69
+ }
70
+
71
+ export function isRateLimitError(err) {
72
+ if (!err) return false
73
+ if (err.status === 429) return true
74
+ const code = extractSlackError(err)
75
+ return code === 'ratelimited'
76
+ }
77
+
78
+ export function isPermanentSlackError(err) {
79
+ const code = extractSlackError(err)
80
+ return NON_RETRYABLE.has(code)
81
+ }
82
+
83
+ /** Scrub secrets/tokens (via hive-webhook.js) AND strip room links / room IDs so a room's
84
+ * capability link never leaks to an external Slack channel. */
85
+ export function scrubSlack(text, { room = '', link = '' } = {}) {
86
+ let s = scrub(String(text || ''))
87
+ if (link) s = s.split(link).join('[room link removed]')
88
+ // Any wss://...|room-... or ws://...|room-... link
89
+ s = s.replace(/\bwss?:\/\/[^\s|"'<>]+\|[^\s"'<>]+/gi, '[room link removed]')
90
+ if (room) s = s.split(room).join('[room id removed]')
91
+ s = s.replace(/\broom-[A-Za-z0-9_-]{6,}\b/g, '[room id removed]')
92
+ return s
93
+ }
94
+
95
+ // Slack allows roughly one message per second per channel and silently reorders or drops when
96
+ // you exceed it. A room can emit a dozen events in a burst, so everything leaves through one
97
+ // paced queue rather than going straight out.
98
+ //
99
+ // 429 / ratelimited is retried in-place at the head of the queue up to maxRetries (5) times,
100
+ // honouring Retry-After. Permanent auth/channel errors (invalid_auth, token_revoked,
101
+ // not_in_channel, channel_not_found) are never retried: reported once via onError, then the
102
+ // queue halts so a dead token or missing invite never hammers Slack in a loop.
103
+ export function makeQueue({
104
+ send,
105
+ minGapMs = 1000,
106
+ maxRetries = 5,
107
+ now = () => Date.now(),
108
+ timer = setTimeout,
109
+ onError = null,
110
+ }) {
111
+ const q = []
112
+ let running = false, last = 0
113
+ let halted = false, haltedErr = null
114
+
115
+ async function pump() {
116
+ if (running) return
117
+ running = true
118
+ while (q.length) {
119
+ const job = q[0]
120
+ if (halted) {
121
+ q.shift()
122
+ job.reject(haltedErr || new Error('slack queue halted'))
123
+ continue
124
+ }
125
+ const wait = Math.max(0, minGapMs - (now() - last))
126
+ if (wait > 0) await new Promise((r) => timer(r, wait))
127
+ last = now()
128
+ try {
129
+ const res = await send(job.msg)
130
+ q.shift()
131
+ job.resolve(res)
132
+ } catch (e) {
133
+ if (isRateLimitError(e) && job.attempts < maxRetries) {
134
+ job.attempts++
135
+ const retryWait = Number(e.retryAfterMs) > 0 ? Number(e.retryAfterMs) : Math.max(minGapMs, 1000)
136
+ await new Promise((r) => timer(r, retryWait))
137
+ last = now()
138
+ continue
139
+ }
140
+ if (isPermanentSlackError(e)) {
141
+ halted = true
142
+ haltedErr = e
143
+ if (typeof onError === 'function') {
144
+ try { onError(e) } catch { /* reporter must not crash the queue */ }
145
+ }
146
+ } else if (typeof onError === 'function') {
147
+ try { onError(e) } catch { }
148
+ }
149
+ q.shift()
150
+ job.reject(e)
151
+ }
152
+ }
153
+ running = false
154
+ }
155
+
156
+ return {
157
+ push: (msg) => {
158
+ if (halted) return Promise.reject(haltedErr || new Error('slack queue halted'))
159
+ return new Promise((resolve, reject) => { q.push({ msg, attempts: 0, resolve, reject }); pump() })
160
+ },
161
+ depth: () => q.length,
162
+ isHalted: () => halted,
163
+ haltedError: () => haltedErr,
164
+ }
165
+ }
166
+
167
+ export const SLACK_TIMEOUT_MS = 15000
168
+
169
+ /** POST to Slack's Web API. Kept tiny and injectable so the bridge can be tested without a
170
+ * workspace -- the thing worth testing is our routing, not Slack's HTTP.
171
+ * Applies a 15s AbortSignal timeout by default so a hung TCP socket never stalls the queue. */
172
+ export async function slackPost(
173
+ method,
174
+ body,
175
+ token,
176
+ fetchImpl = fetch,
177
+ apiBase = process.env.HIVE_SLACK_API || DEFAULT_API,
178
+ timeoutMs = Number(process.env.HIVE_SLACK_TIMEOUT_MS) || SLACK_TIMEOUT_MS,
179
+ ) {
180
+ const r = await fetchImpl(`${apiBase}/${method}`, {
181
+ method: 'POST',
182
+ headers: { 'Content-Type': 'application/json; charset=utf-8', Authorization: `Bearer ${token}` },
183
+ body: JSON.stringify(body),
184
+ signal: AbortSignal.timeout(timeoutMs),
185
+ })
186
+ const retryHeader = r.headers && typeof r.headers.get === 'function' ? Number(r.headers.get('retry-after') || 0) : 0
187
+ const rawScopes = r.headers && typeof r.headers.get === 'function'
188
+ ? r.headers.get('x-oauth-scopes')
189
+ : (r.headers && (r.headers['x-oauth-scopes'] || r.headers['X-OAuth-Scopes']))
190
+ const j = await r.json().catch(() => ({ ok: false, error: r.status === 429 ? 'ratelimited' : 'bad_json' }))
191
+ if (r.status === 429 || (j && j.error === 'ratelimited')) {
192
+ const retrySec = retryHeader || Number(j && j.retry_after) || 1
193
+ const err = new Error(`slack ${method}: ratelimited`)
194
+ err.status = 429
195
+ err.code = 'ratelimited'
196
+ err.slackError = 'ratelimited'
197
+ err.retryAfterMs = retrySec * 1000
198
+ throw err
199
+ }
200
+ if (!j || !j.ok) {
201
+ const code = (j && j.error) || `http_${r.status || 'unknown'}`
202
+ const needed = j && j.needed ? String(j.needed) : ''
203
+ const provided = j && j.provided ? String(j.provided) : ''
204
+ const detail = code === 'missing_scope' && needed ? `${code} (needs ${needed})` : code
205
+ const err = new Error(`slack ${method}: ${detail}`)
206
+ err.status = r.status
207
+ err.code = code
208
+ err.slackError = code
209
+ if (needed) err.needed = needed
210
+ if (provided) err.provided = provided
211
+ throw err
212
+ }
213
+ if (rawScopes !== undefined && rawScopes !== null && j && typeof j === 'object') {
214
+ const parsedScopes = String(rawScopes).split(',').map((s) => s.trim()).filter(Boolean)
215
+ Object.defineProperty(j, '_scopes', { value: parsedScopes, enumerable: false, configurable: true })
216
+ }
217
+ return j
218
+ }
219
+
220
+ /** Reusable threaded Slack sender for both hive-slack.js and hive-github.js.
221
+ * `threads` is any Map-like store exposing .get(key) and .set(key, ts).
222
+ * NOTE: open(key, text) and reply(key, text, { broadcast }) never throw on Slack API errors;
223
+ * they return { ok: true, ts, ... } or { ok: false, error, code, halted }. Always check .ok
224
+ * on the returned object. */
225
+ export function makeSlackThreader({
226
+ token,
227
+ channel,
228
+ threads = new Map(),
229
+ api = process.env.HIVE_SLACK_API || DEFAULT_API,
230
+ post = null,
231
+ minGapMs = 1000,
232
+ maxRetries = 5,
233
+ now = () => Date.now(),
234
+ timer = setTimeout,
235
+ onError = null,
236
+ room = '',
237
+ link = '',
238
+ }) {
239
+ const sendImpl = post || ((m) => slackPost('chat.postMessage', m, token, fetch, api))
240
+ const queue = makeQueue({ send: sendImpl, minGapMs, maxRetries, now, timer, onError })
241
+ const opening = new Map() // key -> in-flight open Promise, so synchronous bursts share one root thread
242
+
243
+ async function open(key, text) {
244
+ if (queue.isHalted()) return { ok: false, halted: true, error: queue.haltedError()?.message || 'halted' }
245
+ const clean = scrubSlack(text, { room, link })
246
+ if (key && opening.has(key)) {
247
+ try { await opening.get(key) } catch { }
248
+ }
249
+ const existing = key ? threads.get(key) : undefined
250
+ if (existing) return { ok: true, ts: existing, reused: true }
251
+ const run = (async () => {
252
+ const res = await queue.push({ channel, text: clean })
253
+ if (res && res.ts && key) threads.set(key, res.ts)
254
+ return res
255
+ })()
256
+ if (key) opening.set(key, run)
257
+ try {
258
+ const res = await run
259
+ return { ok: true, ts: res && res.ts }
260
+ } catch (e) {
261
+ return { ok: false, error: e.message, code: extractSlackError(e) }
262
+ } finally {
263
+ if (key && opening.get(key) === run) opening.delete(key)
264
+ }
265
+ }
266
+
267
+ async function reply(key, text, { broadcast = false } = {}) {
268
+ if (queue.isHalted()) return { ok: false, halted: true, error: queue.haltedError()?.message || 'halted' }
269
+ const clean = scrubSlack(text, { room, link })
270
+ if (key && opening.has(key)) {
271
+ try { await opening.get(key) } catch { }
272
+ }
273
+ const ts = key ? threads.get(key) : undefined
274
+ if (!ts) return open(key, clean)
275
+ try {
276
+ const payload = { channel, text: clean, thread_ts: ts }
277
+ if (broadcast) payload.reply_broadcast = true
278
+ const res = await queue.push(payload)
279
+ return { ok: true, ts: res && res.ts, thread_ts: ts }
280
+ } catch (e) {
281
+ return { ok: false, error: e.message, code: extractSlackError(e) }
282
+ }
283
+ }
284
+
285
+ return {
286
+ open,
287
+ reply,
288
+ threads,
289
+ queue,
290
+ halted: () => ({ halted: queue.isHalted(), error: queue.haltedError()?.message || '' }),
291
+ }
292
+ }
293
+
294
+ /** Verify token and channel membership BEFORE printing READY (Q630, Q631). */
295
+ export async function preflightSlack({
296
+ token,
297
+ channel,
298
+ api = process.env.HIVE_SLACK_API || DEFAULT_API,
299
+ fetchImpl = fetch,
300
+ } = {}) {
301
+ if (!token || !String(token).trim()) {
302
+ return { ok: false, code: 'Q630', error: 'Q630 SLACK_BOT_TOKEN is missing. Set SLACK_BOT_TOKEN=xoxb-… in your environment.' }
303
+ }
304
+ if (!channel || !String(channel).trim()) {
305
+ return { ok: false, code: 'Q631', error: 'Q631 --channel <id> is required (for example: --channel C0123456789).' }
306
+ }
307
+ let auth
308
+ try {
309
+ auth = await slackPost('auth.test', {}, token, fetchImpl, api)
310
+ } catch (e) {
311
+ if (!e || !e.slackError) {
312
+ const why = formatSlackWhy(e)
313
+ return { ok: false, transient: true, code: 'Q636', error: `Q636 could not reach Slack (${why}). The token was not checked; check this machine's network and try again.` }
314
+ }
315
+ const why = formatSlackWhy(e)
316
+ return { ok: false, code: 'Q630', error: `Q630 SLACK_BOT_TOKEN was rejected by Slack (${why}). Check the token or reinstall the Slack app.` }
317
+ }
318
+ const botUser = auth.user || 'Hivecode'
319
+ let info
320
+ try {
321
+ info = await slackPost('conversations.info', { channel }, token, fetchImpl, api)
322
+ } catch (e) {
323
+ if (!e || !e.slackError) {
324
+ const why = formatSlackWhy(e)
325
+ return { ok: false, transient: true, code: 'Q636', error: `Q636 could not reach Slack (${why}). The token was not checked; check this machine's network and try again.` }
326
+ }
327
+ const why = formatSlackWhy(e)
328
+ return { ok: false, code: 'Q631', error: `Q631 Slack channel ${channel} could not be accessed (${why}). Check the channel ID and run /invite @${botUser} in the channel.` }
329
+ }
330
+ if (info && info.channel && info.channel.is_member === false) {
331
+ const chName = info.channel.name ? `#${info.channel.name}` : channel
332
+ return { ok: false, code: 'Q631', error: `Q631 @${botUser} is not a member of ${chName} (${channel}). In Slack, open ${chName} and run: /invite @${botUser}` }
333
+ }
334
+ const scopes = Array.isArray(auth && auth._scopes)
335
+ ? auth._scopes
336
+ : (Array.isArray(info && info._scopes) ? info._scopes : null)
337
+ const isPrivate = Boolean(
338
+ info && info.channel && (
339
+ info.channel.is_private === true ||
340
+ info.channel.is_group === true ||
341
+ String(channel || '').startsWith('G')
342
+ ),
343
+ )
344
+ return {
345
+ ok: true,
346
+ botUser,
347
+ botUserId: auth.user_id || '',
348
+ team: auth.team || '',
349
+ teamId: auth.team_id || '',
350
+ channelName: (info && info.channel && info.channel.name) || channel,
351
+ isPrivate,
352
+ scopes,
353
+ }
354
+ }
355
+
356
+ /** Return the list of missing inbound scopes (users:read, plus channels:history for public or groups:history for private).
357
+ * When `scopes` is null/undefined (header absent), returns [] so "unknown" is never treated as "none". */
358
+ export function missingInboundScopes({ scopes = null, isPrivate = false, channel = '' } = {}) {
359
+ if (scopes === null || scopes === undefined) return []
360
+ const have = new Set(
361
+ (Array.isArray(scopes) ? scopes : String(scopes || '').split(','))
362
+ .map((s) => String(s || '').trim())
363
+ .filter(Boolean),
364
+ )
365
+ const histScope = (isPrivate || String(channel || '').startsWith('G')) ? 'groups:history' : 'channels:history'
366
+ const missing = []
367
+ if (!have.has('users:read')) missing.push('users:read')
368
+ if (!have.has(histScope)) missing.push(histScope)
369
+ return missing
370
+ }
371
+
372
+ /** Verify that SLACK_BOT_TOKEN has the scopes required for inbound replies (users:read + channels:history or groups:history). */
373
+ export function checkInboundScopes({ scopes = null, isPrivate = false, channel = '' } = {}) {
374
+ const missing = missingInboundScopes({ scopes, isPrivate, channel })
375
+ if (missing.length) {
376
+ return {
377
+ ok: false,
378
+ code: 'Q634',
379
+ missingScopes: missing,
380
+ error: `Q634 SLACK_BOT_TOKEN is missing required inbound scope(s): ${missing.join(', ')}. Reinstall your Slack app with slack-app-manifest-inbound.json (docs/SLACK_SETUP.md Section 5).`,
381
+ }
382
+ }
383
+ return { ok: true, missingScopes: [] }
384
+ }
385
+
386
+ /** Return the list of missing Slack Agent surface scopes (chat:write, assistant:write, users:read, plus im:history / channels:history / groups:history).
387
+ * When `scopes` is null/undefined (header absent), returns [] so "unknown" is never treated as "none". */
388
+ export function missingAgentScopes({ scopes = null, isPrivate = false, channel = '' } = {}) {
389
+ if (scopes === null || scopes === undefined) return []
390
+ const have = new Set(
391
+ (Array.isArray(scopes) ? scopes : String(scopes || '').split(','))
392
+ .map((s) => String(s || '').trim())
393
+ .filter(Boolean),
394
+ )
395
+ const ch = String(channel || '').trim()
396
+ const histScope = (!ch || ch.startsWith('D'))
397
+ ? 'im:history'
398
+ : ((isPrivate || ch.startsWith('G')) ? 'groups:history' : 'channels:history')
399
+ const required = ['chat:write', 'assistant:write', histScope, 'users:read']
400
+ return required.filter((s) => !have.has(s))
401
+ }
402
+
403
+ /** Verify that SLACK_BOT_TOKEN has the scopes required for the Slack Agent surface (hive-slack-agent.js). */
404
+ export function checkAgentScopes({ scopes = null, isPrivate = false, channel = '' } = {}) {
405
+ const missing = missingAgentScopes({ scopes, isPrivate, channel })
406
+ if (missing.length) {
407
+ return {
408
+ ok: false,
409
+ code: 'Q634',
410
+ missingScopes: missing,
411
+ error: `Q634 SLACK_BOT_TOKEN is missing required agent scope(s): ${missing.join(', ')}. Reinstall your Slack app with slack-app-manifest-agent.json (docs/SLACK_SETUP.md Section 5).`,
412
+ }
413
+ }
414
+ return { ok: true, missingScopes: [] }
415
+ }
416
+
417
+ /** Read Slack warnings from both shapes Slack uses: top-level `warning` string and `response_metadata.warnings[]`. */
418
+ export function extractSlackWarnings(res) {
419
+ if (!res || typeof res !== 'object') return []
420
+ const out = []
421
+ if (typeof res.warning === 'string' && res.warning.trim()) {
422
+ for (const w of res.warning.split(',')) {
423
+ if (w.trim()) out.push(w.trim())
424
+ }
425
+ }
426
+ const metaWarnings = res.response_metadata && Array.isArray(res.response_metadata.warnings)
427
+ ? res.response_metadata.warnings
428
+ : []
429
+ for (const w of metaWarnings) {
430
+ if (typeof w === 'string' && w.trim() && !out.includes(w.trim())) out.push(w.trim())
431
+ }
432
+ return out
433
+ }
434
+
435
+ /** Verify SLACK_APP_TOKEN (xapp-…) with apps.connections.open (and inbound bot scopes when passed) before READY (Q634). */
436
+ export async function preflightSocketMode({
437
+ appToken,
438
+ scopes = null,
439
+ isPrivate = false,
440
+ channel = '',
441
+ api = process.env.HIVE_SLACK_API || DEFAULT_API,
442
+ fetchImpl = fetch,
443
+ } = {}) {
444
+ if (!appToken || !String(appToken).trim()) {
445
+ return {
446
+ ok: false,
447
+ code: 'Q634',
448
+ error: 'Q634 SLACK_APP_TOKEN is missing. Set SLACK_APP_TOKEN=xapp-… (with connections:write scope) to enable inbound Socket Mode.',
449
+ }
450
+ }
451
+ if (scopes !== null && scopes !== undefined) {
452
+ const sc = checkInboundScopes({ scopes, isPrivate, channel })
453
+ if (!sc.ok) return sc
454
+ }
455
+ try {
456
+ const res = await slackPost('apps.connections.open', {}, appToken, fetchImpl, api)
457
+ if (!res || !res.url) {
458
+ return { ok: false, code: 'Q634', error: 'Q634 Slack apps.connections.open did not return a WebSocket URL.' }
459
+ }
460
+ return { ok: true, url: res.url }
461
+ } catch (e) {
462
+ if (!e || !e.slackError) {
463
+ const why = formatSlackWhy(e)
464
+ return { ok: false, transient: true, code: 'Q636', error: `Q636 could not reach Slack (${why}). The token was not checked; check this machine's network and try again.` }
465
+ }
466
+ const why = extractSlackError(e) || e.message
467
+ return {
468
+ ok: false,
469
+ code: 'Q634',
470
+ error: `Q634 SLACK_APP_TOKEN was rejected by Slack (${why}). Enable Socket Mode and generate an App-Level Token (xapp-…) with connections:write.`,
471
+ }
472
+ }
473
+ }
474
+
475
+ /** Connect to Slack Socket Mode over ws. Immediately acknowledges every envelope_id (<3s),
476
+ * deduplicates event_id across reconnects, reconnects on Slack's periodic disconnect frame,
477
+ * and halts on permanent token errors. */
478
+ export async function connectSocketMode({
479
+ appToken,
480
+ initialUrl = '',
481
+ api = process.env.HIVE_SLACK_API || DEFAULT_API,
482
+ fetchImpl = fetch,
483
+ WebSocketImpl = WebSocket,
484
+ onEvent,
485
+ onError = null,
486
+ onMultiConnection = null,
487
+ logErr = console.error,
488
+ reconnectDelayMs = 1000,
489
+ maxReconnectDelayMs = 60000,
490
+ pingIntervalMs = Number(process.env.HIVE_SLACK_PING_INTERVAL_MS) || 30000,
491
+ probeTimeoutMs = Number(process.env.HIVE_SLACK_PROBE_TIMEOUT_MS) || 5000,
492
+ helloTimeoutMs = Number(process.env.HIVE_SLACK_HELLO_TIMEOUT_MS) || 10000,
493
+ startRetryMs = Number(process.env.HIVE_SLACK_START_RETRY_MS) || 60000,
494
+ startRetryDelayMs = 1000,
495
+ timer = setTimeout,
496
+ } = {}) {
497
+ let ws = null
498
+ let stopped = false
499
+ let halted = false
500
+ let reconnectTimer = null
501
+ const seenEvents = new Set()
502
+ let lastCloseTime = 0
503
+ let warnedMulti = false
504
+ let reconnectAttempts = 0
505
+ let inOutage = false
506
+
507
+ function getNextReconnectDelay() {
508
+ const delay = Math.min(maxReconnectDelayMs, reconnectDelayMs * Math.pow(2, reconnectAttempts))
509
+ reconnectAttempts++
510
+ return delay
511
+ }
512
+
513
+ function rememberEvent(id) {
514
+ if (!id) return false
515
+ if (seenEvents.has(id)) return true
516
+ seenEvents.add(id)
517
+ if (seenEvents.size > MAX_SEEN_EVENTS) {
518
+ const oldest = seenEvents.values().next().value
519
+ seenEvents.delete(oldest)
520
+ }
521
+ return false
522
+ }
523
+
524
+ function wireSocket(sock) {
525
+ let lastActivity = Date.now()
526
+ let probeTimer = null
527
+ let watchdogTimer = null
528
+ let healthyTimer = null
529
+
530
+ function recordActivity() {
531
+ lastActivity = Date.now()
532
+ if (probeTimer) {
533
+ clearTimeout(probeTimer)
534
+ probeTimer = null
535
+ scheduleWatchdog()
536
+ }
537
+ }
538
+
539
+ function scheduleWatchdog() {
540
+ if (stopped || halted || ws !== sock || probeTimer) return
541
+ if (watchdogTimer) clearTimeout(watchdogTimer)
542
+ const silence = Date.now() - lastActivity
543
+ const delay = Math.max(50, pingIntervalMs - silence)
544
+ watchdogTimer = timer(checkWatchdog, delay)
545
+ }
546
+
547
+ function checkWatchdog() {
548
+ if (stopped || halted || ws !== sock) return
549
+ const silence = Date.now() - lastActivity
550
+ if (silence >= pingIntervalMs) {
551
+ if (!probeTimer) {
552
+ try {
553
+ if (typeof sock.ping === 'function') sock.ping()
554
+ } catch { }
555
+
556
+ probeTimer = timer(() => {
557
+ if (stopped || halted || ws !== sock) return
558
+ if (!inOutage) {
559
+ inOutage = true
560
+ if (typeof logErr === 'function') {
561
+ try {
562
+ logErr(`Slack Socket Mode connection went silent (no ping or message for ${Math.round((Date.now() - lastActivity) / 1000)} s); terminating to reconnect...`)
563
+ } catch { }
564
+ }
565
+ }
566
+ try { sock.terminate ? sock.terminate() : sock.close() } catch { }
567
+ }, probeTimeoutMs)
568
+ }
569
+ return
570
+ }
571
+ scheduleWatchdog()
572
+ }
573
+
574
+ scheduleWatchdog()
575
+
576
+ sock.on('message', (raw) => {
577
+ recordActivity()
578
+ let frame
579
+ try { frame = JSON.parse(String(raw)) } catch { return }
580
+ // Acknowledge every envelope immediately (<3s) so Slack never retries and duplicates a stop/reply.
581
+ if (frame && frame.envelope_id) {
582
+ try { sock.send(JSON.stringify({ envelope_id: frame.envelope_id })) } catch { }
583
+ }
584
+ if (!frame) return
585
+ if (frame.type === 'hello') {
586
+ if (inOutage) {
587
+ inOutage = false
588
+ if (typeof logErr === 'function') {
589
+ try { logErr('Slack Socket Mode reconnected.') } catch { }
590
+ }
591
+ }
592
+ if (healthyTimer) clearTimeout(healthyTimer)
593
+ const healthyDelay = Math.min(pingIntervalMs, reconnectDelayMs * 2)
594
+ healthyTimer = timer(() => {
595
+ if (!stopped && !halted && ws === sock) {
596
+ reconnectAttempts = 0
597
+ }
598
+ }, healthyDelay)
599
+ const num = Number(frame.num_connections) || 1
600
+ const isOwnLingering = (num === 2 && (Date.now() - lastCloseTime < 15000))
601
+ if (num > 1 && !isOwnLingering) {
602
+ if (!warnedMulti) {
603
+ warnedMulti = true
604
+ if (typeof logErr === 'function') {
605
+ try {
606
+ logErr(`WARNING: Slack Socket Mode reports ${num} active connections on this SLACK_APP_TOKEN. Events may go to another process (check for another terminal running "hivecode-mcp slack start", "github start --slack-allow", or "hive-slack-agent").`)
607
+ } catch { }
608
+ }
609
+ if (typeof onMultiConnection === 'function') {
610
+ try { onMultiConnection(num) } catch { }
611
+ }
612
+ }
613
+ } else if (num <= 1) {
614
+ warnedMulti = false
615
+ }
616
+ return
617
+ }
618
+ if (frame.type === 'disconnect') {
619
+ try { sock.close() } catch { }
620
+ return
621
+ }
622
+ const evId = (frame.payload && frame.payload.event_id) || frame.envelope_id || ''
623
+ if (evId && rememberEvent(evId)) return
624
+ const ev = frame.payload && frame.payload.event
625
+ if (ev && typeof onEvent === 'function') {
626
+ Promise.resolve(onEvent(ev, frame.payload)).catch((e) => {
627
+ if (typeof onError === 'function') { try { onError(e) } catch { } }
628
+ })
629
+ }
630
+ })
631
+
632
+ sock.on('ping', () => {
633
+ recordActivity()
634
+ })
635
+
636
+ sock.on('pong', () => {
637
+ recordActivity()
638
+ })
639
+
640
+ sock.on('error', (e) => {
641
+ if (typeof onError === 'function') { try { onError(e) } catch { } }
642
+ })
643
+
644
+ sock.on('close', () => {
645
+ lastCloseTime = Date.now()
646
+ if (probeTimer) { clearTimeout(probeTimer); probeTimer = null }
647
+ if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null }
648
+ if (healthyTimer) { clearTimeout(healthyTimer); healthyTimer = null }
649
+ if (ws === sock) ws = null
650
+ if (!stopped && !halted) {
651
+ const delay = getNextReconnectDelay()
652
+ reconnectTimer = timer(() => openSocket(''), delay)
653
+ }
654
+ })
655
+ }
656
+
657
+ async function openSocket(firstUrl = '') {
658
+ if (stopped || halted) return
659
+ let url = firstUrl
660
+ if (!url) {
661
+ try {
662
+ const res = await slackPost('apps.connections.open', {}, appToken, fetchImpl, api)
663
+ url = res && res.url
664
+ } catch (e) {
665
+ if (isPermanentSlackError(e)) {
666
+ halted = true
667
+ if (typeof onError === 'function') { try { onError(e) } catch { } }
668
+ return
669
+ }
670
+ if (typeof onError === 'function') { try { onError(e) } catch { } }
671
+ if (!stopped) {
672
+ const delay = getNextReconnectDelay()
673
+ reconnectTimer = timer(() => openSocket(''), delay)
674
+ }
675
+ return
676
+ }
677
+ }
678
+ if (!url || stopped || halted) return
679
+ const sock = new WebSocketImpl(url)
680
+ ws = sock
681
+ wireSocket(sock)
682
+ }
683
+
684
+ // Initial connection handshake with startRetryMs retry for transient socket/network errors and num_connections > 1
685
+ const startTime = Date.now()
686
+ let currentUrl = initialUrl
687
+ let lastWhy = ''
688
+ let lastNum = 1
689
+ let warnedRetry = false
690
+
691
+ while (true) {
692
+ if (stopped || halted) return { stop: () => {}, isHalted: () => true }
693
+ if (!currentUrl) {
694
+ try {
695
+ const res = await slackPost('apps.connections.open', {}, appToken, fetchImpl, api)
696
+ currentUrl = res && res.url
697
+ } catch (e) {
698
+ if (isPermanentSlackError(e)) {
699
+ halted = true
700
+ throw e
701
+ }
702
+ lastWhy = extractSlackError(e) || e.message
703
+ const elapsed = Date.now() - startTime
704
+ if (elapsed < startRetryMs) {
705
+ if (!warnedRetry && typeof logErr === 'function') {
706
+ warnedRetry = true
707
+ try { logErr(`Slack Socket Mode could not connect (${lastWhy}); retrying for up to ${Math.round(startRetryMs / 1000)} s...`) } catch { }
708
+ }
709
+ await new Promise((r) => timer(r, Math.min(startRetryDelayMs, startRetryMs - elapsed)))
710
+ continue
711
+ }
712
+ halted = true
713
+ stopped = true
714
+ const err = new Error(`Q634 Socket Mode could not connect: ${lastWhy}`)
715
+ err.code = 'Q634'
716
+ throw err
717
+ }
718
+ }
719
+ if (!currentUrl) {
720
+ const err = new Error('Q634 Slack apps.connections.open did not return a WebSocket URL.')
721
+ err.code = 'Q634'
722
+ throw err
723
+ }
724
+
725
+ let sock = null
726
+ const handshake = await new Promise((resolve) => {
727
+ let settled = false
728
+ const finish = (result) => {
729
+ if (settled) return
730
+ settled = true
731
+ clearTimeout(hTimer)
732
+ resolve(result)
733
+ }
734
+ const hTimer = timer(() => finish({ type: 'timeout', reason: 'no hello frame received within timeout' }), helloTimeoutMs)
735
+ try {
736
+ sock = new WebSocketImpl(currentUrl)
737
+ } catch (e) {
738
+ finish({ type: 'error', error: e, reason: e.message })
739
+ return
740
+ }
741
+ ws = sock
742
+
743
+ const onMsg = (raw) => {
744
+ let frame
745
+ try { frame = JSON.parse(String(raw)) } catch { return }
746
+ if (frame && frame.envelope_id) {
747
+ try { sock.send(JSON.stringify({ envelope_id: frame.envelope_id })) } catch { }
748
+ }
749
+ if (!frame) return
750
+ if (frame.type === 'hello') {
751
+ finish({ type: 'hello', frame, sock, onMsg, onErr, onClose })
752
+ return
753
+ }
754
+ }
755
+
756
+ const onErr = (e) => {
757
+ finish({ type: 'error', error: e, reason: e.message || String(e) })
758
+ }
759
+
760
+ const onClose = () => {
761
+ lastCloseTime = Date.now()
762
+ finish({ type: 'close', reason: 'connection closed before receiving hello frame' })
763
+ }
764
+
765
+ sock.on('message', onMsg)
766
+ sock.on('error', onErr)
767
+ sock.on('close', onClose)
768
+ })
769
+
770
+ if (handshake.type === 'hello') {
771
+ const num = Number(handshake.frame && handshake.frame.num_connections) || 1
772
+ lastNum = num
773
+ if (num <= 1) {
774
+ wireSocket(handshake.sock)
775
+ try {
776
+ handshake.sock.removeListener('message', handshake.onMsg)
777
+ handshake.sock.removeListener('close', handshake.onClose)
778
+ handshake.sock.removeListener('error', handshake.onErr)
779
+ } catch { }
780
+ break
781
+ }
782
+
783
+ lastWhy = `${num} active connections`
784
+ const elapsed = Date.now() - startTime
785
+ if (elapsed < startRetryMs) {
786
+ if (!warnedRetry && typeof logErr === 'function') {
787
+ warnedRetry = true
788
+ try { logErr(`Slack reports ${num} connections on this app token; waiting up to ${Math.round(startRetryMs / 1000)} s for the other to drop...`) } catch { }
789
+ }
790
+ try { handshake.sock.removeAllListeners && handshake.sock.removeAllListeners() } catch { }
791
+ try { handshake.sock.close() } catch { }
792
+ const waitTime = Math.min(startRetryDelayMs, Math.max(50, startRetryMs - elapsed))
793
+ await new Promise((r) => timer(r, waitTime))
794
+ currentUrl = initialUrl || ''
795
+ continue
796
+ }
797
+
798
+ halted = true
799
+ stopped = true
800
+ try { handshake.sock.close() } catch { }
801
+ const err = new Error(`Q635 Another process is already connected to Slack Socket Mode with this SLACK_APP_TOKEN (${num} active connections). Slack distributes incoming events across connections, so messages and "stop" commands may go to the other process. Check for another terminal or service running "hivecode-mcp slack start", "github start --slack-allow", or "hive-slack-agent" with the same app token. If you just stopped a bridge, wait a minute for Slack to release the connection and start again.`)
802
+ err.code = 'Q635'
803
+ throw err
804
+ }
805
+
806
+ // Handshake failed (socket error, timeout, or close-before-hello)
807
+ lastWhy = handshake.reason || (handshake.error && handshake.error.message) || 'could not connect'
808
+ if (sock) {
809
+ try { sock.removeAllListeners && sock.removeAllListeners() } catch { }
810
+ try { sock.close() } catch { }
811
+ try { sock.terminate && sock.terminate() } catch { }
812
+ }
813
+
814
+ const elapsed = Date.now() - startTime
815
+ if (elapsed < startRetryMs) {
816
+ if (!warnedRetry && typeof logErr === 'function') {
817
+ warnedRetry = true
818
+ try { logErr(`Slack Socket Mode could not connect (${lastWhy}); retrying for up to ${Math.round(startRetryMs / 1000)} s...`) } catch { }
819
+ }
820
+ const waitTime = Math.min(startRetryDelayMs, Math.max(50, startRetryMs - elapsed))
821
+ await new Promise((r) => timer(r, waitTime))
822
+ currentUrl = initialUrl || ''
823
+ continue
824
+ }
825
+
826
+ halted = true
827
+ stopped = true
828
+ const err = new Error(`Q634 Socket Mode could not connect: ${lastWhy}`)
829
+ err.code = 'Q634'
830
+ throw err
831
+ }
832
+
833
+ return {
834
+ stop: () => {
835
+ stopped = true
836
+ if (reconnectTimer) clearTimeout(reconnectTimer)
837
+ if (ws) {
838
+ try { ws.close() } catch { }
839
+ try { ws.terminate && ws.terminate() } catch { }
840
+ ws = null
841
+ }
842
+ },
843
+ isHalted: () => halted,
844
+ }
845
+ }
846
+
847
+ /** Pause every AI in the room (excluding the bridge itself when skipName is given). */
848
+ export function stopAll(hive, by = 'slack', skipName = '') {
849
+ const done = []
850
+ for (const p of (hive.members ? hive.members() : [])) {
851
+ if (p.kind !== 'ai' || (skipName && p.name === skipName)) continue
852
+ hive.control(p.name, 'paused', by)
853
+ done.push(p.name)
854
+ }
855
+ return done
856
+ }
857
+
858
+ export function resumeAll(hive, by = 'slack', skipName = '') {
859
+ const done = []
860
+ for (const p of (hive.members ? hive.members() : [])) {
861
+ if (p.kind !== 'ai' || (skipName && p.name === skipName)) continue
862
+ hive.control(p.name, 'running', by)
863
+ done.push(p.name)
864
+ }
865
+ return done
866
+ }
867
+
868
+ /** Find the room's real spelling of a participant name (case-insensitive). */
869
+ export function resolveName(hive, who) {
870
+ const want = String(who || '').toLowerCase()
871
+ for (const p of (hive.members ? hive.members() : [])) {
872
+ if (String(p.name).toLowerCase() === want) return p.name
873
+ }
874
+ return null
875
+ }
876
+
877
+ /** Turn a typed prompt into a deterministic control/status intent. */
878
+ export function route(text) {
879
+ const s = String(text || '').trim().toLowerCase()
880
+ let m
881
+ if ((m = s.match(/^(?:resume|unpause|start|continue)\s+(?:agent\s+)?([a-z0-9_.-]+)$/))) {
882
+ return m[1] === 'all' || m[1] === 'everything' ? { intent: 'resume-all' } : { intent: 'resume', who: m[1] }
883
+ }
884
+ if ((m = s.match(/^(?:stop|pause|halt|kill)\s+(?:agent\s+)?([a-z0-9_.-]+)$/))) {
885
+ return m[1] === 'all' || m[1] === 'everything' ? { intent: 'stop-all' } : { intent: 'stop', who: m[1] }
886
+ }
887
+ if (/^(stop|pause|halt|stop it|stop them|shut it down)\b/.test(s)) return { intent: 'stop-all' }
888
+ if (/^(resume|unpause|carry on|keep going)\b/.test(s)) return { intent: 'resume-all' }
889
+ if (/status|what.*(running|happening|going on)|who.*(here|working)|overnight/.test(s)) return { intent: 'status' }
890
+ return { intent: 'ask', text: String(text || '') }
891
+ }
892
+
893
+ /** Never let a Slack display name impersonate a room member: always attribute as slack:<handle>. */
894
+ export function formatSlackSender(handleOrUser) {
895
+ const raw = String(handleOrUser || 'user').trim().replace(/^slack:/i, '')
896
+ const clean = raw.replace(/[^A-Za-z0-9_.-]/g, '') || 'user'
897
+ return `slack:${clean}`
898
+ }
899
+
900
+ /** Refuse to run a second bridge under the same name in the same room (Q632). */
901
+ export function checkDuplicateBridge(hive, botName = 'SlackBridge') {
902
+ let dupes = []
903
+ try { dupes = hive && typeof hive.sameNameElsewhere === 'function' ? hive.sameNameElsewhere() : [] } catch { dupes = [] }
904
+ if (dupes.length) {
905
+ return {
906
+ ok: false,
907
+ code: 'Q632',
908
+ error: `Q632 Another Slack bridge (${botName}) is already running in this room. Stop the other bridge before starting a new one so events are not posted twice.`,
909
+ }
910
+ }
911
+ return { ok: true }
912
+ }
913
+
914
+ /** What a room event looks like in Slack. Plain text on purpose: blocks render badly in
915
+ * notifications and thread previews, which is where most of these are actually read. */
916
+ export function render(ev) {
917
+ if (ev.kind === 'task-open') return `:inbox_tray: *${ev.text || ev.task}* — assigned to *${ev.to}*`
918
+ if (ev.kind === 'task-done') return `:white_check_mark: *${ev.text || ev.task}* — done by *${ev.by}*`
919
+ if (ev.kind === 'gate-fail') return `:no_entry: gate failed for *${ev.by}* on \`${ev.file}\` — the file stays with them, nobody downstream gets it`
920
+ if (ev.kind === 'gate-pass') return `:heavy_check_mark: gate passed for *${ev.by}* on \`${ev.file}\``
921
+ if (ev.kind === 'chat') return `*${ev.by}*: ${ev.text}`
922
+ return `${ev.kind}: ${ev.text || ''}`
923
+ }
924
+
925
+ /** Which events reach the CHANNEL, and which only ever go inside a thread.
926
+ * Getting this wrong is the difference between a useful feed and a muted one. */
927
+ export const TO_CHANNEL = new Set(['task-open', 'task-done'])
928
+
929
+ export const USER_CACHE_TTL_MS = 10 * 60 * 1000
930
+
931
+ /** Shared Slack actor verification and asymmetric authorization (used by both hive-slack.js
932
+ * and hive-github.js):
933
+ * - caches verified users.info results for ttlMs (default 10 minutes); lookup failures are not cached
934
+ * - refuses bots, deleted users, guests (is_restricted / is_ultra_restricted / is_stranger),
935
+ * and external Slack Connect users (team_id !== teamId)
936
+ * - allows 'stop' / 'stop-all' for any full workspace member, AND fails open with unverified: true
937
+ * ("stopped; I could not confirm who you are") if users.info failed during an outage
938
+ * - allows 'resume' / 'retry' / 'reply' only when actor.ok === true AND userId is in allow */
939
+ export function makeSlackActorCheck({
940
+ token,
941
+ teamId = '',
942
+ api = process.env.HIVE_SLACK_API || DEFAULT_API,
943
+ fetchImpl = fetch,
944
+ lookupUser = null,
945
+ ttlMs = USER_CACHE_TTL_MS,
946
+ now = () => Date.now(),
947
+ allowFlag = '--allow',
948
+ } = {}) {
949
+ const userCache = new Map() // userId -> { value, expiresAt }
950
+
951
+ function put(userId, value) {
952
+ userCache.set(userId, { value, expiresAt: now() + ttlMs })
953
+ return value
954
+ }
955
+
956
+ async function resolve(userId) {
957
+ if (!userId) return { ok: false, userId: '', reason: 'missing_user' }
958
+ const cached = userCache.get(userId)
959
+ if (cached) {
960
+ if (now() < cached.expiresAt) return cached.value
961
+ userCache.delete(userId)
962
+ }
963
+ let res
964
+ try {
965
+ res = lookupUser
966
+ ? await lookupUser(userId)
967
+ : await slackPost('users.info', { user: userId }, token, fetchImpl, api)
968
+ } catch (e) {
969
+ return { ok: false, userId, handle: userId, reason: 'lookup_failed', error: e.message }
970
+ }
971
+ const u = res && res.user
972
+ if (!u || u.deleted || u.is_bot) {
973
+ return put(userId, { ok: false, userId, isBot: Boolean(u && u.is_bot), reason: 'bot_or_deleted' })
974
+ }
975
+ const handle = u.name || (u.profile && u.profile.display_name) || userId
976
+ if (u.is_restricted || u.is_ultra_restricted || u.is_stranger) {
977
+ return put(userId, { ok: false, userId, handle, reason: 'guest' })
978
+ }
979
+ if (teamId && u.team_id && u.team_id !== teamId) {
980
+ return put(userId, { ok: false, userId, handle, reason: 'external_team' })
981
+ }
982
+ return put(userId, { ok: true, userId, handle, teamId: u.team_id || '' })
983
+ }
984
+
985
+ function mayAct(actor, action, allow = []) {
986
+ if (!actor || actor.isBot) {
987
+ return { ok: false, ignored: true, unverified: false, reason: 'bot_or_empty', by: '', reply: '' }
988
+ }
989
+ const uid = actor.userId || ''
990
+ const allowSet = allow instanceof Set
991
+ ? allow
992
+ : new Set(
993
+ (Array.isArray(allow) ? allow : String(allow || '').split(','))
994
+ .map((s) => String(s || '').trim())
995
+ .filter(Boolean),
996
+ )
997
+ const isStop = action === 'stop' || action === 'stop-all'
998
+ const by = formatSlackSender(actor.handle || uid || 'user')
999
+
1000
+ if (!actor.ok) {
1001
+ if (actor.reason === 'lookup_failed' && isStop) {
1002
+ return { ok: true, unverified: true, by, reply: 'stopped; I could not confirm who you are' }
1003
+ }
1004
+ const reply = actor.reason === 'lookup_failed'
1005
+ ? `:no_entry: Refused: could not verify your Slack user (${uid || 'unknown'}) via users.info.`
1006
+ : `:no_entry: Refused: guests and external Slack Connect users cannot control or message this room.`
1007
+ return { ok: false, unverified: false, refused: actor.reason, by, reply }
1008
+ }
1009
+
1010
+ if (isStop) {
1011
+ return { ok: true, unverified: false, by, reply: '' }
1012
+ }
1013
+
1014
+ if (!allowSet.has(uid)) {
1015
+ return {
1016
+ ok: false,
1017
+ unverified: false,
1018
+ refused: 'not_in_allow',
1019
+ by,
1020
+ reply: `:no_entry: Refused: only Slack user IDs listed in ${allowFlag} may resume/retry agents or send replies into the room (your ID: \`${uid}\`). Any full workspace member may still type \`stop\`.`,
1021
+ }
1022
+ }
1023
+
1024
+ return { ok: true, unverified: false, by, reply: '' }
1025
+ }
1026
+
1027
+ return { resolve, mayAct }
1028
+ }
1029
+
1030
+ /** Parse and validate Slack member ids from --allow or SLACK_ALLOW (Q634).
1031
+ * Refuses anything not matching /^[UW][A-Z0-9]{2,}$/ (e.g. usernames or handles). */
1032
+ export function parseSlackAllow(allowRaw, flag = '--allow') {
1033
+ const raw = Array.isArray(allowRaw) ? allowRaw.join(',') : String(allowRaw || '').trim()
1034
+ if (!raw) return { ok: true, ids: [] }
1035
+ const list = raw.split(',').map((s) => s.trim()).filter(Boolean)
1036
+ const notIds = list.filter((s) => !/^[UW][A-Z0-9]{2,}$/.test(s))
1037
+ if (notIds.length) {
1038
+ return {
1039
+ ok: false,
1040
+ code: 'Q634',
1041
+ error: `Q634 ${flag} takes Slack member ids like U012AB3CD, not ${notIds.join(', ')}. In Slack, open the person's profile, click More, then Copy member ID.`,
1042
+ }
1043
+ }
1044
+ return { ok: true, ids: list }
1045
+ }
1046
+
1047
+ export const validateSlackAllow = parseSlackAllow
1048
+
1049
+ export function makeBridge({
1050
+ hive,
1051
+ channel,
1052
+ token,
1053
+ post,
1054
+ botName = 'SlackBridge',
1055
+ onError = null,
1056
+ room = '',
1057
+ link = '',
1058
+ allow = [],
1059
+ teamId = '',
1060
+ botUserId = '',
1061
+ lookupUser = null,
1062
+ minGapMs = 1000,
1063
+ now = () => Date.now(),
1064
+ userCacheTtlMs = USER_CACHE_TTL_MS,
1065
+ api = process.env.HIVE_SLACK_API || DEFAULT_API,
1066
+ fetchImpl = fetch,
1067
+ }) {
1068
+ const threads = hive.doc.getMap('slack_threads') // task id -> thread_ts, shared + durable
1069
+ const seen = new Set()
1070
+ const allowSet = new Set(
1071
+ (Array.isArray(allow) ? allow : String(allow || '').split(','))
1072
+ .map((s) => String(s || '').trim())
1073
+ .filter(Boolean),
1074
+ )
1075
+ const actorCheck = makeSlackActorCheck({
1076
+ token,
1077
+ teamId,
1078
+ api,
1079
+ fetchImpl,
1080
+ lookupUser,
1081
+ ttlMs: userCacheTtlMs,
1082
+ now,
1083
+ })
1084
+ let reportedFatal = false
1085
+
1086
+ const handleFatal = (e) => {
1087
+ if (typeof onError === 'function') onError(e)
1088
+ if (!reportedFatal && isPermanentSlackError(e)) {
1089
+ reportedFatal = true
1090
+ console.error(`[hive-slack] STOPPED POSTING: ${e.message}`)
1091
+ }
1092
+ }
1093
+
1094
+ const threader = makeSlackThreader({
1095
+ token,
1096
+ channel,
1097
+ threads,
1098
+ post,
1099
+ minGapMs,
1100
+ onError: handleFatal,
1101
+ room: room || (hive && hive.room) || '',
1102
+ link,
1103
+ })
1104
+ const { queue } = threader
1105
+
1106
+ async function emit(ev) {
1107
+ if (queue.isHalted()) return { ok: false, halted: true }
1108
+ const text = scrubSlack(render(ev), { room: room || (hive && hive.room) || '', link })
1109
+ if (ev.task) {
1110
+ // A task's first event opens the thread; everything after lands inside it. The outcome
1111
+ // (TO_CHANNEL) is also broadcast, so the channel sees "done" without anyone opening the thread.
1112
+ return threader.reply(ev.task, text, { broadcast: TO_CHANNEL.has(ev.kind) })
1113
+ }
1114
+ // Untasked events (general room chat): open one shared __room__ thread on the first message,
1115
+ // and route every subsequent untasked chat inside it so chatter never floods the channel.
1116
+ if (!TO_CHANNEL.has(ev.kind)) {
1117
+ return threader.reply(ROOM_THREAD_KEY, text)
1118
+ }
1119
+ return queue.push({ channel, text })
1120
+ }
1121
+
1122
+ function onChat(msgs, startIdx = null) {
1123
+ for (let i = 0; i < msgs.length; i++) {
1124
+ const m = msgs[i]
1125
+ if (!m || !m.text) continue
1126
+ // THE ECHO GUARD. Anything this bridge itself put into the room must never go back out,
1127
+ // or a Slack reply becomes a room message becomes a Slack message, forever.
1128
+ if (m.by === botName || m.viaSlack) continue
1129
+ const key = `${m.by}|${m.at}|${m.text}`
1130
+ if (seen.has(key)) continue
1131
+ seen.add(key)
1132
+ if (typeof startIdx === 'number') {
1133
+ const nextCursor = startIdx + i + 1
1134
+ if (nextCursor > (Number(threads.get(CURSOR_KEY)) || 0)) threads.set(CURSOR_KEY, nextCursor)
1135
+ }
1136
+ emit({ kind: 'chat', by: m.by, text: m.text, task: m.task }).catch((e) => handleFatal(e))
1137
+ }
1138
+ }
1139
+
1140
+ /** A human replied in a Slack thread. Put it into the room, addressed, attributed as
1141
+ * `slack:<handle>` (never bare member name, preventing impersonation), and marked `viaSlack`
1142
+ * so the echo guard above will not send it straight back to Slack. */
1143
+ function deliver({ thread_ts, text, user }) {
1144
+ let task = null
1145
+ for (const [id, ts] of threads.entries()) {
1146
+ if (id === ROOM_THREAD_KEY || id === CURSOR_KEY) continue
1147
+ if (ts === thread_ts) { task = id; break }
1148
+ }
1149
+ const addressed = /^@/.test(text.trim()) ? text.trim() : text.trim()
1150
+ const by = formatSlackSender(user || 'user')
1151
+ hive.doc.getArray('chat').push([{
1152
+ by, kind: 'say', at: Date.now() * 1000, text: addressed, task, viaSlack: true,
1153
+ }])
1154
+ return { ok: true, task, by }
1155
+ }
1156
+
1157
+ async function replyInThread(thread_ts, text) {
1158
+ const clean = scrubSlack(text, { room: room || (hive && hive.room) || '', link })
1159
+ try {
1160
+ const payload = thread_ts ? { channel, text: clean, thread_ts } : { channel, text: clean }
1161
+ await queue.push(payload)
1162
+ } catch (e) {
1163
+ handleFatal(e)
1164
+ }
1165
+ }
1166
+
1167
+ /** Handle an inbound Socket Mode event (`message` or `agent_session_stopped`).
1168
+ * Asymmetric authorization via makeSlackActorCheck:
1169
+ * - Guests (`is_restricted` / `is_ultra_restricted`) and Slack Connect users from other workspaces
1170
+ * (`team_id !== teamId`) are refused for everything.
1171
+ * - STOP (`stop`, `stop all`, `stop <Agent>`, or `agent_session_stopped`) is fail-safe: any full
1172
+ * workspace member may stop agents, and if `users.info` fails during an outage/rate-limit,
1173
+ * `stop` still goes through ("stopped; I could not confirm who you are").
1174
+ * - RESUME and thread replies into the room require `ev.user` to be in `--allow U123,U456`. */
1175
+ async function onInboundEvent(ev) {
1176
+ if (!ev) return { ok: false, ignored: 'empty' }
1177
+ if (ev.bot_id || ev.subtype || (botUserId && ev.user === botUserId)) {
1178
+ return { ok: false, ignored: 'bot_or_subtype' }
1179
+ }
1180
+ if (ev.channel && channel && ev.channel !== channel) {
1181
+ return { ok: false, ignored: 'wrong_channel' }
1182
+ }
1183
+ const replyTs = ev.thread_ts || ev.ts || threads.get(ROOM_THREAD_KEY) || undefined
1184
+ const r = ev.type === 'message' && ev.text ? route(ev.text) : null
1185
+ const isStopAction =
1186
+ ev.type === 'agent_session_stopped' ||
1187
+ Boolean(r && (r.intent === 'stop-all' || r.intent === 'stop'))
1188
+
1189
+ const actor = await actorCheck.resolve(ev.user)
1190
+ if (actor.isBot) return { ok: false, ignored: 'bot_user' }
1191
+
1192
+ if (isStopAction) {
1193
+ const check = actorCheck.mayAct(actor, 'stop', allowSet)
1194
+ if (!check.ok) {
1195
+ if (check.reply) await replyInThread(replyTs, check.reply)
1196
+ return { ok: false, refused: check.refused || actor.reason }
1197
+ }
1198
+ const sender = check.by
1199
+ const unverifiedStop = Boolean(check.unverified)
1200
+ const unverifiedNote = unverifiedStop ? ` (${check.reply})` : ''
1201
+
1202
+ if (ev.type === 'agent_session_stopped' || (r && r.intent === 'stop-all')) {
1203
+ const who = stopAll(hive, sender, botName)
1204
+ await replyInThread(
1205
+ replyTs,
1206
+ who.length
1207
+ ? `:octagonal_sign: *${sender}* paused *${who.join('*, *')}*. Nothing new will be picked up.${unverifiedNote}`
1208
+ : 'No agents to pause.',
1209
+ )
1210
+ return { ok: true, action: 'stop-all', stopped: who, by: sender, unverified: unverifiedStop && who.length > 0 }
1211
+ }
1212
+ const real = resolveName(hive, r.who)
1213
+ if (!real || real === botName) {
1214
+ const here = (hive.members ? hive.members() : []).filter((p) => p.name !== botName).map((p) => p.name).join(', ') || 'nobody'
1215
+ await replyInThread(replyTs, `:no_entry: I do not see *${r.who}* in this room. Here right now: ${here}.`)
1216
+ return { ok: false, refused: 'unknown_agent' }
1217
+ }
1218
+ hive.control(real, 'paused', sender)
1219
+ await replyInThread(replyTs, `:octagonal_sign: *${sender}* paused *${real}*.${unverifiedNote}`)
1220
+ return { ok: true, action: 'stop', who: real, by: sender, unverified: unverifiedStop }
1221
+ }
1222
+
1223
+ if (!actor.ok) {
1224
+ const check = actorCheck.mayAct(actor, 'reply', allowSet)
1225
+ if (check.reply) await replyInThread(replyTs, check.reply)
1226
+ return { ok: false, refused: check.refused || actor.reason }
1227
+ }
1228
+
1229
+ if (ev.type !== 'message' || !ev.text || !r) return { ok: false, ignored: 'unsupported_event' }
1230
+
1231
+ // RESUME requires --allow <U123,U456>.
1232
+ if (r.intent === 'resume-all' || r.intent === 'resume') {
1233
+ const check = actorCheck.mayAct(actor, 'resume', allowSet)
1234
+ if (!check.ok) {
1235
+ if (check.reply) await replyInThread(replyTs, check.reply)
1236
+ return { ok: false, refused: check.refused || 'not_in_allow' }
1237
+ }
1238
+ const sender = check.by
1239
+ if (r.intent === 'resume-all') {
1240
+ const who = resumeAll(hive, sender, botName)
1241
+ await replyInThread(
1242
+ replyTs,
1243
+ who.length ? `:arrow_forward: *${sender}* resumed *${who.join('*, *')}*.` : 'No agents to resume.',
1244
+ )
1245
+ return { ok: true, action: 'resume-all', resumed: who, by: sender }
1246
+ }
1247
+ const real = resolveName(hive, r.who)
1248
+ if (!real || real === botName) {
1249
+ const here = (hive.members ? hive.members() : []).filter((p) => p.name !== botName).map((p) => p.name).join(', ') || 'nobody'
1250
+ await replyInThread(replyTs, `:no_entry: I do not see *${r.who}* in this room. Here right now: ${here}.`)
1251
+ return { ok: false, refused: 'unknown_agent' }
1252
+ }
1253
+ hive.control(real, 'running', sender)
1254
+ await replyInThread(replyTs, `:arrow_forward: *${sender}* resumed *${real}*.`)
1255
+ return { ok: true, action: 'resume', who: real, by: sender }
1256
+ }
1257
+
1258
+ // Thread replies into the room require a tracked thread_ts AND --allow <U123,U456>.
1259
+ if (!ev.thread_ts) return { ok: false, ignored: 'not_in_thread' }
1260
+ let knownThread = ev.thread_ts === threads.get(ROOM_THREAD_KEY)
1261
+ if (!knownThread) {
1262
+ for (const [id, ts] of threads.entries()) {
1263
+ if (id !== CURSOR_KEY && ts === ev.thread_ts) { knownThread = true; break }
1264
+ }
1265
+ }
1266
+ if (!knownThread) return { ok: false, ignored: 'untracked_thread' }
1267
+ const check = actorCheck.mayAct(actor, 'reply', allowSet)
1268
+ if (!check.ok) {
1269
+ if (check.reply) await replyInThread(ev.thread_ts, check.reply)
1270
+ return { ok: false, refused: check.refused || 'not_in_allow' }
1271
+ }
1272
+ const d = deliver({ thread_ts: ev.thread_ts, text: ev.text, user: check.by })
1273
+ return { ok: true, action: 'reply', task: d.task, by: d.by }
1274
+ }
1275
+
1276
+ // ---- the room's own events: tasks and gates ------------------------------------------------
1277
+ // `slack start` used to watch the chat and nothing else, so task-open / task-done / gate-pass /
1278
+ // gate-fail were rendered by render() and never sent by anything. What was announced is kept in
1279
+ // ROOM STATE (slack_seen), so a restarted bridge does not say it all again. The first time a
1280
+ // bridge ever attaches to a room it only records what is already there: announcing a room's
1281
+ // whole history on first start would flood the channel.
1282
+ const announced = hive.doc.getMap('slack_seen')
1283
+ function unannounced() {
1284
+ const baseline = !announced.has('__init__')
1285
+ const out = []
1286
+ const note = (key, ev) => { if (announced.has(key)) return; announced.set(key, 1); if (!baseline && ev) out.push(ev) }
1287
+ for (const t of hive.doc.getMap('tasks').values()) {
1288
+ if (!t || !t.id) continue
1289
+ note(`task-open|${t.id}`, { kind: 'task-open', task: t.id, to: t.to, by: t.by, text: t.text })
1290
+ if (t.status === 'done') note(`task-done|${t.id}`, { kind: 'task-done', task: t.id, by: t.to, text: t.text })
1291
+ }
1292
+ hive.doc.getMap('gate_results').forEach((v, k) => {
1293
+ if (!v) return
1294
+ // A forced release already says so in room chat, which the bridge carries over.
1295
+ note(`gate|${k}|${v.at}`, v.forced ? null : { kind: v.pass ? 'gate-pass' : 'gate-fail', by: v.by, file: v.region })
1296
+ })
1297
+ if (baseline) announced.set('__init__', 1)
1298
+ return out
1299
+ }
1300
+
1301
+ /** Announce task and gate changes from now on. Resolves once the current state has been handled. */
1302
+ async function watchRoom() {
1303
+ const pass = async () => {
1304
+ for (const ev of unannounced()) {
1305
+ try { await emit(ev) } catch (e) { handleFatal(e) }
1306
+ }
1307
+ }
1308
+ let chain = pass()
1309
+ const kick = () => { chain = chain.then(pass) }
1310
+ const tasks = hive.doc.getMap('tasks')
1311
+ const gates = hive.doc.getMap('gate_results')
1312
+ tasks.observe(kick)
1313
+ gates.observe(kick)
1314
+ await chain
1315
+ return { stop: () => { try { tasks.unobserve(kick); gates.unobserve(kick) } catch { } } }
1316
+ }
1317
+
1318
+ const postChannel = (text) => queue.push({ channel, text })
1319
+
1320
+ return { emit, onChat, deliver, onInboundEvent, watchRoom, threads, queue, threader, postChannel }
1321
+ }
1322
+
1323
+ export async function runSlackCheck({
1324
+ token = '',
1325
+ appToken = '',
1326
+ channel = '',
1327
+ agent = false,
1328
+ timeoutMs = 120000,
1329
+ helloTimeoutMs = Number(process.env.HIVE_SLACK_HELLO_TIMEOUT_MS) || 10000,
1330
+ startRetryMs = Number(process.env.HIVE_SLACK_START_RETRY_MS) || 60000,
1331
+ startRetryDelayMs = 1000,
1332
+ api = process.env.HIVE_SLACK_API || DEFAULT_API,
1333
+ fetchImpl = fetch,
1334
+ WebSocketImpl = WebSocket,
1335
+ log = console.log,
1336
+ logErr = console.error,
1337
+ } = {}) {
1338
+ if (!token || !String(token).trim()) {
1339
+ logErr('Q630 SLACK_BOT_TOKEN is missing. Set SLACK_BOT_TOKEN=xoxb-… in your environment.')
1340
+ return 1
1341
+ }
1342
+ if (!channel || !String(channel).trim()) {
1343
+ logErr('Q631 --channel <id> is required (for example: --channel C0123456789).')
1344
+ return 2
1345
+ }
1346
+ if (agent && (!appToken || !String(appToken).trim())) {
1347
+ logErr('Q634 --agent requires SLACK_APP_TOKEN=xapp-… (with connections:write scope) for Socket Mode.')
1348
+ return 1
1349
+ }
1350
+
1351
+ // 1. Token (auth.test)
1352
+ let auth
1353
+ try {
1354
+ auth = await slackPost('auth.test', {}, token, fetchImpl, api)
1355
+ } catch (e) {
1356
+ if (!e || !e.slackError) {
1357
+ logErr(`Q636 could not reach Slack (${formatSlackWhy(e)}). The token was not checked; check this machine's network and try again.`)
1358
+ return 1
1359
+ }
1360
+ logErr(`Q630 SLACK_BOT_TOKEN was rejected by Slack (${formatSlackWhy(e)}). Check the token or reinstall the Slack app.`)
1361
+ return 1
1362
+ }
1363
+ const botUser = auth.user || 'Hivecode'
1364
+ const botUserId = auth.user_id || ''
1365
+ log(`✓ token: @${botUser} (${botUserId || 'bot'})`)
1366
+
1367
+ // 2. Workspace
1368
+ if (!auth.team_id) {
1369
+ logErr('Q634 Slack auth.test did not return a workspace team_id. Reinstall the Slack app.')
1370
+ return 1
1371
+ }
1372
+ log(`✓ workspace: ${auth.team || auth.team_id} (${auth.team_id})`)
1373
+
1374
+ // 3. Channel & membership (conversations.info)
1375
+ let info
1376
+ try {
1377
+ info = await slackPost('conversations.info', { channel }, token, fetchImpl, api)
1378
+ } catch (e) {
1379
+ if (!e || !e.slackError) {
1380
+ logErr(`Q636 could not reach Slack (${formatSlackWhy(e)}). The token was not checked; check this machine's network and try again.`)
1381
+ return 1
1382
+ }
1383
+ logErr(`Q631 Slack channel ${channel} could not be accessed (${formatSlackWhy(e)}). Check the channel ID and run /invite @${botUser} in the channel.`)
1384
+ return 1
1385
+ }
1386
+ if (info && info.channel && info.channel.is_member === false) {
1387
+ const chName = info.channel.name ? `#${info.channel.name}` : channel
1388
+ logErr(`Q631 @${botUser} is not a member of ${chName} (${channel}). In Slack, open ${chName} and run: /invite @${botUser}`)
1389
+ return 1
1390
+ }
1391
+ const channelName = (info && info.channel && info.channel.name) || channel
1392
+ const isPrivate = Boolean(
1393
+ info && info.channel && (
1394
+ info.channel.is_private === true ||
1395
+ info.channel.is_group === true ||
1396
+ String(channel || '').startsWith('G')
1397
+ ),
1398
+ )
1399
+ log(`✓ channel: #${channelName} (${channel}, member)`)
1400
+
1401
+ // 4. Scopes (x-oauth-scopes)
1402
+ const scopes = Array.isArray(auth && auth._scopes)
1403
+ ? auth._scopes
1404
+ : (Array.isArray(info && info._scopes) ? info._scopes : null)
1405
+ if (scopes === null) {
1406
+ log('- scopes: x-oauth-scopes header not returned by server (not verified)')
1407
+ } else if (agent) {
1408
+ const agCheck = checkAgentScopes({ scopes, isPrivate, channel })
1409
+ if (!agCheck.ok) {
1410
+ logErr(agCheck.error)
1411
+ return 1
1412
+ }
1413
+ log(`✓ scopes (outbound + inbound + agent): ${scopes.join(', ')}`)
1414
+ } else {
1415
+ const have = new Set(scopes)
1416
+ const readScope = isPrivate ? 'groups:read' : 'channels:read'
1417
+ const outMissing = []
1418
+ if (!have.has('chat:write')) outMissing.push('chat:write')
1419
+ if (!have.has(readScope)) outMissing.push(readScope)
1420
+ if (outMissing.length) {
1421
+ logErr(`Q630 SLACK_BOT_TOKEN is missing required outbound scope(s): ${outMissing.join(', ')}. Reinstall your Slack app from slack-app-manifest.json.`)
1422
+ return 1
1423
+ }
1424
+ if (appToken) {
1425
+ const inCheck = checkInboundScopes({ scopes, isPrivate, channel })
1426
+ if (!inCheck.ok) {
1427
+ logErr(inCheck.error)
1428
+ return 1
1429
+ }
1430
+ log(`✓ scopes (outbound + inbound): ${scopes.join(', ')}`)
1431
+ } else {
1432
+ log(`✓ scopes (outbound): ${scopes.join(', ')} (SLACK_APP_TOKEN not set; inbound reply check skipped)`)
1433
+ }
1434
+ }
1435
+
1436
+ // Outbound-only mode (when SLACK_APP_TOKEN is not set): verify outbound post + thread reply, never claim inbound replies work
1437
+ if (!appToken || !String(appToken).trim()) {
1438
+ log(`This posts one message in #${channelName} (and one reply in its thread)`)
1439
+ try {
1440
+ const rootPost = await slackPost('chat.postMessage', { channel, text: 'Hivecode check: outbound posting works.' }, token, fetchImpl, api)
1441
+ if (rootPost && rootPost.ts) {
1442
+ await slackPost('chat.postMessage', { channel, thread_ts: rootPost.ts, text: '✓ Hivecode check: thread reply works (set SLACK_APP_TOKEN to verify inbound replies).' }, token, fetchImpl, api)
1443
+ }
1444
+ } catch (e) {
1445
+ logErr(`Q630 Slack chat.postMessage failed (${formatSlackWhy(e)}).`)
1446
+ return 1
1447
+ }
1448
+ log(`✓ outbound post: delivered to #${channelName} (set SLACK_APP_TOKEN to verify inbound replies)`)
1449
+ return 0
1450
+ }
1451
+
1452
+ // 5. Socket Mode connect (must receive Slack's `hello` frame within helloTimeoutMs BEFORE we post the prompt)
1453
+ const sm = await preflightSocketMode({ appToken, scopes: agent ? null : scopes, isPrivate, channel, api, fetchImpl })
1454
+ if (!sm.ok) {
1455
+ logErr(sm.error)
1456
+ return 1
1457
+ }
1458
+
1459
+ let sock = null
1460
+ const closeSock = () => {
1461
+ if (!sock) return
1462
+ try { sock.removeAllListeners && sock.removeAllListeners() } catch { }
1463
+ try { sock.close() } catch { }
1464
+ try { sock.terminate && sock.terminate() } catch { }
1465
+ sock = null
1466
+ }
1467
+
1468
+ let checkTs = ''
1469
+ let replyResolver = null
1470
+ const replyPromise = new Promise((resolve) => { replyResolver = resolve })
1471
+
1472
+ let currentUrl = sm.url
1473
+ const startTime = Date.now()
1474
+
1475
+ while (true) {
1476
+ if (!currentUrl) {
1477
+ try {
1478
+ const res = await slackPost('apps.connections.open', {}, appToken, fetchImpl, api)
1479
+ currentUrl = res && res.url
1480
+ } catch (e) {
1481
+ closeSock()
1482
+ if (!e || !e.slackError) {
1483
+ logErr(`Q636 could not reach Slack (${formatSlackWhy(e)}). The token was not checked; check this machine's network and try again.`)
1484
+ return 1
1485
+ }
1486
+ logErr(`Q634 Slack apps.connections.open failed (${formatSlackWhy(e)}).`)
1487
+ return 1
1488
+ }
1489
+ }
1490
+ if (!currentUrl) {
1491
+ closeSock()
1492
+ logErr('Q634 Slack apps.connections.open did not return a WebSocket URL.')
1493
+ return 1
1494
+ }
1495
+
1496
+ const res = await new Promise((resolve) => {
1497
+ let settled = false
1498
+ const finish = (val) => {
1499
+ if (settled) return
1500
+ settled = true
1501
+ clearTimeout(hTimer)
1502
+ resolve(val)
1503
+ }
1504
+ const hTimer = setTimeout(() => finish({ timedOut: true }), helloTimeoutMs)
1505
+ try {
1506
+ sock = new WebSocketImpl(currentUrl)
1507
+ } catch (e) {
1508
+ finish({ error: e })
1509
+ return
1510
+ }
1511
+ sock.on('error', (e) => finish({ error: e }))
1512
+ sock.on('close', () => {
1513
+ finish({ closed: true })
1514
+ if (replyResolver) replyResolver(false)
1515
+ })
1516
+ sock.on('message', (raw) => {
1517
+ let frame
1518
+ try { frame = JSON.parse(String(raw)) } catch { return }
1519
+ if (frame && frame.envelope_id) {
1520
+ try { sock && sock.send(JSON.stringify({ envelope_id: frame.envelope_id })) } catch { }
1521
+ }
1522
+ if (!frame) return
1523
+ if (frame.type === 'hello') {
1524
+ finish({ ok: true, frame })
1525
+ return
1526
+ }
1527
+ const ev = frame.payload && frame.payload.event
1528
+ if (
1529
+ ev &&
1530
+ (ev.type === 'message' || (agent && ev.type === 'agent_session_stopped')) &&
1531
+ !ev.bot_id &&
1532
+ !ev.subtype &&
1533
+ ev.user &&
1534
+ ev.user !== botUserId &&
1535
+ (!ev.channel || ev.channel === channel) &&
1536
+ checkTs &&
1537
+ (ev.thread_ts || ev.ts) === checkTs
1538
+ ) {
1539
+ if (replyResolver) replyResolver(true)
1540
+ }
1541
+ })
1542
+ })
1543
+
1544
+ if (res.timedOut || res.closed || res.error) {
1545
+ closeSock()
1546
+ logErr('Q634 Socket Mode did not open (no hello frame received within timeout). Check SLACK_APP_TOKEN (connections:write) and network access to Slack.')
1547
+ return 1
1548
+ }
1549
+
1550
+ const num = Number(res.frame && res.frame.num_connections) || 1
1551
+ if (num <= 1) {
1552
+ break
1553
+ }
1554
+
1555
+ const elapsed = Date.now() - startTime
1556
+ if (elapsed < startRetryMs) {
1557
+ closeSock()
1558
+ const waitTime = Math.min(startRetryDelayMs, Math.max(50, startRetryMs - elapsed))
1559
+ await new Promise((r) => setTimeout(r, waitTime))
1560
+ currentUrl = ''
1561
+ continue
1562
+ }
1563
+
1564
+ closeSock()
1565
+ logErr(`Q635 Another process is already connected to Slack Socket Mode with this SLACK_APP_TOKEN (${num} active connections). Slack distributes incoming events across connections, so messages and "stop" commands may go to the other process. Check for another terminal or service running "hivecode-mcp slack start", "github start --slack-allow", or "hive-slack-agent" with the same app token. If you just stopped a bridge, wait a minute for Slack to release the connection and start again.`)
1566
+ return 1
1567
+ }
1568
+
1569
+ log('✓ Socket Mode: connected')
1570
+
1571
+ // 6. Real proof: post prompt and wait up to timeoutMs for any non-bot reply in that thread
1572
+ log(`This posts one message in #${channelName} (and one reply in its thread)`)
1573
+ try {
1574
+ const postRes = await slackPost(
1575
+ 'chat.postMessage',
1576
+ { channel, text: 'Hivecode check: reply anything in this thread' },
1577
+ token,
1578
+ fetchImpl,
1579
+ api,
1580
+ )
1581
+ checkTs = (postRes && postRes.ts) || ''
1582
+ } catch (e) {
1583
+ closeSock()
1584
+ logErr(`Q630 Slack chat.postMessage failed (${formatSlackWhy(e)}).`)
1585
+ return 1
1586
+ }
1587
+
1588
+ let waitTimer = null
1589
+ const onSigInt = () => {
1590
+ if (replyResolver) replyResolver(false)
1591
+ }
1592
+ if (agent) {
1593
+ process.once('SIGINT', onSigInt)
1594
+ }
1595
+
1596
+ try {
1597
+ if (agent) {
1598
+ let statusRes
1599
+ try {
1600
+ statusRes = await slackPost(
1601
+ 'agents.sessions.setStatus',
1602
+ { status: 'processing', channel_id: channel, thread_ts: checkTs },
1603
+ token,
1604
+ fetchImpl,
1605
+ api,
1606
+ )
1607
+ } catch (e) {
1608
+ const code = extractSlackError(e)
1609
+ if (code === 'feature_disabled') {
1610
+ logErr('Q634 Slack agents.sessions.setStatus failed (feature_disabled): Slack AI agent sessions require a paid Slack plan with Agents & Assistants enabled (slack-app-manifest-agent.json).')
1611
+ } else if (code === 'thread_ts_required') {
1612
+ logErr('Q634 Slack agents.sessions.setStatus failed (thread_ts_required): Slack requires thread_ts for thread-based agent sessions in DMs.')
1613
+ } else {
1614
+ logErr(`Q634 Slack agents.sessions.setStatus failed (${formatSlackWhy(e)}). Reinstall from slack-app-manifest-agent.json.`)
1615
+ }
1616
+ return 1
1617
+ }
1618
+ const warnings = extractSlackWarnings(statusRes)
1619
+ if (warnings.includes('missing_agent_session_stopped_event_subscription')) {
1620
+ logErr('Q634 Slack agents.sessions.setStatus returned warning missing_agent_session_stopped_event_subscription: the native Slack stop button is not active because agent_session_stopped is not subscribed. Reinstall from slack-app-manifest-agent.json.')
1621
+ return 1
1622
+ }
1623
+ log('✓ agent session: native stop button subscribed (agents.sessions.setStatus)')
1624
+ }
1625
+
1626
+ const arrived = await Promise.race([
1627
+ replyPromise,
1628
+ new Promise((resolve) => { waitTimer = setTimeout(() => resolve(false), timeoutMs) }),
1629
+ ])
1630
+ clearTimeout(waitTimer)
1631
+
1632
+ if (!arrived) {
1633
+ const manifestName = agent ? 'slack-app-manifest-agent.json' : 'slack-app-manifest-inbound.json'
1634
+ logErr(`Q634 replies did not arrive. Event subscriptions are probably missing: reinstall from ${manifestName}`)
1635
+ return 1
1636
+ }
1637
+
1638
+ await slackPost(
1639
+ 'chat.postMessage',
1640
+ { channel, thread_ts: checkTs, text: '✓ Hivecode check: replies reach Hivecode.' },
1641
+ token,
1642
+ fetchImpl,
1643
+ api,
1644
+ ).catch(() => {})
1645
+ log('✓ replies reach Hivecode')
1646
+ return 0
1647
+ } finally {
1648
+ clearTimeout(waitTimer)
1649
+ if (agent) {
1650
+ process.removeListener('SIGINT', onSigInt)
1651
+ if (checkTs) {
1652
+ await slackPost(
1653
+ 'agents.sessions.setStatus',
1654
+ { status: 'active', channel_id: channel, thread_ts: checkTs },
1655
+ token,
1656
+ fetchImpl,
1657
+ api,
1658
+ ).catch(() => {})
1659
+ }
1660
+ }
1661
+ closeSock()
1662
+ }
1663
+ }
1664
+
1665
+ export async function main(argv = process.argv.slice(2), { log = console.log, logErr = console.error, fetchImpl = fetch, env = process.env, WebSocketImpl = WebSocket } = {}) {
1666
+ let mode = 'start'
1667
+ let CHANNEL = env.SLACK_CHANNEL || ''
1668
+ let NAME = 'SlackBridge'
1669
+ let ALLOW = env.SLACK_ALLOW || ''
1670
+ let allowSource = env.SLACK_ALLOW ? 'SLACK_ALLOW' : '--allow'
1671
+ let LINK = ''
1672
+ let dryRun = false
1673
+ let agent = false
1674
+ let timeoutMs = Number(env.HIVE_SLACK_CHECK_TIMEOUT_MS) || 120000
1675
+ let helloTimeoutMs = Number(env.HIVE_SLACK_HELLO_TIMEOUT_MS) || 10000
1676
+ let startRetryMs = Number(env.HIVE_SLACK_START_RETRY_MS) || 60000
1677
+ for (let i = 0; i < argv.length; i++) {
1678
+ const a = argv[i]
1679
+ if ((a === 'start' || a === 'check') && !LINK && i === 0) { mode = a; continue }
1680
+ if (a === '--help' || a === '-h') { log(USAGE.trim()); return 0 }
1681
+ if (a === '--dry-run') { dryRun = true; continue }
1682
+ if (a === '--agent') { agent = true; continue }
1683
+ if (a === '--channel') { CHANNEL = argv[++i] || ''; continue }
1684
+ if (a === '--name') { NAME = argv[++i] || 'SlackBridge'; continue }
1685
+ if (a === '--allow') { ALLOW = argv[++i] || ''; allowSource = '--allow'; continue }
1686
+ if (a === '--timeout') { timeoutMs = Number(argv[++i]) || timeoutMs; continue }
1687
+ if (a === '--hello-timeout') { helloTimeoutMs = Number(argv[++i]) || helloTimeoutMs; continue }
1688
+ if (a === '--start-retry') { startRetryMs = Number(argv[++i]) || startRetryMs; continue }
1689
+ if (a.startsWith('--')) { logErr(USAGE.trim()); return 2 }
1690
+ if (!LINK) LINK = a
1691
+ }
1692
+ const TOKEN = env.SLACK_BOT_TOKEN || ''
1693
+ const APP_TOKEN = env.SLACK_APP_TOKEN || ''
1694
+ if (mode === 'check') {
1695
+ return await runSlackCheck({
1696
+ token: TOKEN,
1697
+ appToken: APP_TOKEN,
1698
+ channel: CHANNEL,
1699
+ agent,
1700
+ timeoutMs,
1701
+ helloTimeoutMs,
1702
+ startRetryMs,
1703
+ fetchImpl,
1704
+ WebSocketImpl,
1705
+ log,
1706
+ logErr,
1707
+ })
1708
+ }
1709
+ if (!LINK) {
1710
+ logErr(USAGE.trim())
1711
+ return 2
1712
+ }
1713
+ const allowParsed = parseSlackAllow(ALLOW, allowSource)
1714
+ if (!allowParsed.ok) {
1715
+ logErr(allowParsed.error)
1716
+ return 1
1717
+ }
1718
+ const pre = await preflightSlack({ token: TOKEN, channel: CHANNEL, fetchImpl })
1719
+ if (!pre.ok) {
1720
+ logErr(pre.error)
1721
+ return 1
1722
+ }
1723
+ let sockPre = null
1724
+ if (APP_TOKEN) {
1725
+ sockPre = await preflightSocketMode({
1726
+ appToken: APP_TOKEN,
1727
+ scopes: pre.scopes,
1728
+ isPrivate: pre.isPrivate,
1729
+ channel: CHANNEL,
1730
+ fetchImpl,
1731
+ })
1732
+ if (!sockPre.ok) {
1733
+ logErr(sockPre.error)
1734
+ return 1
1735
+ }
1736
+ }
1737
+ const { relay, room, token: linkToken } = parseLink(LINK)
1738
+ if (!relay || !room) {
1739
+ logErr(`Q630 "${LINK}" is not a valid room link (expected wss://relay|room-…).`)
1740
+ return 2
1741
+ }
1742
+ if (dryRun) {
1743
+ const mode = sockPre ? 'outbound + inbound Socket Mode' : 'outbound'
1744
+ log(`DRY RUN OK: @${pre.botUser} is in #${pre.channelName} (${CHANNEL}) [${mode}] and ready to bridge ${room} — resume: ${allowParsed.ids.length ? allowParsed.ids.join(', ') : 'off'}.`)
1745
+ return 0
1746
+ }
1747
+ const dir = (env.TEMP || process.env.TEMP || '.') + '/hive-slack-' + room + '-' + process.pid
1748
+ const hive = startSync({ relay, room, dir, name: NAME, kind: 'ai', token: linkToken || env.HIVE_TOKEN || '', syncFiles: false, log: () => {} })
1749
+ await new Promise((resolve) => {
1750
+ let done = false
1751
+ const finish = () => { if (!done) { done = true; resolve() } }
1752
+ hive.provider.on('sync', (s) => { if (s) finish() })
1753
+ setTimeout(finish, 8000)
1754
+ })
1755
+ const dup = checkDuplicateBridge(hive, NAME)
1756
+ if (!dup.ok) {
1757
+ logErr(dup.error)
1758
+ try { hive.stop() } catch { }
1759
+ return 1
1760
+ }
1761
+ const bridge = makeBridge({
1762
+ hive,
1763
+ channel: CHANNEL,
1764
+ token: TOKEN,
1765
+ botName: NAME,
1766
+ room,
1767
+ link: LINK,
1768
+ allow: ALLOW,
1769
+ teamId: pre.teamId || '',
1770
+ botUserId: pre.botUserId || '',
1771
+ fetchImpl,
1772
+ post: (m) => slackPost('chat.postMessage', m, TOKEN, fetchImpl),
1773
+ })
1774
+ if (sockPre) {
1775
+ try {
1776
+ await connectSocketMode({
1777
+ appToken: APP_TOKEN,
1778
+ initialUrl: sockPre.url,
1779
+ fetchImpl,
1780
+ WebSocketImpl,
1781
+ logErr,
1782
+ helloTimeoutMs,
1783
+ startRetryMs,
1784
+ onEvent: (ev) => bridge.onInboundEvent(ev),
1785
+ onMultiConnection: (n) => {
1786
+ bridge.postChannel(
1787
+ `:warning: Another process connected to Slack Socket Mode with this app token (${n} active connections). Slack distributes incoming events across connections, so messages or "stop" commands may go to the other process. Check for another terminal running hivecode-mcp slack start, github start --slack-allow, or hive-slack-agent.`,
1788
+ )
1789
+ },
1790
+ })
1791
+ } catch (e) {
1792
+ logErr(e.message)
1793
+ try { hive.stop() } catch { }
1794
+ return 1
1795
+ }
1796
+ }
1797
+ const chat = hive.doc.getArray('chat')
1798
+ const savedCursor = Number(bridge.threads.get(CURSOR_KEY)) || 0
1799
+ let at = savedCursor > 0 && savedCursor <= chat.length ? savedCursor : chat.length
1800
+ chat.observe(() => { const all = chat.toArray(); bridge.onChat(all.slice(at), at); at = all.length })
1801
+ await bridge.watchRoom()
1802
+ log(`READY: [hive-slack] @${pre.botUser} bridging ${room} -> #${pre.channelName} (${CHANNEL}) — resume: ${allowParsed.ids.length ? allowParsed.ids.join(', ') : 'off'}`)
1803
+ return 0
1804
+ }
1805
+
1806
+ // ---- main -------------------------------------------------------------------------------
1807
+ if (import.meta.url === `file:///${(process.argv[1] || '').replace(/\\/g, '/')}`) {
1808
+ main().then((code) => { if (code !== 0) process.exit(code) })
1809
+ }