hivecode-mcp 0.14.1 → 0.14.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.
Files changed (72) hide show
  1. package/HIVE_RULES.md +98 -98
  2. package/LICENSE +21 -21
  3. package/README.md +421 -421
  4. package/core.js +91 -91
  5. package/docs/ALWAYS_ON.md +310 -310
  6. package/docs/GITHUB_WORKFLOW.md +394 -394
  7. package/docs/HIVE_RULES.md +41 -41
  8. package/docs/MCP.md +157 -157
  9. package/docs/QUICKSTART.md +266 -266
  10. package/docs/SELF_HOSTING.md +256 -256
  11. package/docs/SLACK_SETUP.md +474 -474
  12. package/docs/TROUBLESHOOTING.md +105 -105
  13. package/hive-activity.js +336 -336
  14. package/hive-authors.js +46 -44
  15. package/hive-await.js +230 -230
  16. package/hive-branch.js +608 -607
  17. package/hive-busy.js +88 -88
  18. package/hive-coord.js +110 -110
  19. package/hive-gate.js +135 -135
  20. package/hive-github.js +3 -1
  21. package/hive-hook-install.js +179 -179
  22. package/hive-instructions.js +74 -74
  23. package/hive-land.js +695 -691
  24. package/hive-listen.js +81 -81
  25. package/hive-mailbox.js +520 -520
  26. package/hive-mcp-http.js +591 -591
  27. package/hive-mcp.js +33 -6
  28. package/hive-mux-proto.js +58 -58
  29. package/hive-mux.js +351 -351
  30. package/hive-notify.js +234 -233
  31. package/hive-obligations.js +247 -247
  32. package/hive-poll-proto.js +50 -50
  33. package/hive-pr-staleness.js +189 -189
  34. package/hive-pr.js +600 -512
  35. package/hive-privacy.js +327 -327
  36. package/hive-receipts.js +233 -233
  37. package/hive-relay-health.js +234 -234
  38. package/hive-repos.js +430 -430
  39. package/hive-room-key.js +210 -210
  40. package/hive-room-mode.js +1668 -1668
  41. package/hive-room-watcher.js +562 -561
  42. package/hive-secrets.js +89 -89
  43. package/hive-session.js +114 -114
  44. package/hive-setup.js +560 -560
  45. package/hive-slack-agent.js +408 -408
  46. package/hive-slack.js +2384 -2367
  47. package/hive-stdio.js +172 -172
  48. package/hive-stop-hook.js +293 -293
  49. package/hive-throttle.js +118 -118
  50. package/hive-time.js +71 -71
  51. package/hive-transport.js +578 -578
  52. package/hive-update.js +58 -58
  53. package/hive-version.js +110 -110
  54. package/hive-webhook.js +55 -54
  55. package/icr-merge.js +58 -58
  56. package/icr.js +851 -851
  57. package/lang-brace.js +305 -305
  58. package/lang-js.js +651 -651
  59. package/lang-json.js +211 -211
  60. package/lang-python.js +588 -588
  61. package/lang-ruby.js +230 -230
  62. package/lang-toml.js +136 -136
  63. package/lang-yaml.js +183 -183
  64. package/limits.js +413 -413
  65. package/package.json +1 -1
  66. package/reconcile.cjs +621 -621
  67. package/slack-app-manifest-agent.json +61 -61
  68. package/slack-app-manifest-inbound.json +36 -36
  69. package/slack-app-manifest.json +27 -27
  70. package/substrate.js +252 -252
  71. package/sync.js +169 -11
  72. package/token.js +201 -201
package/hive-slack.js CHANGED
@@ -1,2367 +1,2384 @@
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 fs from 'fs'
29
- import os from 'os'
30
- import path from 'path'
31
- import WebSocket from 'ws'
32
- import { startSync, parseLink } from './sync.js'
33
- import { scrub } from './hive-webhook.js'
34
- import { realpathSync as __hiveRealpath } from 'fs'
35
- import { resolve as __hiveResolve } from 'path'
36
- import { fileURLToPath as __hiveFileURLToPath } from 'url'
37
-
38
- const DEFAULT_API = 'https://slack.com/api'
39
- const ROOM_THREAD_KEY = '__room__'
40
- const CURSOR_KEY = '__cursor__'
41
- const MAX_SEEN_EVENTS = 500
42
-
43
- export const USAGE = `usage:
44
- SLACK_BOT_TOKEN=xoxb-… npx -y hivecode-mcp@latest slack start "<relay|room>" --channel <id> [--name SlackBridge] [--allow <U123,U456>] [--inbound auto|poll|socket] [--dry-run] [--start-retry <ms>]
45
- SLACK_BOT_TOKEN=xoxb-… [SLACK_APP_TOKEN=xapp-…] npx -y hivecode-mcp@latest slack check --channel <id> [--agent] [--inbound auto|poll|socket] [--timeout <ms>] [--start-retry <ms>]
46
- SLACK_BOT_TOKEN=xoxb-… SLACK_APP_TOKEN=xapp-… npx -y hivecode-mcp@latest slack agent "<relay|room>" --channel <id> [--name <name>] [--allow <U123,U456>]
47
- 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]
48
- `
49
-
50
- // A person's message can carry a subtype: thread_broadcast ("Also send to channel" on a thread
51
- // reply) and file_share (a reply with a file). Every other subtype is not a person saying something
52
- // new: edits, deletions, joins, bot messages. Dropping every subtype silently ignored a "stop" sent
53
- // with "Also send to channel" (found by antigravity-4, 7 Nov 2026). hive-github.js keeps a copy
54
- // (it loads this module lazily); tests/hive-slack-test.mjs 10a and github-cli-test CK9b pin both.
55
- export const PERSON_SUBTYPES = new Set(['thread_broadcast', 'file_share'])
56
- export const notFromAPerson = (ev) => Boolean(!ev || ev.bot_id || (ev.subtype && !PERSON_SUBTYPES.has(ev.subtype)))
57
-
58
- export const NON_RETRYABLE = new Set([
59
- 'invalid_auth',
60
- 'token_revoked',
61
- 'account_inactive',
62
- 'not_authed',
63
- 'not_in_channel',
64
- 'channel_not_found',
65
- 'is_archived',
66
- 'missing_scope',
67
- 'ekm_access_denied',
68
- ])
69
-
70
- function extractSlackError(err) {
71
- if (!err) return ''
72
- if (err.slackError) return String(err.slackError)
73
- if (err.code && typeof err.code === 'string') return err.code
74
- const m = /slack [^:]+:\s*([a-z0-9_]+)/i.exec(err.message || '')
75
- return m ? m[1].toLowerCase() : ''
76
- }
77
-
78
- function formatSlackWhy(err) {
79
- if (!err) return 'unknown_error'
80
- const code = extractSlackError(err) || err.message || 'unknown_error'
81
- if (code === 'missing_scope' && err.needed) return `missing_scope: needs ${err.needed}`
82
- return code
83
- }
84
-
85
- export function isRateLimitError(err) {
86
- if (!err) return false
87
- if (err.status === 429) return true
88
- const code = extractSlackError(err)
89
- return code === 'ratelimited'
90
- }
91
-
92
- export function isPermanentSlackError(err) {
93
- const code = extractSlackError(err)
94
- return NON_RETRYABLE.has(code)
95
- }
96
-
97
- /** Scrub secrets/tokens (via hive-webhook.js) AND strip room links / room IDs so a room's
98
- * capability link never leaks to an external Slack channel. */
99
- export function scrubSlack(text, { room = '', link = '' } = {}) {
100
- let s = scrub(String(text || ''))
101
- if (link) s = s.split(link).join('[room link removed]')
102
- // Any wss://...|room-... or ws://...|room-... link
103
- s = s.replace(/\bwss?:\/\/[^\s|"'<>]+\|[^\s"'<>]+/gi, '[room link removed]')
104
- if (room) s = s.split(room).join('[room id removed]')
105
- s = s.replace(/\broom-[A-Za-z0-9_-]{6,}\b/g, '[room id removed]')
106
- return s
107
- }
108
-
109
- // Slack allows roughly one message per second per channel and silently reorders or drops when
110
- // you exceed it. A room can emit a dozen events in a burst, so everything leaves through one
111
- // paced queue rather than going straight out.
112
- //
113
- // 429 / ratelimited is retried in-place at the head of the queue up to maxRetries (5) times,
114
- // honouring Retry-After. Permanent auth/channel errors (invalid_auth, token_revoked,
115
- // not_in_channel, channel_not_found) are never retried: reported once via onError, then the
116
- // queue halts so a dead token or missing invite never hammers Slack in a loop.
117
- export function makeQueue({
118
- send,
119
- minGapMs = 1000,
120
- maxRetries = 5,
121
- now = () => Date.now(),
122
- timer = setTimeout,
123
- onError = null,
124
- }) {
125
- const q = []
126
- let running = false, last = 0
127
- let halted = false, haltedErr = null
128
-
129
- async function pump() {
130
- if (running) return
131
- running = true
132
- while (q.length) {
133
- const job = q[0]
134
- if (halted) {
135
- q.shift()
136
- job.reject(haltedErr || new Error('slack queue halted'))
137
- continue
138
- }
139
- const wait = Math.max(0, minGapMs - (now() - last))
140
- if (wait > 0) await new Promise((r) => timer(r, wait))
141
- last = now()
142
- try {
143
- const res = await send(job.msg)
144
- q.shift()
145
- job.resolve(res)
146
- } catch (e) {
147
- if (isRateLimitError(e) && job.attempts < maxRetries) {
148
- job.attempts++
149
- const retryWait = Number(e.retryAfterMs) > 0 ? Number(e.retryAfterMs) : Math.max(minGapMs, 1000)
150
- await new Promise((r) => timer(r, retryWait))
151
- last = now()
152
- continue
153
- }
154
- if (isPermanentSlackError(e)) {
155
- halted = true
156
- haltedErr = e
157
- if (typeof onError === 'function') {
158
- try { onError(e) } catch { /* reporter must not crash the queue */ }
159
- }
160
- } else if (typeof onError === 'function') {
161
- try { onError(e) } catch { }
162
- }
163
- q.shift()
164
- job.reject(e)
165
- }
166
- }
167
- running = false
168
- }
169
-
170
- return {
171
- push: (msg) => {
172
- if (halted) return Promise.reject(haltedErr || new Error('slack queue halted'))
173
- return new Promise((resolve, reject) => { q.push({ msg, attempts: 0, resolve, reject }); pump() })
174
- },
175
- depth: () => q.length,
176
- isHalted: () => halted,
177
- haltedError: () => haltedErr,
178
- }
179
- }
180
-
181
- export const SLACK_TIMEOUT_MS = 15000
182
-
183
- /** POST to Slack's Web API. Kept tiny and injectable so the bridge can be tested without a
184
- * workspace -- the thing worth testing is our routing, not Slack's HTTP.
185
- * Applies a 15s AbortSignal timeout by default so a hung TCP socket never stalls the queue. */
186
- export async function slackPost(
187
- method,
188
- body,
189
- token,
190
- fetchImpl = fetch,
191
- apiBase = process.env.HIVE_SLACK_API || DEFAULT_API,
192
- timeoutMs = Number(process.env.HIVE_SLACK_TIMEOUT_MS) || SLACK_TIMEOUT_MS,
193
- ) {
194
- const r = await fetchImpl(`${apiBase}/${method}`, {
195
- method: 'POST',
196
- headers: { 'Content-Type': 'application/json; charset=utf-8', Authorization: `Bearer ${token}` },
197
- body: JSON.stringify(body),
198
- signal: AbortSignal.timeout(timeoutMs),
199
- })
200
- const retryHeader = r.headers && typeof r.headers.get === 'function' ? Number(r.headers.get('retry-after') || 0) : 0
201
- const rawScopes = r.headers && typeof r.headers.get === 'function'
202
- ? r.headers.get('x-oauth-scopes')
203
- : (r.headers && (r.headers['x-oauth-scopes'] || r.headers['X-OAuth-Scopes']))
204
- const j = await r.json().catch(() => ({ ok: false, error: r.status === 429 ? 'ratelimited' : 'bad_json' }))
205
- if (r.status === 429 || (j && j.error === 'ratelimited')) {
206
- const retrySec = retryHeader || Number(j && j.retry_after) || 1
207
- const err = new Error(`slack ${method}: ratelimited`)
208
- err.status = 429
209
- err.code = 'ratelimited'
210
- err.slackError = 'ratelimited'
211
- err.retryAfterMs = retrySec * 1000
212
- throw err
213
- }
214
- if (!j || !j.ok) {
215
- const code = (j && j.error) || `http_${r.status || 'unknown'}`
216
- const needed = j && j.needed ? String(j.needed) : ''
217
- const provided = j && j.provided ? String(j.provided) : ''
218
- const detail = code === 'missing_scope' && needed ? `${code} (needs ${needed})` : code
219
- const err = new Error(`slack ${method}: ${detail}`)
220
- err.status = r.status
221
- err.code = code
222
- err.slackError = code
223
- if (needed) err.needed = needed
224
- if (provided) err.provided = provided
225
- throw err
226
- }
227
- if (rawScopes !== undefined && rawScopes !== null && j && typeof j === 'object') {
228
- const parsedScopes = String(rawScopes).split(',').map((s) => s.trim()).filter(Boolean)
229
- Object.defineProperty(j, '_scopes', { value: parsedScopes, enumerable: false, configurable: true })
230
- }
231
- return j
232
- }
233
-
234
- /** Reusable threaded Slack sender for both hive-slack.js and hive-github.js.
235
- * `threads` is any Map-like store exposing .get(key) and .set(key, ts).
236
- * NOTE: open(key, text) and reply(key, text, { broadcast }) never throw on Slack API errors;
237
- * they return { ok: true, ts, ... } or { ok: false, error, code, halted }. Always check .ok
238
- * on the returned object. */
239
- export function makeSlackThreader({
240
- token,
241
- channel,
242
- threads = new Map(),
243
- api = process.env.HIVE_SLACK_API || DEFAULT_API,
244
- post = null,
245
- minGapMs = 1000,
246
- maxRetries = 5,
247
- now = () => Date.now(),
248
- timer = setTimeout,
249
- onError = null,
250
- room = '',
251
- link = '',
252
- }) {
253
- const sendImpl = post || ((m) => slackPost('chat.postMessage', m, token, fetch, api))
254
- const queue = makeQueue({ send: sendImpl, minGapMs, maxRetries, now, timer, onError })
255
- const opening = new Map() // key -> in-flight open Promise, so synchronous bursts share one root thread
256
-
257
- async function open(key, text) {
258
- if (queue.isHalted()) return { ok: false, halted: true, error: queue.haltedError()?.message || 'halted' }
259
- const clean = scrubSlack(text, { room, link })
260
- if (key && opening.has(key)) {
261
- try { await opening.get(key) } catch { }
262
- }
263
- const existing = key ? threads.get(key) : undefined
264
- if (existing) return { ok: true, ts: existing, reused: true }
265
- const run = (async () => {
266
- const res = await queue.push({ channel, text: clean })
267
- if (res && res.ts && key) threads.set(key, res.ts)
268
- return res
269
- })()
270
- if (key) opening.set(key, run)
271
- try {
272
- const res = await run
273
- return { ok: true, ts: res && res.ts }
274
- } catch (e) {
275
- return { ok: false, error: e.message, code: extractSlackError(e) }
276
- } finally {
277
- if (key && opening.get(key) === run) opening.delete(key)
278
- }
279
- }
280
-
281
- async function reply(key, text, { broadcast = false } = {}) {
282
- if (queue.isHalted()) return { ok: false, halted: true, error: queue.haltedError()?.message || 'halted' }
283
- const clean = scrubSlack(text, { room, link })
284
- if (key && opening.has(key)) {
285
- try { await opening.get(key) } catch { }
286
- }
287
- const ts = key ? threads.get(key) : undefined
288
- if (!ts) return open(key, clean)
289
- try {
290
- const payload = { channel, text: clean, thread_ts: ts }
291
- if (broadcast) payload.reply_broadcast = true
292
- const res = await queue.push(payload)
293
- return { ok: true, ts: res && res.ts, thread_ts: ts }
294
- } catch (e) {
295
- return { ok: false, error: e.message, code: extractSlackError(e) }
296
- }
297
- }
298
-
299
- return {
300
- open,
301
- reply,
302
- threads,
303
- queue,
304
- halted: () => ({ halted: queue.isHalted(), error: queue.haltedError()?.message || '' }),
305
- }
306
- }
307
-
308
- /** Verify token and channel membership BEFORE printing READY (Q630, Q631). */
309
- export async function preflightSlack({
310
- token,
311
- channel,
312
- api = process.env.HIVE_SLACK_API || DEFAULT_API,
313
- fetchImpl = fetch,
314
- } = {}) {
315
- if (!token || !String(token).trim()) {
316
- return { ok: false, code: 'Q630', error: 'Q630 SLACK_BOT_TOKEN is missing. Set SLACK_BOT_TOKEN=xoxb-… in your environment.' }
317
- }
318
- if (!channel || !String(channel).trim()) {
319
- return { ok: false, code: 'Q631', error: 'Q631 --channel <id> is required (for example: --channel C0123456789).' }
320
- }
321
- let auth
322
- try {
323
- auth = await slackPost('auth.test', {}, token, fetchImpl, api)
324
- } catch (e) {
325
- if (!e || !e.slackError) {
326
- const why = formatSlackWhy(e)
327
- 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.` }
328
- }
329
- const why = formatSlackWhy(e)
330
- return { ok: false, code: 'Q630', error: `Q630 SLACK_BOT_TOKEN was rejected by Slack (${why}). Check the token or reinstall the Slack app.` }
331
- }
332
- const botUser = auth.user || 'Hivecode'
333
- let info
334
- try {
335
- info = await slackPost('conversations.info', { channel }, token, fetchImpl, api)
336
- } catch (e) {
337
- if (!e || !e.slackError) {
338
- const why = formatSlackWhy(e)
339
- 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.` }
340
- }
341
- const why = formatSlackWhy(e)
342
- 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.` }
343
- }
344
- if (info && info.channel && info.channel.is_member === false) {
345
- const chName = info.channel.name ? `#${info.channel.name}` : channel
346
- return { ok: false, code: 'Q631', error: `Q631 @${botUser} is not a member of ${chName} (${channel}). In Slack, open ${chName} and run: /invite @${botUser}` }
347
- }
348
- const scopes = Array.isArray(auth && auth._scopes)
349
- ? auth._scopes
350
- : (Array.isArray(info && info._scopes) ? info._scopes : null)
351
- const isPrivate = Boolean(
352
- info && info.channel && (
353
- info.channel.is_private === true ||
354
- info.channel.is_group === true ||
355
- String(channel || '').startsWith('G')
356
- ),
357
- )
358
- return {
359
- ok: true,
360
- botUser,
361
- botUserId: auth.user_id || '',
362
- team: auth.team || '',
363
- teamId: auth.team_id || '',
364
- channelName: (info && info.channel && info.channel.name) || channel,
365
- isPrivate,
366
- scopes,
367
- }
368
- }
369
-
370
- /** Return the list of missing inbound scopes (users:read, plus channels:history for public or groups:history for private).
371
- * When `scopes` is null/undefined (header absent), returns [] so "unknown" is never treated as "none". */
372
- export function missingInboundScopes({ scopes = null, isPrivate = false, channel = '' } = {}) {
373
- if (scopes === null || scopes === undefined) return []
374
- const have = new Set(
375
- (Array.isArray(scopes) ? scopes : String(scopes || '').split(','))
376
- .map((s) => String(s || '').trim())
377
- .filter(Boolean),
378
- )
379
- const histScope = (isPrivate || String(channel || '').startsWith('G')) ? 'groups:history' : 'channels:history'
380
- const missing = []
381
- if (!have.has('users:read')) missing.push('users:read')
382
- if (!have.has(histScope)) missing.push(histScope)
383
- return missing
384
- }
385
-
386
- /** Verify that SLACK_BOT_TOKEN has the scopes required for inbound replies (users:read + channels:history or groups:history). */
387
- export function checkInboundScopes({ scopes = null, isPrivate = false, channel = '' } = {}) {
388
- const missing = missingInboundScopes({ scopes, isPrivate, channel })
389
- if (missing.length) {
390
- return {
391
- ok: false,
392
- code: 'Q634',
393
- missingScopes: missing,
394
- 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).`,
395
- }
396
- }
397
- return { ok: true, missingScopes: [] }
398
- }
399
-
400
- /** Return the list of missing Slack Agent surface scopes (chat:write, assistant:write, users:read, plus im:history / channels:history / groups:history).
401
- * When `scopes` is null/undefined (header absent), returns [] so "unknown" is never treated as "none". */
402
- export function missingAgentScopes({ scopes = null, isPrivate = false, channel = '' } = {}) {
403
- if (scopes === null || scopes === undefined) return []
404
- const have = new Set(
405
- (Array.isArray(scopes) ? scopes : String(scopes || '').split(','))
406
- .map((s) => String(s || '').trim())
407
- .filter(Boolean),
408
- )
409
- const ch = String(channel || '').trim()
410
- const histScope = (!ch || ch.startsWith('D'))
411
- ? 'im:history'
412
- : ((isPrivate || ch.startsWith('G')) ? 'groups:history' : 'channels:history')
413
- const required = ['chat:write', 'assistant:write', histScope, 'users:read']
414
- return required.filter((s) => !have.has(s))
415
- }
416
-
417
- /** Verify that SLACK_BOT_TOKEN has the scopes required for the Slack Agent surface (hive-slack-agent.js). */
418
- export function checkAgentScopes({ scopes = null, isPrivate = false, channel = '' } = {}) {
419
- const missing = missingAgentScopes({ scopes, isPrivate, channel })
420
- if (missing.length) {
421
- return {
422
- ok: false,
423
- code: 'Q634',
424
- missingScopes: missing,
425
- 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).`,
426
- }
427
- }
428
- return { ok: true, missingScopes: [] }
429
- }
430
-
431
- /** Read Slack warnings from both shapes Slack uses: top-level `warning` string and `response_metadata.warnings[]`. */
432
- export function extractSlackWarnings(res) {
433
- if (!res || typeof res !== 'object') return []
434
- const out = []
435
- if (typeof res.warning === 'string' && res.warning.trim()) {
436
- for (const w of res.warning.split(',')) {
437
- if (w.trim()) out.push(w.trim())
438
- }
439
- }
440
- const metaWarnings = res.response_metadata && Array.isArray(res.response_metadata.warnings)
441
- ? res.response_metadata.warnings
442
- : []
443
- for (const w of metaWarnings) {
444
- if (typeof w === 'string' && w.trim() && !out.includes(w.trim())) out.push(w.trim())
445
- }
446
- return out
447
- }
448
-
449
- /** Verify SLACK_APP_TOKEN (xapp-…) with apps.connections.open (and inbound bot scopes when passed) before READY (Q634). */
450
- export async function preflightSocketMode({
451
- appToken,
452
- scopes = null,
453
- isPrivate = false,
454
- channel = '',
455
- api = process.env.HIVE_SLACK_API || DEFAULT_API,
456
- fetchImpl = fetch,
457
- } = {}) {
458
- if (!appToken || !String(appToken).trim()) {
459
- return {
460
- ok: false,
461
- code: 'Q634',
462
- error: 'Q634 SLACK_APP_TOKEN is missing. Set SLACK_APP_TOKEN=xapp-… (with connections:write scope) to enable inbound Socket Mode.',
463
- }
464
- }
465
- if (scopes !== null && scopes !== undefined) {
466
- const sc = checkInboundScopes({ scopes, isPrivate, channel })
467
- if (!sc.ok) return sc
468
- }
469
- try {
470
- const res = await slackPost('apps.connections.open', {}, appToken, fetchImpl, api)
471
- if (!res || !res.url) {
472
- return { ok: false, code: 'Q634', error: 'Q634 Slack apps.connections.open did not return a WebSocket URL.' }
473
- }
474
- return { ok: true, url: res.url }
475
- } catch (e) {
476
- if (!e || !e.slackError) {
477
- const why = formatSlackWhy(e)
478
- 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.` }
479
- }
480
- const why = extractSlackError(e) || e.message
481
- return {
482
- ok: false,
483
- code: 'Q634',
484
- error: `Q634 SLACK_APP_TOKEN was rejected by Slack (${why}). Enable Socket Mode and generate an App-Level Token (xapp-…) with connections:write.`,
485
- }
486
- }
487
- }
488
-
489
- /** Connect to Slack Socket Mode over ws. Immediately acknowledges every envelope_id (<3s),
490
- * deduplicates event_id across reconnects, reconnects on Slack's periodic disconnect frame,
491
- * and halts on permanent token errors. */
492
- export async function connectSocketMode({
493
- appToken,
494
- initialUrl = '',
495
- api = process.env.HIVE_SLACK_API || DEFAULT_API,
496
- fetchImpl = fetch,
497
- WebSocketImpl = WebSocket,
498
- onEvent,
499
- onError = null,
500
- onMultiConnection = null,
501
- logErr = console.error,
502
- reconnectDelayMs = 1000,
503
- maxReconnectDelayMs = 60000,
504
- pingIntervalMs = Number(process.env.HIVE_SLACK_PING_INTERVAL_MS) || 30000,
505
- probeTimeoutMs = Number(process.env.HIVE_SLACK_PROBE_TIMEOUT_MS) || 5000,
506
- helloTimeoutMs = Number(process.env.HIVE_SLACK_HELLO_TIMEOUT_MS) || 10000,
507
- startRetryMs = Number(process.env.HIVE_SLACK_START_RETRY_MS) || 60000,
508
- startRetryDelayMs = 1000,
509
- timer = setTimeout,
510
- // true: an upgrade answered with an HTTP status (a proxy that refuses WebSockets, like the
511
- // "403 WebSocket upgrades are not allowed" on Antigravity's VMs) throws at once with
512
- // err.wsRefused = <status>, instead of retrying for startRetryMs, so the caller can poll.
513
- failFastOnRefusal = false,
514
- } = {}) {
515
- let ws = null
516
- let stopped = false
517
- let halted = false
518
- let reconnectTimer = null
519
- const seenEvents = new Set()
520
- let lastCloseTime = 0
521
- let warnedMulti = false
522
- let reconnectAttempts = 0
523
- let inOutage = false
524
-
525
- function getNextReconnectDelay() {
526
- const delay = Math.min(maxReconnectDelayMs, reconnectDelayMs * Math.pow(2, reconnectAttempts))
527
- reconnectAttempts++
528
- return delay
529
- }
530
-
531
- function rememberEvent(id) {
532
- if (!id) return false
533
- if (seenEvents.has(id)) return true
534
- seenEvents.add(id)
535
- if (seenEvents.size > MAX_SEEN_EVENTS) {
536
- const oldest = seenEvents.values().next().value
537
- seenEvents.delete(oldest)
538
- }
539
- return false
540
- }
541
-
542
- function wireSocket(sock) {
543
- let lastActivity = Date.now()
544
- let probeTimer = null
545
- let watchdogTimer = null
546
- let healthyTimer = null
547
-
548
- function recordActivity() {
549
- lastActivity = Date.now()
550
- if (probeTimer) {
551
- clearTimeout(probeTimer)
552
- probeTimer = null
553
- scheduleWatchdog()
554
- }
555
- }
556
-
557
- function scheduleWatchdog() {
558
- if (stopped || halted || ws !== sock || probeTimer) return
559
- if (watchdogTimer) clearTimeout(watchdogTimer)
560
- const silence = Date.now() - lastActivity
561
- const delay = Math.max(50, pingIntervalMs - silence)
562
- watchdogTimer = timer(checkWatchdog, delay)
563
- }
564
-
565
- function checkWatchdog() {
566
- if (stopped || halted || ws !== sock) return
567
- const silence = Date.now() - lastActivity
568
- if (silence >= pingIntervalMs) {
569
- if (!probeTimer) {
570
- try {
571
- if (typeof sock.ping === 'function') sock.ping()
572
- } catch { }
573
-
574
- probeTimer = timer(() => {
575
- if (stopped || halted || ws !== sock) return
576
- if (!inOutage) {
577
- inOutage = true
578
- if (typeof logErr === 'function') {
579
- try {
580
- logErr(`Slack Socket Mode connection went silent (no ping or message for ${Math.round((Date.now() - lastActivity) / 1000)} s); terminating to reconnect...`)
581
- } catch { }
582
- }
583
- }
584
- try { sock.terminate ? sock.terminate() : sock.close() } catch { }
585
- }, probeTimeoutMs)
586
- }
587
- return
588
- }
589
- scheduleWatchdog()
590
- }
591
-
592
- scheduleWatchdog()
593
-
594
- sock.on('message', (raw) => {
595
- recordActivity()
596
- let frame
597
- try { frame = JSON.parse(String(raw)) } catch { return }
598
- // Acknowledge every envelope immediately (<3s) so Slack never retries and duplicates a stop/reply.
599
- if (frame && frame.envelope_id) {
600
- try { sock.send(JSON.stringify({ envelope_id: frame.envelope_id })) } catch { }
601
- }
602
- if (!frame) return
603
- if (frame.type === 'hello') {
604
- if (inOutage) {
605
- inOutage = false
606
- if (typeof logErr === 'function') {
607
- try { logErr('Slack Socket Mode reconnected.') } catch { }
608
- }
609
- }
610
- if (healthyTimer) clearTimeout(healthyTimer)
611
- const healthyDelay = Math.min(pingIntervalMs, reconnectDelayMs * 2)
612
- healthyTimer = timer(() => {
613
- if (!stopped && !halted && ws === sock) {
614
- reconnectAttempts = 0
615
- }
616
- }, healthyDelay)
617
- const num = Number(frame.num_connections) || 1
618
- const isOwnLingering = (num === 2 && (Date.now() - lastCloseTime < 15000))
619
- if (num > 1 && !isOwnLingering) {
620
- if (!warnedMulti) {
621
- warnedMulti = true
622
- if (typeof logErr === 'function') {
623
- try {
624
- 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").`)
625
- } catch { }
626
- }
627
- if (typeof onMultiConnection === 'function') {
628
- try { onMultiConnection(num) } catch { }
629
- }
630
- }
631
- } else if (num <= 1) {
632
- warnedMulti = false
633
- }
634
- return
635
- }
636
- if (frame.type === 'disconnect') {
637
- try { sock.close() } catch { }
638
- return
639
- }
640
- const evId = (frame.payload && frame.payload.event_id) || frame.envelope_id || ''
641
- if (evId && rememberEvent(evId)) return
642
- const ev = frame.payload && frame.payload.event
643
- if (ev && typeof onEvent === 'function') {
644
- Promise.resolve(onEvent(ev, frame.payload)).catch((e) => {
645
- if (typeof onError === 'function') { try { onError(e) } catch { } }
646
- })
647
- }
648
- })
649
-
650
- sock.on('ping', () => {
651
- recordActivity()
652
- })
653
-
654
- sock.on('pong', () => {
655
- recordActivity()
656
- })
657
-
658
- sock.on('error', (e) => {
659
- if (typeof onError === 'function') { try { onError(e) } catch { } }
660
- })
661
-
662
- sock.on('close', () => {
663
- lastCloseTime = Date.now()
664
- if (probeTimer) { clearTimeout(probeTimer); probeTimer = null }
665
- if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null }
666
- if (healthyTimer) { clearTimeout(healthyTimer); healthyTimer = null }
667
- if (ws === sock) ws = null
668
- if (!stopped && !halted) {
669
- const delay = getNextReconnectDelay()
670
- reconnectTimer = timer(() => openSocket(''), delay)
671
- }
672
- })
673
- }
674
-
675
- async function openSocket(firstUrl = '') {
676
- if (stopped || halted) return
677
- let url = firstUrl
678
- if (!url) {
679
- try {
680
- const res = await slackPost('apps.connections.open', {}, appToken, fetchImpl, api)
681
- url = res && res.url
682
- } catch (e) {
683
- if (isPermanentSlackError(e)) {
684
- halted = true
685
- if (typeof onError === 'function') { try { onError(e) } catch { } }
686
- return
687
- }
688
- if (typeof onError === 'function') { try { onError(e) } catch { } }
689
- if (!stopped) {
690
- const delay = getNextReconnectDelay()
691
- reconnectTimer = timer(() => openSocket(''), delay)
692
- }
693
- return
694
- }
695
- }
696
- if (!url || stopped || halted) return
697
- const sock = new WebSocketImpl(url)
698
- ws = sock
699
- wireSocket(sock)
700
- }
701
-
702
- // Initial connection handshake with startRetryMs retry for transient socket/network errors and num_connections > 1
703
- const startTime = Date.now()
704
- let currentUrl = initialUrl
705
- let lastWhy = ''
706
- let lastNum = 1
707
- let warnedRetry = false
708
-
709
- while (true) {
710
- if (stopped || halted) return { stop: () => {}, isHalted: () => true }
711
- if (!currentUrl) {
712
- try {
713
- const res = await slackPost('apps.connections.open', {}, appToken, fetchImpl, api)
714
- currentUrl = res && res.url
715
- } catch (e) {
716
- if (isPermanentSlackError(e)) {
717
- halted = true
718
- throw e
719
- }
720
- lastWhy = extractSlackError(e) || e.message
721
- const elapsed = Date.now() - startTime
722
- if (elapsed < startRetryMs) {
723
- if (!warnedRetry && typeof logErr === 'function') {
724
- warnedRetry = true
725
- try { logErr(`Slack Socket Mode could not connect (${lastWhy}); retrying for up to ${Math.round(startRetryMs / 1000)} s...`) } catch { }
726
- }
727
- await new Promise((r) => timer(r, Math.min(startRetryDelayMs, startRetryMs - elapsed)))
728
- continue
729
- }
730
- halted = true
731
- stopped = true
732
- const err = new Error(`Q634 Socket Mode could not connect: ${lastWhy}`)
733
- err.code = 'Q634'
734
- throw err
735
- }
736
- }
737
- if (!currentUrl) {
738
- const err = new Error('Q634 Slack apps.connections.open did not return a WebSocket URL.')
739
- err.code = 'Q634'
740
- throw err
741
- }
742
-
743
- let sock = null
744
- const handshake = await new Promise((resolve) => {
745
- let settled = false
746
- const finish = (result) => {
747
- if (settled) return
748
- settled = true
749
- clearTimeout(hTimer)
750
- resolve(result)
751
- }
752
- const hTimer = timer(() => finish({ type: 'timeout', reason: 'no hello frame received within timeout' }), helloTimeoutMs)
753
- try {
754
- sock = new WebSocketImpl(currentUrl)
755
- } catch (e) {
756
- finish({ type: 'error', error: e, reason: e.message })
757
- return
758
- }
759
- ws = sock
760
-
761
- const onMsg = (raw) => {
762
- let frame
763
- try { frame = JSON.parse(String(raw)) } catch { return }
764
- if (frame && frame.envelope_id) {
765
- try { sock.send(JSON.stringify({ envelope_id: frame.envelope_id })) } catch { }
766
- }
767
- if (!frame) return
768
- if (frame.type === 'hello') {
769
- finish({ type: 'hello', frame, sock, onMsg, onErr, onClose })
770
- return
771
- }
772
- }
773
-
774
- const onErr = (e) => {
775
- finish({ type: 'error', error: e, reason: e.message || String(e) })
776
- }
777
-
778
- const onClose = () => {
779
- lastCloseTime = Date.now()
780
- finish({ type: 'close', reason: 'connection closed before receiving hello frame' })
781
- }
782
-
783
- sock.on('message', onMsg)
784
- sock.on('error', onErr)
785
- sock.on('close', onClose)
786
- })
787
-
788
- if (handshake.type === 'hello') {
789
- const num = Number(handshake.frame && handshake.frame.num_connections) || 1
790
- lastNum = num
791
- if (num <= 1) {
792
- wireSocket(handshake.sock)
793
- try {
794
- handshake.sock.removeListener('message', handshake.onMsg)
795
- handshake.sock.removeListener('close', handshake.onClose)
796
- handshake.sock.removeListener('error', handshake.onErr)
797
- } catch { }
798
- break
799
- }
800
-
801
- lastWhy = `${num} active connections`
802
- const elapsed = Date.now() - startTime
803
- if (elapsed < startRetryMs) {
804
- if (!warnedRetry && typeof logErr === 'function') {
805
- warnedRetry = true
806
- try { logErr(`Slack reports ${num} connections on this app token; waiting up to ${Math.round(startRetryMs / 1000)} s for the other to drop...`) } catch { }
807
- }
808
- try { handshake.sock.removeAllListeners && handshake.sock.removeAllListeners() } catch { }
809
- try { handshake.sock.close() } catch { }
810
- const waitTime = Math.min(startRetryDelayMs, Math.max(50, startRetryMs - elapsed))
811
- await new Promise((r) => timer(r, waitTime))
812
- currentUrl = initialUrl || ''
813
- continue
814
- }
815
-
816
- halted = true
817
- stopped = true
818
- try { handshake.sock.close() } catch { }
819
- 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.`)
820
- err.code = 'Q635'
821
- throw err
822
- }
823
-
824
- // Handshake failed (socket error, timeout, or close-before-hello)
825
- lastWhy = handshake.reason || (handshake.error && handshake.error.message) || 'could not connect'
826
- if (sock) {
827
- try { sock.removeAllListeners && sock.removeAllListeners() } catch { }
828
- try { sock.close() } catch { }
829
- try { sock.terminate && sock.terminate() } catch { }
830
- }
831
- const refused = refusedUpgradeStatus(lastWhy)
832
- if (failFastOnRefusal && refused) {
833
- halted = true
834
- stopped = true
835
- const err = new Error(`Q634 Socket Mode refused: this network answered the WebSocket upgrade with HTTP ${refused}`)
836
- err.code = 'Q634'
837
- err.wsRefused = refused
838
- throw err
839
- }
840
-
841
- const elapsed = Date.now() - startTime
842
- if (elapsed < startRetryMs) {
843
- if (!warnedRetry && typeof logErr === 'function') {
844
- warnedRetry = true
845
- try { logErr(`Slack Socket Mode could not connect (${lastWhy}); retrying for up to ${Math.round(startRetryMs / 1000)} s...`) } catch { }
846
- }
847
- const waitTime = Math.min(startRetryDelayMs, Math.max(50, startRetryMs - elapsed))
848
- await new Promise((r) => timer(r, waitTime))
849
- currentUrl = initialUrl || ''
850
- continue
851
- }
852
-
853
- halted = true
854
- stopped = true
855
- const err = new Error(`Q634 Socket Mode could not connect: ${lastWhy}`)
856
- err.code = 'Q634'
857
- throw err
858
- }
859
-
860
- return {
861
- stop: () => {
862
- stopped = true
863
- if (reconnectTimer) clearTimeout(reconnectTimer)
864
- if (ws) {
865
- try { ws.close() } catch { }
866
- try { ws.terminate && ws.terminate() } catch { }
867
- ws = null
868
- }
869
- },
870
- isHalted: () => halted,
871
- }
872
- }
873
-
874
- /** Pause every AI in the room (excluding the bridge itself when skipName is given). */
875
- export function stopAll(hive, by = 'slack', skipName = '') {
876
- const done = []
877
- for (const p of (hive.members ? hive.members() : [])) {
878
- if (p.kind !== 'ai' || (skipName && p.name === skipName)) continue
879
- hive.control(p.name, 'paused', by)
880
- done.push(p.name)
881
- }
882
- return done
883
- }
884
-
885
- export function resumeAll(hive, by = 'slack', skipName = '') {
886
- const done = []
887
- for (const p of (hive.members ? hive.members() : [])) {
888
- if (p.kind !== 'ai' || (skipName && p.name === skipName)) continue
889
- hive.control(p.name, 'running', by)
890
- done.push(p.name)
891
- }
892
- return done
893
- }
894
-
895
- const CONTROL_WATCHES = new WeakMap() // controls Y.Map -> Map(target -> end that watch)
896
-
897
- /** The AIs a "stop all" / "resume all" acts on. */
898
- export function aiTargets(hive, skipName = '') {
899
- return (hive.members ? hive.members() : []).filter((p) => p.kind === 'ai' && !(skipName && p.name === skipName)).map((p) => p.name)
900
- }
901
-
902
- /** Write a control, then watch whether the room keeps it. Resolves true (kept) or false (refused).
903
- *
904
- * WHY (8 Nov 2026). The relay's governor puts back a control written by anyone who may not
905
- * control: every AI, and on a strict room anyone without a human token. It does that one round
906
- * trip later and says nothing. The bridge used to answer Slack "paused" straight after writing,
907
- * so a refused stop was reported as done and the agents kept running
908
- * (tests/slack-control-confirm-test.mjs). There is no positive ack from the relay, so:
909
- * - within settleMs, any change of this agent's control away from the state we asked for is the
910
- * revert -> false;
911
- * - after that, until lateMs, the control going back to exactly what it was before our write is
912
- * a late revert -> onLateRevert() (so Slack can be corrected); any other change is someone
913
- * else's new decision and ends the watch.
914
- * A hive with no doc (a test fake) cannot be watched and resolves true. */
915
- export function confirmControl(hive, target, state, by, { settleMs = 1500, lateMs = 30000, onLateRevert = null } = {}) {
916
- const controls = hive && hive.doc && typeof hive.doc.getMap === 'function' ? hive.doc.getMap('controls') : null
917
- const snap = (v) => (v ? JSON.stringify([v.state, v.by, v.at]) : '')
918
- // A newer write to the same agent through this function ends the older watch: otherwise "stop"
919
- // then "resume" within one second (same by, same `at`) reads as the room putting the old value back.
920
- const watching = controls ? (CONTROL_WATCHES.get(controls) || CONTROL_WATCHES.set(controls, new Map()).get(controls)) : null
921
- if (watching && watching.has(target)) { try { watching.get(target)() } catch { } }
922
- const before = controls ? snap(controls.get(target)) : ''
923
- hive.control(target, state, by)
924
- if (!controls || !(settleMs > 0)) return Promise.resolve(true)
925
- const ours = snap(controls.get(target))
926
- if (ours === before) return Promise.resolve(true) // already exactly this; nothing to revert
927
- return new Promise((resolve) => {
928
- let settled = false
929
- let t1 = null, t2 = null
930
- const cleanup = () => {
931
- try { controls.unobserve(obs) } catch { }
932
- if (t1) clearTimeout(t1)
933
- if (t2) clearTimeout(t2)
934
- if (watching.get(target) === cleanup) watching.delete(target)
935
- if (!settled) { settled = true; resolve(true) } // superseded by our own newer write
936
- }
937
- watching.set(target, cleanup)
938
- const obs = (ev) => {
939
- if (!ev.keysChanged || !ev.keysChanged.has(target)) return
940
- const cur = controls.get(target)
941
- const now = snap(cur)
942
- if (now === ours) return
943
- if (!settled) {
944
- if (!cur || cur.state !== state) { settled = true; cleanup(); resolve(false) }
945
- return
946
- }
947
- if (now === before && typeof onLateRevert === 'function') { try { onLateRevert() } catch { } }
948
- cleanup()
949
- }
950
- controls.observe(obs)
951
- t1 = setTimeout(() => { t1 = null; if (!settled) { settled = true; resolve(true) } }, settleMs)
952
- t2 = setTimeout(() => { t2 = null; cleanup() }, Math.max(settleMs, lateMs))
953
- if (t2.unref) t2.unref()
954
- })
955
- }
956
-
957
- /** Find the room's real spelling of a participant name (case-insensitive). */
958
- export function resolveName(hive, who) {
959
- const want = String(who || '').toLowerCase()
960
- for (const p of (hive.members ? hive.members() : [])) {
961
- if (String(p.name).toLowerCase() === want) return p.name
962
- }
963
- return null
964
- }
965
-
966
- /** Turn a typed prompt into a deterministic control/status intent. */
967
- export function route(text) {
968
- const s = String(text || '').trim().toLowerCase()
969
- let m
970
- if ((m = s.match(/^(?:resume|unpause|start|continue)\s+(?:agent\s+)?([a-z0-9_.-]+)$/))) {
971
- return m[1] === 'all' || m[1] === 'everything' ? { intent: 'resume-all' } : { intent: 'resume', who: m[1] }
972
- }
973
- if ((m = s.match(/^(?:stop|pause|halt|kill)\s+(?:agent\s+)?([a-z0-9_.-]+)$/))) {
974
- return m[1] === 'all' || m[1] === 'everything' ? { intent: 'stop-all' } : { intent: 'stop', who: m[1] }
975
- }
976
- if (/^(stop|pause|halt|stop it|stop them|shut it down)\b/.test(s)) return { intent: 'stop-all' }
977
- if (/^(resume|unpause|carry on|keep going)\b/.test(s)) return { intent: 'resume-all' }
978
- if (/status|what.*(running|happening|going on)|who.*(here|working)|overnight/.test(s)) return { intent: 'status' }
979
- return { intent: 'ask', text: String(text || '') }
980
- }
981
-
982
- /** Never let a Slack display name impersonate a room member: always attribute as slack:<handle>. */
983
- export function formatSlackSender(handleOrUser) {
984
- const raw = String(handleOrUser || 'user').trim().replace(/^slack:/i, '')
985
- const clean = raw.replace(/[^A-Za-z0-9_.-]/g, '') || 'user'
986
- return `slack:${clean}`
987
- }
988
-
989
- /** Refuse to run a second bridge under the same name in the same room (Q632). */
990
- export function checkDuplicateBridge(hive, botName = 'SlackBridge') {
991
- let dupes = []
992
- try { dupes = hive && typeof hive.sameNameElsewhere === 'function' ? hive.sameNameElsewhere() : [] } catch { dupes = [] }
993
- if (dupes.length) {
994
- return {
995
- ok: false,
996
- code: 'Q632',
997
- 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.`,
998
- }
999
- }
1000
- return { ok: true }
1001
- }
1002
-
1003
- /** What a room event looks like in Slack. Plain text on purpose: blocks render badly in
1004
- * notifications and thread previews, which is where most of these are actually read. */
1005
- export function render(ev) {
1006
- if (ev.kind === 'task-open') return `:inbox_tray: *${ev.text || ev.task}* — assigned to *${ev.to}*`
1007
- if (ev.kind === 'task-done') return `:white_check_mark: *${ev.text || ev.task}* — done by *${ev.by}*`
1008
- 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`
1009
- if (ev.kind === 'gate-pass') return `:heavy_check_mark: gate passed for *${ev.by}* on \`${ev.file}\``
1010
- if (ev.kind === 'chat') return `*${ev.by}*: ${ev.text}`
1011
- return `${ev.kind}: ${ev.text || ''}`
1012
- }
1013
-
1014
- /** Which events reach the CHANNEL, and which only ever go inside a thread.
1015
- * Getting this wrong is the difference between a useful feed and a muted one. */
1016
- export const TO_CHANNEL = new Set(['task-open', 'task-done'])
1017
-
1018
- export const USER_CACHE_TTL_MS = 10 * 60 * 1000
1019
-
1020
- /** Shared Slack actor verification and asymmetric authorization (used by both hive-slack.js
1021
- * and hive-github.js):
1022
- * - caches verified users.info results for ttlMs (default 10 minutes); lookup failures are not cached
1023
- * - refuses bots, deleted users, guests (is_restricted / is_ultra_restricted / is_stranger),
1024
- * and external Slack Connect users (team_id !== teamId)
1025
- * - allows 'stop' / 'stop-all' for any full workspace member, AND fails open with unverified: true
1026
- * ("stopped; I could not confirm who you are") if users.info failed during an outage
1027
- * - allows 'resume' / 'retry' / 'reply' only when actor.ok === true AND userId is in allow */
1028
- export function makeSlackActorCheck({
1029
- token,
1030
- teamId = '',
1031
- api = process.env.HIVE_SLACK_API || DEFAULT_API,
1032
- fetchImpl = fetch,
1033
- lookupUser = null,
1034
- ttlMs = USER_CACHE_TTL_MS,
1035
- now = () => Date.now(),
1036
- allowFlag = '--allow',
1037
- } = {}) {
1038
- const userCache = new Map() // userId -> { value, expiresAt }
1039
-
1040
- function put(userId, value) {
1041
- userCache.set(userId, { value, expiresAt: now() + ttlMs })
1042
- return value
1043
- }
1044
-
1045
- async function resolve(userId) {
1046
- if (!userId) return { ok: false, userId: '', reason: 'missing_user' }
1047
- const cached = userCache.get(userId)
1048
- if (cached) {
1049
- if (now() < cached.expiresAt) return cached.value
1050
- userCache.delete(userId)
1051
- }
1052
- let res
1053
- try {
1054
- res = lookupUser
1055
- ? await lookupUser(userId)
1056
- : await slackPost('users.info', { user: userId }, token, fetchImpl, api)
1057
- } catch (e) {
1058
- return { ok: false, userId, handle: userId, reason: 'lookup_failed', error: e.message }
1059
- }
1060
- const u = res && res.user
1061
- if (!u || u.deleted || u.is_bot) {
1062
- return put(userId, { ok: false, userId, isBot: Boolean(u && u.is_bot), reason: 'bot_or_deleted' })
1063
- }
1064
- const handle = u.name || (u.profile && u.profile.display_name) || userId
1065
- if (u.is_restricted || u.is_ultra_restricted || u.is_stranger) {
1066
- return put(userId, { ok: false, userId, handle, reason: 'guest' })
1067
- }
1068
- if (teamId && u.team_id && u.team_id !== teamId) {
1069
- return put(userId, { ok: false, userId, handle, reason: 'external_team' })
1070
- }
1071
- return put(userId, { ok: true, userId, handle, teamId: u.team_id || '' })
1072
- }
1073
-
1074
- function mayAct(actor, action, allow = []) {
1075
- if (!actor || actor.isBot) {
1076
- return { ok: false, ignored: true, unverified: false, reason: 'bot_or_empty', by: '', reply: '' }
1077
- }
1078
- const uid = actor.userId || ''
1079
- const allowSet = allow instanceof Set
1080
- ? allow
1081
- : new Set(
1082
- (Array.isArray(allow) ? allow : String(allow || '').split(','))
1083
- .map((s) => String(s || '').trim())
1084
- .filter(Boolean),
1085
- )
1086
- const isStop = action === 'stop' || action === 'stop-all'
1087
- const by = formatSlackSender(actor.handle || uid || 'user')
1088
-
1089
- if (!actor.ok) {
1090
- if (actor.reason === 'lookup_failed' && isStop) {
1091
- return { ok: true, unverified: true, by, reply: 'stopped; I could not confirm who you are' }
1092
- }
1093
- const reply = actor.reason === 'lookup_failed'
1094
- ? `:no_entry: Refused: could not verify your Slack user (${uid || 'unknown'}) via users.info.`
1095
- : `:no_entry: Refused: guests and external Slack Connect users cannot control or message this room.`
1096
- return { ok: false, unverified: false, refused: actor.reason, by, reply }
1097
- }
1098
-
1099
- if (isStop) {
1100
- return { ok: true, unverified: false, by, reply: '' }
1101
- }
1102
-
1103
- if (!allowSet.has(uid)) {
1104
- return {
1105
- ok: false,
1106
- unverified: false,
1107
- refused: 'not_in_allow',
1108
- by,
1109
- 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\`.`,
1110
- }
1111
- }
1112
-
1113
- return { ok: true, unverified: false, by, reply: '' }
1114
- }
1115
-
1116
- return { resolve, mayAct }
1117
- }
1118
-
1119
- /** Parse and validate Slack member ids from --allow or SLACK_ALLOW (Q634).
1120
- * Refuses anything not matching /^[UW][A-Z0-9]{2,}$/ (e.g. usernames or handles). */
1121
- export function parseSlackAllow(allowRaw, flag = '--allow') {
1122
- const raw = Array.isArray(allowRaw) ? allowRaw.join(',') : String(allowRaw || '').trim()
1123
- if (!raw) return { ok: true, ids: [] }
1124
- const list = raw.split(',').map((s) => s.trim()).filter(Boolean)
1125
- const notIds = list.filter((s) => !/^[UW][A-Z0-9]{2,}$/.test(s))
1126
- if (notIds.length) {
1127
- return {
1128
- ok: false,
1129
- code: 'Q634',
1130
- 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.`,
1131
- }
1132
- }
1133
- return { ok: true, ids: list }
1134
- }
1135
-
1136
- export const validateSlackAllow = parseSlackAllow
1137
-
1138
- export function makeBridge({
1139
- hive,
1140
- channel,
1141
- token,
1142
- post,
1143
- botName = 'SlackBridge',
1144
- onError = null,
1145
- room = '',
1146
- link = '',
1147
- allow = [],
1148
- teamId = '',
1149
- botUserId = '',
1150
- lookupUser = null,
1151
- minGapMs = 1000,
1152
- controlConfirmMs = 1500,
1153
- controlLateMs = 30000,
1154
- now = () => Date.now(),
1155
- userCacheTtlMs = USER_CACHE_TTL_MS,
1156
- api = process.env.HIVE_SLACK_API || DEFAULT_API,
1157
- fetchImpl = fetch,
1158
- }) {
1159
- const threads = hive.doc.getMap('slack_threads') // task id -> thread_ts, shared + durable
1160
- const seen = new Set()
1161
- const allowSet = new Set(
1162
- (Array.isArray(allow) ? allow : String(allow || '').split(','))
1163
- .map((s) => String(s || '').trim())
1164
- .filter(Boolean),
1165
- )
1166
- const actorCheck = makeSlackActorCheck({
1167
- token,
1168
- teamId,
1169
- api,
1170
- fetchImpl,
1171
- lookupUser,
1172
- ttlMs: userCacheTtlMs,
1173
- now,
1174
- })
1175
- let reportedFatal = false
1176
-
1177
- const handleFatal = (e) => {
1178
- if (typeof onError === 'function') onError(e)
1179
- if (!reportedFatal && isPermanentSlackError(e)) {
1180
- reportedFatal = true
1181
- console.error(`[hive-slack] STOPPED POSTING: ${e.message}`)
1182
- }
1183
- }
1184
-
1185
- const threader = makeSlackThreader({
1186
- token,
1187
- channel,
1188
- threads,
1189
- post,
1190
- minGapMs,
1191
- onError: handleFatal,
1192
- room: room || (hive && hive.room) || '',
1193
- link,
1194
- })
1195
- const { queue } = threader
1196
-
1197
- async function emit(ev) {
1198
- if (queue.isHalted()) return { ok: false, halted: true }
1199
- const text = scrubSlack(render(ev), { room: room || (hive && hive.room) || '', link })
1200
- if (ev.task) {
1201
- // A task's first event opens the thread; everything after lands inside it. The outcome
1202
- // (TO_CHANNEL) is also broadcast, so the channel sees "done" without anyone opening the thread.
1203
- return threader.reply(ev.task, text, { broadcast: TO_CHANNEL.has(ev.kind) })
1204
- }
1205
- // Untasked events (general room chat): open one shared __room__ thread on the first message,
1206
- // and route every subsequent untasked chat inside it so chatter never floods the channel.
1207
- if (!TO_CHANNEL.has(ev.kind)) {
1208
- return threader.reply(ROOM_THREAD_KEY, text)
1209
- }
1210
- return queue.push({ channel, text })
1211
- }
1212
-
1213
- function onChat(msgs, startIdx = null) {
1214
- for (let i = 0; i < msgs.length; i++) {
1215
- const m = msgs[i]
1216
- if (!m || !m.text) continue
1217
- // THE ECHO GUARD. Anything this bridge itself put into the room must never go back out,
1218
- // or a Slack reply becomes a room message becomes a Slack message, forever.
1219
- if (m.by === botName || m.viaSlack) continue
1220
- const key = `${m.by}|${m.at}|${m.text}`
1221
- if (seen.has(key)) continue
1222
- seen.add(key)
1223
- if (typeof startIdx === 'number') {
1224
- const nextCursor = startIdx + i + 1
1225
- if (nextCursor > (Number(threads.get(CURSOR_KEY)) || 0)) threads.set(CURSOR_KEY, nextCursor)
1226
- }
1227
- emit({ kind: 'chat', by: m.by, text: m.text, task: m.task }).catch((e) => handleFatal(e))
1228
- }
1229
- }
1230
-
1231
- /** A human replied in a Slack thread. Put it into the room, addressed, attributed as
1232
- * `slack:<handle>` (never bare member name, preventing impersonation), and marked `viaSlack`
1233
- * so the echo guard above will not send it straight back to Slack. */
1234
- function deliver({ thread_ts, text, user }) {
1235
- let task = null
1236
- for (const [id, ts] of threads.entries()) {
1237
- if (id === ROOM_THREAD_KEY || id === CURSOR_KEY) continue
1238
- if (ts === thread_ts) { task = id; break }
1239
- }
1240
- const addressed = /^@/.test(text.trim()) ? text.trim() : text.trim()
1241
- const by = formatSlackSender(user || 'user')
1242
- hive.doc.getArray('chat').push([{
1243
- by, kind: 'say', at: Date.now() * 1000, text: addressed, task, viaSlack: true,
1244
- }])
1245
- return { ok: true, task, by }
1246
- }
1247
-
1248
- async function replyInThread(thread_ts, text) {
1249
- const clean = scrubSlack(text, { room: room || (hive && hive.room) || '', link })
1250
- try {
1251
- const payload = thread_ts ? { channel, text: clean, thread_ts } : { channel, text: clean }
1252
- await queue.push(payload)
1253
- } catch (e) {
1254
- handleFatal(e)
1255
- }
1256
- }
1257
-
1258
- // A stop or resume is reported only after the room kept it (confirmControl). A refusal says so,
1259
- // and a revert that lands after we confirmed is corrected in the same thread.
1260
- const refusedText = (verb, names) =>
1261
- `:warning: The room refused that ${verb}: *${names.join('*, *')}* ${names.length === 1 ? 'is' : 'are'} still ${verb === 'stop' ? 'running' : 'paused'}. ` +
1262
- 'This bridge may not change agents in this room. A strict or secured room needs the bridge started with a human token (docs/SLACK_SETUP.md).'
1263
- const controlOne = (target, state, by, thread) =>
1264
- confirmControl(hive, target, state, by, {
1265
- settleMs: controlConfirmMs,
1266
- lateMs: controlLateMs,
1267
- onLateRevert: () => {
1268
- replyInThread(thread, `:warning: The room undid *${by}*'s ${state === 'paused' ? 'pause' : 'resume'} of *${target}* after I confirmed it: *${target}* is ${state === 'paused' ? 'running' : 'paused'} again. ` +
1269
- 'This bridge may not change agents in this room (docs/SLACK_SETUP.md).')
1270
- },
1271
- })
1272
- async function controlMany(targets, state, by, thread) {
1273
- const kept = await Promise.all(targets.map((t) => controlOne(t, state, by, thread)))
1274
- return { done: targets.filter((_, i) => kept[i]), refused: targets.filter((_, i) => !kept[i]) }
1275
- }
1276
-
1277
- /** Handle an inbound Socket Mode event (`message` or `agent_session_stopped`).
1278
- * Asymmetric authorization via makeSlackActorCheck:
1279
- * - Guests (`is_restricted` / `is_ultra_restricted`) and Slack Connect users from other workspaces
1280
- * (`team_id !== teamId`) are refused for everything.
1281
- * - STOP (`stop`, `stop all`, `stop <Agent>`, or `agent_session_stopped`) is fail-safe: any full
1282
- * workspace member may stop agents, and if `users.info` fails during an outage/rate-limit,
1283
- * `stop` still goes through ("stopped; I could not confirm who you are").
1284
- * - RESUME and thread replies into the room require `ev.user` to be in `--allow U123,U456`. */
1285
- async function onInboundEvent(ev) {
1286
- if (!ev) return { ok: false, ignored: 'empty' }
1287
- if (notFromAPerson(ev) || (botUserId && ev.user === botUserId)) {
1288
- return { ok: false, ignored: 'bot_or_subtype' }
1289
- }
1290
- if (ev.channel && channel && ev.channel !== channel) {
1291
- return { ok: false, ignored: 'wrong_channel' }
1292
- }
1293
- const replyTs = ev.thread_ts || ev.ts || threads.get(ROOM_THREAD_KEY) || undefined
1294
- const r = ev.type === 'message' && ev.text ? route(ev.text) : null
1295
- const isStopAction =
1296
- ev.type === 'agent_session_stopped' ||
1297
- Boolean(r && (r.intent === 'stop-all' || r.intent === 'stop'))
1298
-
1299
- const actor = await actorCheck.resolve(ev.user)
1300
- if (actor.isBot) return { ok: false, ignored: 'bot_user' }
1301
-
1302
- if (isStopAction) {
1303
- const check = actorCheck.mayAct(actor, 'stop', allowSet)
1304
- if (!check.ok) {
1305
- if (check.reply) await replyInThread(replyTs, check.reply)
1306
- return { ok: false, refused: check.refused || actor.reason }
1307
- }
1308
- const sender = check.by
1309
- const unverifiedStop = Boolean(check.unverified)
1310
- const unverifiedNote = unverifiedStop ? ` (${check.reply})` : ''
1311
-
1312
- if (ev.type === 'agent_session_stopped' || (r && r.intent === 'stop-all')) {
1313
- const { done: who, refused } = await controlMany(aiTargets(hive, botName), 'paused', sender, replyTs)
1314
- const parts = []
1315
- if (who.length) parts.push(`:octagonal_sign: *${sender}* paused *${who.join('*, *')}*. Nothing new will be picked up.${unverifiedNote}`)
1316
- if (refused.length) parts.push(refusedText('stop', refused))
1317
- await replyInThread(replyTs, parts.length ? parts.join('\n') : 'No agents to pause.')
1318
- return { ok: refused.length === 0, action: 'stop-all', stopped: who, refusedBy: refused.length ? 'room' : undefined, notStopped: refused, by: sender, unverified: unverifiedStop && who.length > 0 }
1319
- }
1320
- const real = resolveName(hive, r.who)
1321
- if (!real || real === botName) {
1322
- const here = (hive.members ? hive.members() : []).filter((p) => p.name !== botName).map((p) => p.name).join(', ') || 'nobody'
1323
- await replyInThread(replyTs, `:no_entry: I do not see *${r.who}* in this room. Here right now: ${here}.`)
1324
- return { ok: false, refused: 'unknown_agent' }
1325
- }
1326
- if (!(await controlOne(real, 'paused', sender, replyTs))) {
1327
- await replyInThread(replyTs, refusedText('stop', [real]))
1328
- return { ok: false, refused: 'room_refused_control', who: real, by: sender }
1329
- }
1330
- await replyInThread(replyTs, `:octagonal_sign: *${sender}* paused *${real}*.${unverifiedNote}`)
1331
- return { ok: true, action: 'stop', who: real, by: sender, unverified: unverifiedStop }
1332
- }
1333
-
1334
- if (!actor.ok) {
1335
- const check = actorCheck.mayAct(actor, 'reply', allowSet)
1336
- if (check.reply) await replyInThread(replyTs, check.reply)
1337
- return { ok: false, refused: check.refused || actor.reason }
1338
- }
1339
-
1340
- if (ev.type !== 'message' || !ev.text || !r) return { ok: false, ignored: 'unsupported_event' }
1341
-
1342
- // RESUME requires --allow <U123,U456>.
1343
- if (r.intent === 'resume-all' || r.intent === 'resume') {
1344
- const check = actorCheck.mayAct(actor, 'resume', allowSet)
1345
- if (!check.ok) {
1346
- if (check.reply) await replyInThread(replyTs, check.reply)
1347
- return { ok: false, refused: check.refused || 'not_in_allow' }
1348
- }
1349
- const sender = check.by
1350
- if (r.intent === 'resume-all') {
1351
- const { done: who, refused } = await controlMany(aiTargets(hive, botName), 'running', sender, replyTs)
1352
- const parts = []
1353
- if (who.length) parts.push(`:arrow_forward: *${sender}* resumed *${who.join('*, *')}*.`)
1354
- if (refused.length) parts.push(refusedText('resume', refused))
1355
- await replyInThread(replyTs, parts.length ? parts.join('\n') : 'No agents to resume.')
1356
- return { ok: refused.length === 0, action: 'resume-all', resumed: who, notResumed: refused, by: sender }
1357
- }
1358
- const real = resolveName(hive, r.who)
1359
- if (!real || real === botName) {
1360
- const here = (hive.members ? hive.members() : []).filter((p) => p.name !== botName).map((p) => p.name).join(', ') || 'nobody'
1361
- await replyInThread(replyTs, `:no_entry: I do not see *${r.who}* in this room. Here right now: ${here}.`)
1362
- return { ok: false, refused: 'unknown_agent' }
1363
- }
1364
- if (!(await controlOne(real, 'running', sender, replyTs))) {
1365
- await replyInThread(replyTs, refusedText('resume', [real]))
1366
- return { ok: false, refused: 'room_refused_control', who: real, by: sender }
1367
- }
1368
- await replyInThread(replyTs, `:arrow_forward: *${sender}* resumed *${real}*.`)
1369
- return { ok: true, action: 'resume', who: real, by: sender }
1370
- }
1371
-
1372
- // Thread replies into the room require a tracked thread_ts AND --allow <U123,U456>.
1373
- if (!ev.thread_ts) return { ok: false, ignored: 'not_in_thread' }
1374
- let knownThread = ev.thread_ts === threads.get(ROOM_THREAD_KEY)
1375
- if (!knownThread) {
1376
- for (const [id, ts] of threads.entries()) {
1377
- if (id !== CURSOR_KEY && ts === ev.thread_ts) { knownThread = true; break }
1378
- }
1379
- }
1380
- if (!knownThread) return { ok: false, ignored: 'untracked_thread' }
1381
- const check = actorCheck.mayAct(actor, 'reply', allowSet)
1382
- if (!check.ok) {
1383
- if (check.reply) await replyInThread(ev.thread_ts, check.reply)
1384
- return { ok: false, refused: check.refused || 'not_in_allow' }
1385
- }
1386
- const d = deliver({ thread_ts: ev.thread_ts, text: ev.text, user: check.by })
1387
- return { ok: true, action: 'reply', task: d.task, by: d.by }
1388
- }
1389
-
1390
- // ---- the room's own events: tasks and gates ------------------------------------------------
1391
- // `slack start` used to watch the chat and nothing else, so task-open / task-done / gate-pass /
1392
- // gate-fail were rendered by render() and never sent by anything. What was announced is kept in
1393
- // ROOM STATE (slack_seen), so a restarted bridge does not say it all again. The first time a
1394
- // bridge ever attaches to a room it only records what is already there: announcing a room's
1395
- // whole history on first start would flood the channel.
1396
- const announced = hive.doc.getMap('slack_seen')
1397
- function unannounced() {
1398
- const baseline = !announced.has('__init__')
1399
- const out = []
1400
- const note = (key, ev) => { if (announced.has(key)) return; announced.set(key, 1); if (!baseline && ev) out.push(ev) }
1401
- for (const t of hive.doc.getMap('tasks').values()) {
1402
- if (!t || !t.id) continue
1403
- note(`task-open|${t.id}`, { kind: 'task-open', task: t.id, to: t.to, by: t.by, text: t.text })
1404
- if (t.status === 'done') note(`task-done|${t.id}`, { kind: 'task-done', task: t.id, by: t.to, text: t.text })
1405
- }
1406
- hive.doc.getMap('gate_results').forEach((v, k) => {
1407
- if (!v) return
1408
- // A forced release already says so in room chat, which the bridge carries over.
1409
- note(`gate|${k}|${v.at}`, v.forced ? null : { kind: v.pass ? 'gate-pass' : 'gate-fail', by: v.by, file: v.region })
1410
- })
1411
- if (baseline) announced.set('__init__', 1)
1412
- return out
1413
- }
1414
-
1415
- /** Announce task and gate changes from now on. Resolves once the current state has been handled. */
1416
- async function watchRoom() {
1417
- const pass = async () => {
1418
- for (const ev of unannounced()) {
1419
- try { await emit(ev) } catch (e) { handleFatal(e) }
1420
- }
1421
- }
1422
- let chain = pass()
1423
- const kick = () => { chain = chain.then(pass) }
1424
- const tasks = hive.doc.getMap('tasks')
1425
- const gates = hive.doc.getMap('gate_results')
1426
- tasks.observe(kick)
1427
- gates.observe(kick)
1428
- await chain
1429
- return { stop: () => { try { tasks.unobserve(kick); gates.unobserve(kick) } catch { } } }
1430
- }
1431
-
1432
- const postChannel = (text) => queue.push({ channel, text })
1433
- // Inside the room's own thread when it has one, so a notice does not flood the channel.
1434
- const postRoomThread = (text) => {
1435
- const ts = threads.get(ROOM_THREAD_KEY)
1436
- queue.push(ts ? { channel, thread_ts: ts, text } : { channel, text })
1437
- }
1438
-
1439
- return { emit, onChat, deliver, onInboundEvent, watchRoom, threads, queue, threader, postChannel, postRoomThread }
1440
- }
1441
-
1442
- // ---- inbound over HTTPS polling ---------------------------------------------------------
1443
- //
1444
- // WHY (7 Nov 2026). Every inbound path (stop/resume, thread replies, `slack check`) used Socket
1445
- // Mode, and Socket Mode is a WebSocket only (docs.slack.dev). Antigravity's 24/7 cloud VMs refuse
1446
- // every WebSocket upgrade ("403 WebSocket upgrades are not allowed"), so a bridge there could post
1447
- // to Slack and never hear it. This reads the same events over plain HTTPS: conversations.history
1448
- // for the channel and conversations.replies for the threads the bridge tracks. Both are Tier 3
1449
- // (50+/min), and Slack's limits are per method. The design and its failure list were argued in the
1450
- // room by antigravity-2, antigravity-4 and Antigravity; tests/slack-poll-inbound-test.mjs is
1451
- // antigravity-2's black-box suite for it.
1452
- // - history comes newest-first and its messages carry no `channel`: collect, restore, sort ascending.
1453
- // - replies always repeats the thread's root as messages[0]: skipped.
1454
- // - every ts is acted on once: a seen set in memory, on disk and in the room (two bridges).
1455
- // Two bridges first CLAIM a ts in the room and wait a beat, so only one delivers it.
1456
- // - a 30 s overlap re-reads late-indexed messages; the seen set makes that free.
1457
- // - 429: wait Retry-After. Nothing is marked seen until it was delivered: late, never lost.
1458
- // - the cursor is Slack server time. It lives in the room AND on disk, keyed (room, channel), not
1459
- // by pid: a relay wipe (every deploy on Render's free plan) loses the room's copy, never
1460
- // Slack's messages, so the bridge resumes from the disk copy.
1461
- // - a bridge that was off for days must not replay days-old commands: anything older than
1462
- // catchUpMs is not acted on, and the bridge says so once.
1463
- // - first attach anchors to Slack's newest message, not to this machine's clock, and never
1464
- // replays what was already in the channel.
1465
- export const INBOUND_DEFAULT_POLL_MS = 3000
1466
-
1467
- /** The HTTP status a WebSocket upgrade was answered with ("Unexpected server response: 403"), or 0. */
1468
- export function refusedUpgradeStatus(why) {
1469
- const m = /Unexpected server response: (\d{3})/.exec(String(why || ''))
1470
- return m ? Number(m[1]) : 0
1471
- }
1472
-
1473
- const safeFilePart = (s) => String(s || '').replace(/[^\w.-]/g, '_')
1474
- /** Where a bridge keeps its Slack cursor across restarts and relay wipes. Keyed by room and channel,
1475
- * never by process id, so a restarted bridge finds what the last one left. */
1476
- export function slackInboundStateFile(dir, room, channel) {
1477
- return path.join(dir || os.tmpdir(), `hive-slack-inbound-${safeFilePart(room)}-${safeFilePart(channel)}.json`)
1478
- }
1479
- const readStateFile = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')) || {} } catch { return {} } }
1480
- const writeStateFile = (f, obj) => {
1481
- try {
1482
- fs.mkdirSync(path.dirname(f), { recursive: true })
1483
- const tmp = `${f}.${process.pid}.tmp`
1484
- fs.writeFileSync(tmp, JSON.stringify(obj))
1485
- fs.renameSync(tmp, f)
1486
- } catch { /* a state file we cannot write must not stop the bridge; the room still has its copy */ }
1487
- }
1488
- const tsNum = (ts) => Number(ts) || 0
1489
-
1490
- /** Polls Slack over HTTPS and hands each new person message to onEvent, in order, once.
1491
- * store: the room's slack_inbound map (shared by every bridge on this room).
1492
- * threads: the bridge's slack_threads map (task id or __room__ -> thread ts).
1493
- * isSettled(taskId): true when that task is done, so its thread is not polled. */
1494
- export function makeSlackPoller({
1495
- token = '',
1496
- channel = '',
1497
- api = process.env.HIVE_SLACK_API || DEFAULT_API,
1498
- fetchImpl = fetch,
1499
- store = null,
1500
- threads = null,
1501
- isSettled = () => false,
1502
- diskFile = '',
1503
- me = 'SlackBridge',
1504
- onEvent = async () => {},
1505
- onSkipped = null,
1506
- logErr = console.error,
1507
- pollMs = INBOUND_DEFAULT_POLL_MS,
1508
- overlapSec = 30,
1509
- catchUpMs = 30 * 60 * 1000,
1510
- claimSettleMs = null,
1511
- takeOverMs = 10000,
1512
- threadRecheckMs = 30000,
1513
- now = () => Date.now(),
1514
- timer = setTimeout,
1515
- } = {}) {
1516
- const call = (method, body) => slackPost(method, body, token, fetchImpl, api)
1517
- const settleMs = claimSettleMs != null ? claimSettleMs : Math.min(250, Math.max(20, Math.floor(pollMs / 2)))
1518
- const disk = diskFile ? readStateFile(diskFile) : {}
1519
- const seen = new Set((Array.isArray(disk.seen) ? disk.seen : []).map(String))
1520
- const claimSeenAt = new Map() // ts -> when this process first saw another bridge's claim on it
1521
- const lastPolled = new Map() // thread ts -> when this process last read its replies
1522
- const get = (k) => (store ? store.get(k) : undefined)
1523
- const set = (k, v) => { if (store) store.set(k, v) }
1524
- // The room's copy wins while it exists; the disk copy carries the bridge across a relay wipe.
1525
- let cursor = String(get('cursor') || disk.cursorTs || '')
1526
- let floor = String(get('floor') || disk.floorTs || '')
1527
- const threadSeenDisk = { ...(disk.threadSeen || {}) }
1528
- const threadSeen = (ts) => String(get(`t:${ts}`) || threadSeenDisk[ts] || '')
1529
- let agedTurn = 0
1530
- let started = false
1531
- let stopped = false
1532
- let loopTimer = null
1533
- let failures = 0
1534
- let ticks = 0
1535
-
1536
- // A relay wipe empties slack_threads; put back the threads the last bridge knew (never the
1537
- // outbound chat cursor, which counts room chat, not Slack).
1538
- if (threads && disk.threads && typeof disk.threads === 'object') {
1539
- for (const [k, v] of Object.entries(disk.threads)) {
1540
- if (k !== CURSOR_KEY && v && !threads.get(k)) threads.set(k, String(v))
1541
- }
1542
- }
1543
-
1544
- function tracked() {
1545
- const out = []
1546
- if (!threads) return out
1547
- for (const [key, ts] of threads.entries()) {
1548
- if (key === CURSOR_KEY || !ts) continue
1549
- if (key !== ROOM_THREAD_KEY && isSettled(key)) continue
1550
- out.push({ key, ts: String(ts) })
1551
- }
1552
- return out
1553
- }
1554
-
1555
- function persist() {
1556
- // Only real changes go into the room: every set is an update every peer receives.
1557
- if (cursor && get('cursor') !== cursor) set('cursor', cursor)
1558
- if (floor && get('floor') !== floor) set('floor', floor)
1559
- const keepFrom = tsNum(cursor) - overlapSec * 4
1560
- if (store && typeof store.keys === 'function') {
1561
- for (const k of [...store.keys()]) {
1562
- if ((k.startsWith('s:') || k.startsWith('c:')) && tsNum(k.slice(2)) < keepFrom) store.delete(k)
1563
- }
1564
- }
1565
- if (!diskFile) return
1566
- const th = {}
1567
- if (threads) for (const [k, v] of threads.entries()) if (k !== CURSOR_KEY && v) th[k] = String(v)
1568
- writeStateFile(diskFile, {
1569
- cursorTs: cursor,
1570
- floorTs: floor,
1571
- threads: th,
1572
- threadSeen: threadSeenDisk,
1573
- seen: [...seen].filter((ts) => tsNum(ts) >= keepFrom).slice(-2000),
1574
- updatedAt: new Date(now()).toISOString(),
1575
- })
1576
- }
1577
-
1578
- async function tick() {
1579
- ticks++
1580
- const nowSec = now() / 1000
1581
- // First attach: anchor to Slack's own newest message, never to this machine's clock, and act
1582
- // on nothing that was already there.
1583
- // The anchor is the newest thing Slack holds that we could be asked about: a top-level ts, a
1584
- // root's latest_reply, or (for a tracked thread whose root is off the first page) its newest
1585
- // reply. Anchoring to the newest top-level ts alone replayed a reply posted before the bridge
1586
- // existed (tests/slack-poller-cursor-test.mjs P1, found by Antigravity).
1587
- if (!cursor) {
1588
- let high = ''
1589
- const bump = (ts) => { if (ts && tsNum(ts) > tsNum(high)) high = String(ts) }
1590
- const r = await call('conversations.history', { channel, limit: 100 })
1591
- const onPage = new Set()
1592
- for (const m of r.messages || []) { if (!m) continue; onPage.add(String(m.ts)); bump(m.ts); bump(m.latest_reply) }
1593
- for (const t of tracked()) {
1594
- if (onPage.has(t.ts)) continue
1595
- try {
1596
- let pageCursor = ''
1597
- for (let page = 0; page < 5; page++) {
1598
- const rr = await call('conversations.replies', { channel, ts: t.ts, limit: 200, ...(pageCursor ? { cursor: pageCursor } : {}) })
1599
- for (const m of rr.messages || []) if (m) bump(m.ts)
1600
- pageCursor = (rr.has_more && rr.response_metadata && rr.response_metadata.next_cursor) || ''
1601
- if (!pageCursor) break
1602
- }
1603
- } catch (e) {
1604
- if (e && e.status === 429) throw e
1605
- // A deleted or unreadable thread cannot replay anything; anchor on the rest.
1606
- }
1607
- }
1608
- cursor = high || `${Math.floor(nowSec)}.000000`
1609
- floor = cursor
1610
- persist()
1611
- return { anchored: cursor, delivered: 0, skipped: 0 }
1612
- }
1613
- const before = cursor
1614
- const windowFrom = tsNum(cursor) - overlapSec
1615
-
1616
- // 1. The channel: its newest page, paged back until it reaches the cursor (minus the overlap).
1617
- // The same page tells us which tracked threads have new replies (latest_reply).
1618
- const top = []
1619
- let pageCursor = ''
1620
- for (let page = 0; page < 5; page++) {
1621
- const r = await call('conversations.history', { channel, limit: 100, ...(pageCursor ? { cursor: pageCursor } : {}) })
1622
- const msgs = r.messages || []
1623
- top.push(...msgs)
1624
- const oldestHere = msgs.length ? tsNum(msgs[msgs.length - 1].ts) : 0
1625
- pageCursor = (r.has_more && r.response_metadata && r.response_metadata.next_cursor) || ''
1626
- if (!pageCursor || oldestHere <= windowFrom) break
1627
- }
1628
- const roots = new Map()
1629
- for (const m of top) if (m && m.ts && m.reply_count) roots.set(String(m.ts), String(m.latest_reply || ''))
1630
-
1631
- // 2. Tracked threads. A reply is not in the channel's history unless it was also sent to the
1632
- // channel. A root on the page is read when its latest_reply moved (or every 30 s, for a
1633
- // late-indexed reply); a root that scrolled off the page is read in turn, one per tick.
1634
- const toRead = []
1635
- const aged = []
1636
- for (const t of tracked()) {
1637
- if (roots.has(t.ts)) {
1638
- const latest = roots.get(t.ts)
1639
- if ((latest && tsNum(latest) > tsNum(threadSeen(t.ts))) || now() - (lastPolled.get(t.ts) || 0) > threadRecheckMs) toRead.push(t)
1640
- } else aged.push(t)
1641
- }
1642
- if (aged.length) toRead.push(aged[agedTurn++ % aged.length])
1643
- const replies = []
1644
- const threadHigh = new Map()
1645
- for (const t of toRead) {
1646
- const last = threadSeen(t.ts) || t.ts
1647
- const oldest = Math.max(tsNum(t.ts), tsNum(last) - overlapSec)
1648
- const r = await call('conversations.replies', { channel, ts: t.ts, oldest: String(oldest), inclusive: true, limit: 200 })
1649
- lastPolled.set(t.ts, now())
1650
- let high = last
1651
- for (const m of r.messages || []) {
1652
- if (!m || !m.ts || String(m.ts) === t.ts) continue // Slack repeats the thread's root first
1653
- replies.push({ ...m, thread_ts: m.thread_ts || t.ts })
1654
- if (tsNum(m.ts) > tsNum(high)) high = String(m.ts)
1655
- }
1656
- threadHigh.set(t.ts, high)
1657
- }
1658
-
1659
- // 3. One list, oldest first, each ts once (a reply sent to the channel is in both), channel restored.
1660
- const byTs = new Map()
1661
- for (const m of [...top.filter((x) => x && x.ts && tsNum(x.ts) > windowFrom), ...replies]) {
1662
- const k = String(m.ts)
1663
- if (!byTs.has(k)) byTs.set(k, { ...m, channel })
1664
- }
1665
- const fresh = [...byTs.values()]
1666
- .filter((m) => !seen.has(String(m.ts)) && !get(`s:${m.ts}`) && (!floor || tsNum(m.ts) > tsNum(floor)))
1667
- .sort((a, b) => tsNum(a.ts) - tsNum(b.ts))
1668
- for (const m of [...byTs.values()]) if (get(`s:${m.ts}`)) seen.add(String(m.ts))
1669
-
1670
- // 4. Too old to act on (the bridge was off): not delivered, counted, said once by the caller.
1671
- const tooOld = nowSec - catchUpMs / 1000
1672
- let skipped = 0
1673
- const live = []
1674
- for (const m of fresh) {
1675
- if (tsNum(m.ts) < tooOld) {
1676
- if (!notFromAPerson(m)) skipped++
1677
- seen.add(String(m.ts)); set(`s:${m.ts}`, 1)
1678
- } else live.push(m)
1679
- }
1680
-
1681
- // 5. Two bridges on one room: claim, wait a beat for the other's claim to arrive, deliver
1682
- // only what this bridge won. A claim whose owner never delivered is taken over later.
1683
- if (live.length && store) {
1684
- for (const m of live) if (!get(`c:${m.ts}`)) set(`c:${m.ts}`, me)
1685
- await new Promise((r) => timer(r, settleMs))
1686
- }
1687
- let delivered = 0
1688
- for (const m of live) {
1689
- const k = String(m.ts)
1690
- if (store && get(`s:${k}`)) { seen.add(k); continue }
1691
- const owner = store ? get(`c:${k}`) : me
1692
- if (owner && owner !== me) {
1693
- const first = claimSeenAt.get(k) || now()
1694
- claimSeenAt.set(k, first)
1695
- if (now() - first < takeOverMs) continue // the other bridge has it; look again next tick
1696
- set(`c:${k}`, me)
1697
- }
1698
- try { await onEvent(m) } catch (e) { try { logErr(`[hivecode] a Slack message could not be handled: ${e && e.message}`) } catch { } }
1699
- seen.add(k); set(`s:${k}`, 1); claimSeenAt.delete(k)
1700
- delivered++
1701
- }
1702
-
1703
- // 6. Only now move the marks: the channel cursor and each thread's last-read reply.
1704
- // Slack ts is one clock per channel, so replies move the cursor too (P2: most bridge traffic
1705
- // is thread replies; a cursor that only followed top-level messages stood still).
1706
- for (const m of [...top, ...replies]) if (m && m.ts && tsNum(m.ts) > tsNum(cursor)) cursor = String(m.ts)
1707
- for (const [ts, high] of threadHigh) {
1708
- if (tsNum(high) > tsNum(threadSeen(ts))) { set(`t:${ts}`, high); threadSeenDisk[ts] = high }
1709
- }
1710
- persist()
1711
- if (skipped && onSkipped) {
1712
- try { await onSkipped({ count: skipped, since: before, catchUpMs }) } catch { }
1713
- }
1714
- return { delivered, skipped }
1715
- }
1716
-
1717
- function schedule(delay) {
1718
- if (stopped) return
1719
- loopTimer = timer(async () => {
1720
- let next = pollMs
1721
- try {
1722
- await tick()
1723
- if (failures) { try { logErr('[hivecode] Slack polling recovered.') } catch { } }
1724
- failures = 0
1725
- } catch (e) {
1726
- if (e && (e.status === 429 || e.code === 'ratelimited')) {
1727
- next = Math.max(pollMs, Number(e.retryAfterMs) || 1000) // nothing was marked seen: late, not lost
1728
- } else {
1729
- failures++
1730
- next = Math.min(60000, pollMs * 2 ** Math.min(failures, 5))
1731
- if (failures === 1) {
1732
- const why = e && e.code === 'missing_scope'
1733
- ? `${e.message}. Reinstall your Slack app with slack-app-manifest-inbound.json (docs/SLACK_SETUP.md Section 5)`
1734
- : (e && e.message) || String(e)
1735
- try { logErr(`[hivecode] Slack polling failed (${why}); retrying.`) } catch { }
1736
- }
1737
- }
1738
- }
1739
- schedule(next)
1740
- }, delay)
1741
- if (loopTimer && typeof loopTimer.unref === 'function' && process.env.HIVE_SLACK_POLL_UNREF === '1') loopTimer.unref()
1742
- }
1743
-
1744
- return {
1745
- tick,
1746
- /** Keep polling every pollMs. Two bridges on one room start out of phase (by name). */
1747
- start() {
1748
- if (started) return
1749
- started = true
1750
- let h = 0
1751
- for (const ch of String(me)) h = (h * 31 + ch.charCodeAt(0)) >>> 0
1752
- schedule(pollMs + (h % Math.max(1, pollMs)))
1753
- },
1754
- stop() { stopped = true; if (loopTimer) clearTimeout(loopTimer) },
1755
- state: () => ({ cursor, floor, ticks, seen: seen.size }),
1756
- }
1757
- }
1758
-
1759
- export async function runSlackCheck({
1760
- token = '',
1761
- appToken = '',
1762
- channel = '',
1763
- agent = false,
1764
- timeoutMs = 120000,
1765
- helloTimeoutMs = Number(process.env.HIVE_SLACK_HELLO_TIMEOUT_MS) || 10000,
1766
- startRetryMs = Number(process.env.HIVE_SLACK_START_RETRY_MS) || 60000,
1767
- startRetryDelayMs = 1000,
1768
- api = process.env.HIVE_SLACK_API || DEFAULT_API,
1769
- fetchImpl = fetch,
1770
- WebSocketImpl = WebSocket,
1771
- log = console.log,
1772
- logErr = console.error,
1773
- inbound = 'auto',
1774
- pollMs = INBOUND_DEFAULT_POLL_MS,
1775
- } = {}) {
1776
- if (!token || !String(token).trim()) {
1777
- logErr('Q630 SLACK_BOT_TOKEN is missing. Set SLACK_BOT_TOKEN=xoxb-… in your environment.')
1778
- return 1
1779
- }
1780
- if (!channel || !String(channel).trim()) {
1781
- logErr('Q631 --channel <id> is required (for example: --channel C0123456789).')
1782
- return 2
1783
- }
1784
- if (agent && (!appToken || !String(appToken).trim())) {
1785
- logErr('Q634 --agent requires SLACK_APP_TOKEN=xapp-… (with connections:write scope) for Socket Mode.')
1786
- return 1
1787
- }
1788
-
1789
- // 1. Token (auth.test)
1790
- let auth
1791
- try {
1792
- auth = await slackPost('auth.test', {}, token, fetchImpl, api)
1793
- } catch (e) {
1794
- if (!e || !e.slackError) {
1795
- logErr(`Q636 could not reach Slack (${formatSlackWhy(e)}). The token was not checked; check this machine's network and try again.`)
1796
- return 1
1797
- }
1798
- logErr(`Q630 SLACK_BOT_TOKEN was rejected by Slack (${formatSlackWhy(e)}). Check the token or reinstall the Slack app.`)
1799
- return 1
1800
- }
1801
- const botUser = auth.user || 'Hivecode'
1802
- const botUserId = auth.user_id || ''
1803
- log(`✓ token: @${botUser} (${botUserId || 'bot'})`)
1804
-
1805
- // 2. Workspace
1806
- if (!auth.team_id) {
1807
- logErr('Q634 Slack auth.test did not return a workspace team_id. Reinstall the Slack app.')
1808
- return 1
1809
- }
1810
- log(`✓ workspace: ${auth.team || auth.team_id} (${auth.team_id})`)
1811
-
1812
- // 3. Channel & membership (conversations.info)
1813
- let info
1814
- try {
1815
- info = await slackPost('conversations.info', { channel }, token, fetchImpl, api)
1816
- } catch (e) {
1817
- if (!e || !e.slackError) {
1818
- logErr(`Q636 could not reach Slack (${formatSlackWhy(e)}). The token was not checked; check this machine's network and try again.`)
1819
- return 1
1820
- }
1821
- logErr(`Q631 Slack channel ${channel} could not be accessed (${formatSlackWhy(e)}). Check the channel ID and run /invite @${botUser} in the channel.`)
1822
- return 1
1823
- }
1824
- if (info && info.channel && info.channel.is_member === false) {
1825
- const chName = info.channel.name ? `#${info.channel.name}` : channel
1826
- logErr(`Q631 @${botUser} is not a member of ${chName} (${channel}). In Slack, open ${chName} and run: /invite @${botUser}`)
1827
- return 1
1828
- }
1829
- const channelName = (info && info.channel && info.channel.name) || channel
1830
- const isPrivate = Boolean(
1831
- info && info.channel && (
1832
- info.channel.is_private === true ||
1833
- info.channel.is_group === true ||
1834
- String(channel || '').startsWith('G')
1835
- ),
1836
- )
1837
- log(`✓ channel: #${channelName} (${channel}, member)`)
1838
-
1839
- // 4. Scopes (x-oauth-scopes)
1840
- const scopes = Array.isArray(auth && auth._scopes)
1841
- ? auth._scopes
1842
- : (Array.isArray(info && info._scopes) ? info._scopes : null)
1843
- if (scopes === null) {
1844
- log('- scopes: x-oauth-scopes header not returned by server (not verified)')
1845
- } else if (agent) {
1846
- const agCheck = checkAgentScopes({ scopes, isPrivate, channel })
1847
- if (!agCheck.ok) {
1848
- logErr(agCheck.error)
1849
- return 1
1850
- }
1851
- log(`✓ scopes (outbound + inbound + agent): ${scopes.join(', ')}`)
1852
- } else {
1853
- const have = new Set(scopes)
1854
- const readScope = isPrivate ? 'groups:read' : 'channels:read'
1855
- const outMissing = []
1856
- if (!have.has('chat:write')) outMissing.push('chat:write')
1857
- if (!have.has(readScope)) outMissing.push(readScope)
1858
- if (outMissing.length) {
1859
- logErr(`Q630 SLACK_BOT_TOKEN is missing required outbound scope(s): ${outMissing.join(', ')}. Reinstall your Slack app from slack-app-manifest.json.`)
1860
- return 1
1861
- }
1862
- if (appToken || inbound === 'poll') {
1863
- const inCheck = checkInboundScopes({ scopes, isPrivate, channel })
1864
- if (!inCheck.ok) {
1865
- logErr(inCheck.error)
1866
- return 1
1867
- }
1868
- log(`✓ scopes (outbound + inbound): ${scopes.join(', ')}`)
1869
- } else {
1870
- log(`✓ scopes (outbound): ${scopes.join(', ')} (SLACK_APP_TOKEN not set; inbound reply check skipped)`)
1871
- }
1872
- }
1873
-
1874
- // Outbound-only mode (when SLACK_APP_TOKEN is not set): verify outbound post + thread reply, never claim inbound replies work
1875
- if ((!appToken || !String(appToken).trim()) && inbound !== 'poll') {
1876
- log(`This posts one message in #${channelName} (and one reply in its thread)`)
1877
- try {
1878
- const rootPost = await slackPost('chat.postMessage', { channel, text: 'Hivecode check: outbound posting works.' }, token, fetchImpl, api)
1879
- if (rootPost && rootPost.ts) {
1880
- 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)
1881
- }
1882
- } catch (e) {
1883
- logErr(`Q630 Slack chat.postMessage failed (${formatSlackWhy(e)}).`)
1884
- return 1
1885
- }
1886
- log(`✓ outbound post: delivered to #${channelName} (set SLACK_APP_TOKEN to verify inbound replies)`)
1887
- return 0
1888
- }
1889
-
1890
- // 5. Socket Mode connect (must receive Slack's `hello` frame within helloTimeoutMs BEFORE we post the prompt)
1891
- // --inbound poll, or a network that refuses Socket Mode's WebSocket: the reply is read from the
1892
- // check thread over HTTPS instead (conversations.replies). The agent surface needs Socket Mode.
1893
- let pollInbound = inbound === 'poll'
1894
- if (pollInbound && agent) {
1895
- logErr('Q634 --agent needs Socket Mode (the native stop button is a Socket Mode event); --inbound poll cannot check it.')
1896
- return 1
1897
- }
1898
- const sm = pollInbound ? { ok: true, url: '' } : await preflightSocketMode({ appToken, scopes: agent ? null : scopes, isPrivate, channel, api, fetchImpl })
1899
- if (!sm.ok) {
1900
- logErr(sm.error)
1901
- return 1
1902
- }
1903
-
1904
- let sock = null
1905
- const closeSock = () => {
1906
- if (!sock) return
1907
- try { sock.removeAllListeners && sock.removeAllListeners() } catch { }
1908
- try { sock.close() } catch { }
1909
- try { sock.terminate && sock.terminate() } catch { }
1910
- sock = null
1911
- }
1912
-
1913
- let checkTs = ''
1914
- let replyResolver = null
1915
- const replyPromise = new Promise((resolve) => { replyResolver = resolve })
1916
-
1917
- let currentUrl = sm.url
1918
- const startTime = Date.now()
1919
-
1920
- while (!pollInbound) {
1921
- if (!currentUrl) {
1922
- try {
1923
- const res = await slackPost('apps.connections.open', {}, appToken, fetchImpl, api)
1924
- currentUrl = res && res.url
1925
- } catch (e) {
1926
- closeSock()
1927
- if (!e || !e.slackError) {
1928
- logErr(`Q636 could not reach Slack (${formatSlackWhy(e)}). The token was not checked; check this machine's network and try again.`)
1929
- return 1
1930
- }
1931
- logErr(`Q634 Slack apps.connections.open failed (${formatSlackWhy(e)}).`)
1932
- return 1
1933
- }
1934
- }
1935
- if (!currentUrl) {
1936
- closeSock()
1937
- logErr('Q634 Slack apps.connections.open did not return a WebSocket URL.')
1938
- return 1
1939
- }
1940
-
1941
- const res = await new Promise((resolve) => {
1942
- let settled = false
1943
- const finish = (val) => {
1944
- if (settled) return
1945
- settled = true
1946
- clearTimeout(hTimer)
1947
- resolve(val)
1948
- }
1949
- const hTimer = setTimeout(() => finish({ timedOut: true }), helloTimeoutMs)
1950
- try {
1951
- sock = new WebSocketImpl(currentUrl)
1952
- } catch (e) {
1953
- finish({ error: e })
1954
- return
1955
- }
1956
- sock.on('error', (e) => finish({ error: e }))
1957
- sock.on('close', () => {
1958
- finish({ closed: true })
1959
- if (replyResolver) replyResolver(false)
1960
- })
1961
- sock.on('message', (raw) => {
1962
- let frame
1963
- try { frame = JSON.parse(String(raw)) } catch { return }
1964
- if (frame && frame.envelope_id) {
1965
- try { sock && sock.send(JSON.stringify({ envelope_id: frame.envelope_id })) } catch { }
1966
- }
1967
- if (!frame) return
1968
- if (frame.type === 'hello') {
1969
- finish({ ok: true, frame })
1970
- return
1971
- }
1972
- const ev = frame.payload && frame.payload.event
1973
- if (
1974
- ev &&
1975
- (ev.type === 'message' || (agent && ev.type === 'agent_session_stopped')) &&
1976
- !notFromAPerson(ev) &&
1977
- ev.user &&
1978
- ev.user !== botUserId &&
1979
- (!ev.channel || ev.channel === channel) &&
1980
- checkTs &&
1981
- (ev.thread_ts || ev.ts) === checkTs
1982
- ) {
1983
- if (replyResolver) replyResolver(true)
1984
- }
1985
- })
1986
- })
1987
-
1988
- const refused = res.error ? refusedUpgradeStatus(res.error.message) : 0
1989
- if (refused && inbound === 'auto' && !agent) {
1990
- closeSock()
1991
- pollInbound = true
1992
- logErr(`[hivecode] Slack replies are read over HTTPS every ${pollMs >= 1000 ? `${Math.round(pollMs / 100) / 10} s` : `${pollMs} ms`} (this network refused a WebSocket to Slack, HTTP ${refused}).`)
1993
- break
1994
- }
1995
- if (res.timedOut || res.closed || res.error) {
1996
- closeSock()
1997
- logErr(refused
1998
- ? `Q634 this network refused Socket Mode's WebSocket (HTTP ${refused}). Run slack check without --agent to read replies over HTTPS instead.`
1999
- : 'Q634 Socket Mode did not open (no hello frame received within timeout). Check SLACK_APP_TOKEN (connections:write) and network access to Slack.')
2000
- return 1
2001
- }
2002
-
2003
- const num = Number(res.frame && res.frame.num_connections) || 1
2004
- if (num <= 1) {
2005
- break
2006
- }
2007
-
2008
- const elapsed = Date.now() - startTime
2009
- if (elapsed < startRetryMs) {
2010
- closeSock()
2011
- const waitTime = Math.min(startRetryDelayMs, Math.max(50, startRetryMs - elapsed))
2012
- await new Promise((r) => setTimeout(r, waitTime))
2013
- currentUrl = ''
2014
- continue
2015
- }
2016
-
2017
- closeSock()
2018
- 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.`)
2019
- return 1
2020
- }
2021
-
2022
- log(pollInbound ? '✓ inbound: replies are read over HTTPS (conversations.replies)' : '✓ Socket Mode: connected')
2023
- // Reading the check thread over HTTPS: any person's reply, not the bot's own posts.
2024
- const pollForReply = async () => {
2025
- const until = Date.now() + timeoutMs
2026
- while (Date.now() < until) {
2027
- let wait = pollMs
2028
- try {
2029
- const r = await slackPost('conversations.replies', { channel, ts: checkTs, limit: 100 }, token, fetchImpl, api)
2030
- if ((r.messages || []).some((m) => m && String(m.ts) !== checkTs && !notFromAPerson(m) && m.user && m.user !== botUserId)) return true
2031
- } catch (e) {
2032
- if (e && e.code === 'missing_scope') {
2033
- const sc = checkInboundScopes({ scopes: [], isPrivate, channel })
2034
- logErr(sc.ok ? `Q634 ${e.message}` : sc.error)
2035
- return false
2036
- }
2037
- if (e && e.retryAfterMs) wait = Math.max(pollMs, e.retryAfterMs)
2038
- }
2039
- await new Promise((r) => setTimeout(r, wait))
2040
- }
2041
- return false
2042
- }
2043
-
2044
- // 6. Real proof: post prompt and wait up to timeoutMs for any non-bot reply in that thread
2045
- log(`This posts one message in #${channelName} (and one reply in its thread)`)
2046
- try {
2047
- const postRes = await slackPost(
2048
- 'chat.postMessage',
2049
- { channel, text: 'Hivecode check: reply anything in this thread' },
2050
- token,
2051
- fetchImpl,
2052
- api,
2053
- )
2054
- checkTs = (postRes && postRes.ts) || ''
2055
- } catch (e) {
2056
- closeSock()
2057
- logErr(`Q630 Slack chat.postMessage failed (${formatSlackWhy(e)}).`)
2058
- return 1
2059
- }
2060
-
2061
- let waitTimer = null
2062
- const onSigInt = () => {
2063
- if (replyResolver) replyResolver(false)
2064
- }
2065
- if (agent) {
2066
- process.once('SIGINT', onSigInt)
2067
- }
2068
-
2069
- try {
2070
- if (agent) {
2071
- let statusRes
2072
- try {
2073
- statusRes = await slackPost(
2074
- 'agents.sessions.setStatus',
2075
- { status: 'processing', channel_id: channel, thread_ts: checkTs },
2076
- token,
2077
- fetchImpl,
2078
- api,
2079
- )
2080
- } catch (e) {
2081
- const code = extractSlackError(e)
2082
- if (code === 'feature_disabled') {
2083
- 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).')
2084
- } else if (code === 'thread_ts_required') {
2085
- logErr('Q634 Slack agents.sessions.setStatus failed (thread_ts_required): Slack requires thread_ts for thread-based agent sessions in DMs.')
2086
- } else {
2087
- logErr(`Q634 Slack agents.sessions.setStatus failed (${formatSlackWhy(e)}). Reinstall from slack-app-manifest-agent.json.`)
2088
- }
2089
- return 1
2090
- }
2091
- const warnings = extractSlackWarnings(statusRes)
2092
- if (warnings.includes('missing_agent_session_stopped_event_subscription')) {
2093
- 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.')
2094
- return 1
2095
- }
2096
- log('✓ agent session: native stop button subscribed (agents.sessions.setStatus)')
2097
- }
2098
-
2099
- const arrived = await Promise.race([
2100
- pollInbound ? pollForReply() : replyPromise,
2101
- new Promise((resolve) => { waitTimer = setTimeout(() => resolve(false), timeoutMs) }),
2102
- ])
2103
- clearTimeout(waitTimer)
2104
-
2105
- if (!arrived) {
2106
- const manifestName = agent ? 'slack-app-manifest-agent.json' : 'slack-app-manifest-inbound.json'
2107
- logErr(`Q634 replies did not arrive. Event subscriptions are probably missing: reinstall from ${manifestName}`)
2108
- return 1
2109
- }
2110
-
2111
- await slackPost(
2112
- 'chat.postMessage',
2113
- { channel, thread_ts: checkTs, text: '✓ Hivecode check: replies reach Hivecode.' },
2114
- token,
2115
- fetchImpl,
2116
- api,
2117
- ).catch(() => {})
2118
- log('✓ replies reach Hivecode')
2119
- return 0
2120
- } finally {
2121
- clearTimeout(waitTimer)
2122
- if (agent) {
2123
- process.removeListener('SIGINT', onSigInt)
2124
- if (checkTs) {
2125
- await slackPost(
2126
- 'agents.sessions.setStatus',
2127
- { status: 'active', channel_id: channel, thread_ts: checkTs },
2128
- token,
2129
- fetchImpl,
2130
- api,
2131
- ).catch(() => {})
2132
- }
2133
- }
2134
- closeSock()
2135
- }
2136
- }
2137
-
2138
- export async function main(argv = process.argv.slice(2), { log = console.log, logErr = console.error, fetchImpl = fetch, env = process.env, WebSocketImpl = WebSocket } = {}) {
2139
- let mode = 'start'
2140
- let CHANNEL = env.SLACK_CHANNEL || ''
2141
- let NAME = 'SlackBridge'
2142
- let ALLOW = env.SLACK_ALLOW || ''
2143
- let allowSource = env.SLACK_ALLOW ? 'SLACK_ALLOW' : '--allow'
2144
- let LINK = ''
2145
- let dryRun = false
2146
- let agent = false
2147
- let timeoutMs = Number(env.HIVE_SLACK_CHECK_TIMEOUT_MS) || 120000
2148
- let helloTimeoutMs = Number(env.HIVE_SLACK_HELLO_TIMEOUT_MS) || 10000
2149
- let startRetryMs = Number(env.HIVE_SLACK_START_RETRY_MS) || 60000
2150
- // auto: Socket Mode when SLACK_APP_TOKEN is set, HTTPS polling if the network refuses the
2151
- // WebSocket; poll: HTTPS polling only (needs no SLACK_APP_TOKEN); socket: Socket Mode only.
2152
- let inbound = String(env.HIVE_SLACK_INBOUND || 'auto').toLowerCase()
2153
- for (let i = 0; i < argv.length; i++) {
2154
- const a = argv[i]
2155
- if ((a === 'start' || a === 'check') && !LINK && i === 0) { mode = a; continue }
2156
- if (a === '--inbound') { inbound = String(argv[++i] || '').toLowerCase(); continue }
2157
- if (a === '--help' || a === '-h') { log(USAGE.trim()); return 0 }
2158
- if (a === '--dry-run') { dryRun = true; continue }
2159
- if (a === '--agent') { agent = true; continue }
2160
- if (a === '--channel') { CHANNEL = argv[++i] || ''; continue }
2161
- if (a === '--name') { NAME = argv[++i] || 'SlackBridge'; continue }
2162
- if (a === '--allow') { ALLOW = argv[++i] || ''; allowSource = '--allow'; continue }
2163
- if (a === '--timeout') { timeoutMs = Number(argv[++i]) || timeoutMs; continue }
2164
- if (a === '--hello-timeout') { helloTimeoutMs = Number(argv[++i]) || helloTimeoutMs; continue }
2165
- if (a === '--start-retry') { startRetryMs = Number(argv[++i]) || startRetryMs; continue }
2166
- if (a.startsWith('--')) { logErr(USAGE.trim()); return 2 }
2167
- if (!LINK) LINK = a
2168
- }
2169
- if (!['auto', 'poll', 'socket'].includes(inbound)) {
2170
- logErr(`Q630 --inbound must be auto, poll or socket (got "${inbound}").`)
2171
- return 2
2172
- }
2173
- const TOKEN = env.SLACK_BOT_TOKEN || ''
2174
- const APP_TOKEN = env.SLACK_APP_TOKEN || ''
2175
- const pollMs = Math.max(50, Number(env.HIVE_SLACK_POLL_MS) || INBOUND_DEFAULT_POLL_MS)
2176
- if (mode === 'check') {
2177
- return await runSlackCheck({
2178
- token: TOKEN,
2179
- appToken: APP_TOKEN,
2180
- channel: CHANNEL,
2181
- agent,
2182
- inbound,
2183
- pollMs,
2184
- timeoutMs,
2185
- helloTimeoutMs,
2186
- startRetryMs,
2187
- fetchImpl,
2188
- WebSocketImpl,
2189
- log,
2190
- logErr,
2191
- })
2192
- }
2193
- if (!LINK) {
2194
- logErr(USAGE.trim())
2195
- return 2
2196
- }
2197
- const allowParsed = parseSlackAllow(ALLOW, allowSource)
2198
- if (!allowParsed.ok) {
2199
- logErr(allowParsed.error)
2200
- return 1
2201
- }
2202
- const pre = await preflightSlack({ token: TOKEN, channel: CHANNEL, fetchImpl })
2203
- if (!pre.ok) {
2204
- logErr(pre.error)
2205
- return 1
2206
- }
2207
- let sockPre = null
2208
- if (inbound === 'poll') {
2209
- // Polling reads channel history with the bot token: it needs the inbound scopes, not an app token.
2210
- const sc = checkInboundScopes({ scopes: pre.scopes, isPrivate: pre.isPrivate, channel: CHANNEL })
2211
- if (!sc.ok) {
2212
- logErr(sc.error)
2213
- return 1
2214
- }
2215
- } else if (APP_TOKEN) {
2216
- sockPre = await preflightSocketMode({
2217
- appToken: APP_TOKEN,
2218
- scopes: pre.scopes,
2219
- isPrivate: pre.isPrivate,
2220
- channel: CHANNEL,
2221
- fetchImpl,
2222
- })
2223
- if (!sockPre.ok) {
2224
- logErr(sockPre.error)
2225
- return 1
2226
- }
2227
- }
2228
- const { relay, room, token: linkToken } = parseLink(LINK)
2229
- if (!relay || !room) {
2230
- logErr(`Q630 "${LINK}" is not a valid room link (expected wss://relay|room-…).`)
2231
- return 2
2232
- }
2233
- if (dryRun) {
2234
- const mode = inbound === 'poll' ? 'outbound + inbound HTTPS polling' : sockPre ? 'outbound + inbound Socket Mode' : 'outbound'
2235
- 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'}.`)
2236
- return 0
2237
- }
2238
- const dir = (env.TEMP || process.env.TEMP || '.') + '/hive-slack-' + room + '-' + process.pid
2239
- // NOT kind 'ai'. The bridge presses the stop button for the Slack people it has checked (a
2240
- // full workspace member may stop; resume needs --allow), and the relay's governor reverts any
2241
- // write to `controls` from an AI: "the party being stopped never gets the switch". As 'ai',
2242
- // every "stop" from Slack was undone by the relay while Slack and room chat both said
2243
- // "paused" (found 8 Nov 2026 by tests/slack-poll-inbound-test.mjs S4, the first test to run
2244
- // the bridge against the real server.js). hive-slack-agent.js already joins this way. The relay
2245
- // does not trust a declared 'human' (it records 'unknown', which may control on an open relay);
2246
- // a secured room still needs a maintainer or admin token for this.
2247
- const hive = startSync({ relay, room, dir, name: NAME, kind: 'human', token: linkToken || env.HIVE_TOKEN || '', syncFiles: false, log: () => {} })
2248
- await new Promise((resolve) => {
2249
- let done = false
2250
- const finish = () => { if (!done) { done = true; resolve() } }
2251
- hive.provider.on('sync', (s) => { if (s) finish() })
2252
- setTimeout(finish, 8000)
2253
- })
2254
- const dup = checkDuplicateBridge(hive, NAME)
2255
- if (!dup.ok) {
2256
- logErr(dup.error)
2257
- try { hive.stop() } catch { }
2258
- return 1
2259
- }
2260
- const bridge = makeBridge({
2261
- hive,
2262
- channel: CHANNEL,
2263
- token: TOKEN,
2264
- botName: NAME,
2265
- room,
2266
- link: LINK,
2267
- allow: ALLOW,
2268
- teamId: pre.teamId || '',
2269
- botUserId: pre.botUserId || '',
2270
- fetchImpl,
2271
- post: (m) => slackPost('chat.postMessage', m, TOKEN, fetchImpl),
2272
- // Slack asks for about one message per second per channel; tests may go faster.
2273
- minGapMs: Math.max(0, Number(env.HIVE_SLACK_MIN_GAP_MS) || 1000),
2274
- // How long a stop/resume waits to see whether the room kept it (confirmControl).
2275
- controlConfirmMs: Math.max(0, Number(env.HIVE_SLACK_CONTROL_CONFIRM_MS) || 1500),
2276
- })
2277
- // Inbound over HTTPS: --inbound poll, or a network that refused Socket Mode's WebSocket.
2278
- const startPolling = async (why) => {
2279
- const stateDir = env.HIVE_SLACK_STATE_DIR || env.TEMP || env.TMPDIR || os.tmpdir()
2280
- const tasks = hive.doc.getMap('tasks')
2281
- const poller = makeSlackPoller({
2282
- token: TOKEN,
2283
- channel: CHANNEL,
2284
- fetchImpl,
2285
- store: hive.doc.getMap('slack_inbound'),
2286
- threads: bridge.threads,
2287
- isSettled: (id) => { const t = tasks.get(id); return Boolean(t && t.status === 'done') },
2288
- diskFile: slackInboundStateFile(stateDir, room, CHANNEL),
2289
- me: NAME,
2290
- pollMs,
2291
- catchUpMs: Number(env.HIVE_SLACK_CATCHUP_MS) || 30 * 60 * 1000,
2292
- onEvent: (ev) => bridge.onInboundEvent(ev),
2293
- onSkipped: ({ count, since, catchUpMs }) => {
2294
- const mins = Math.round(catchUpMs / 60000)
2295
- const window = mins >= 1 ? `${mins} min` : `${Math.round(catchUpMs / 1000)} s`
2296
- const from = new Date(tsNum(since) * 1000).toISOString().slice(0, 16).replace('T', ' ')
2297
- const to = new Date().toISOString().slice(0, 16).replace('T', ' ')
2298
- bridge.postRoomThread(`:hourglass: Hivecode was offline from ${from} to ${to} UTC; ${count} Slack message${count === 1 ? '' : 's'} older than ${window} ${count === 1 ? 'was' : 'were'} not acted on. Send it again if it still matters.`)
2299
- },
2300
- logErr,
2301
- })
2302
- try {
2303
- await poller.tick() // anchors the cursor, or catches up; a missing scope is loud here
2304
- } catch (e) {
2305
- if (e && e.code === 'missing_scope') {
2306
- const sc = checkInboundScopes({ scopes: [], isPrivate: pre.isPrivate, channel: CHANNEL })
2307
- logErr(sc.ok ? `Q634 ${e.message}` : sc.error)
2308
- return null
2309
- }
2310
- logErr(`[hivecode] Slack polling could not start (${(e && e.message) || e}); retrying in the background.`)
2311
- }
2312
- poller.start()
2313
- logErr(`[hivecode] Slack replies are read over HTTPS every ${pollMs >= 1000 ? `${Math.round(pollMs / 100) / 10} s` : `${pollMs} ms`} (${why}). A "stop" from Slack takes up to that long to arrive.`)
2314
- return poller
2315
- }
2316
- if (inbound === 'poll') {
2317
- if (!(await startPolling('--inbound poll'))) { try { hive.stop() } catch { }; return 1 }
2318
- } else if (sockPre) {
2319
- try {
2320
- await connectSocketMode({
2321
- appToken: APP_TOKEN,
2322
- initialUrl: sockPre.url,
2323
- fetchImpl,
2324
- WebSocketImpl,
2325
- logErr,
2326
- helloTimeoutMs,
2327
- startRetryMs,
2328
- failFastOnRefusal: inbound === 'auto',
2329
- onEvent: (ev) => bridge.onInboundEvent(ev),
2330
- onMultiConnection: (n) => {
2331
- bridge.postChannel(
2332
- `: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.`,
2333
- )
2334
- },
2335
- })
2336
- } catch (e) {
2337
- if (e && e.wsRefused && inbound === 'auto') {
2338
- // The bot token reads history as long as the app has the inbound scopes it was installed with.
2339
- if (!(await startPolling(`this network refused a WebSocket to Slack (HTTP ${e.wsRefused})`))) { try { hive.stop() } catch { }; return 1 }
2340
- } else {
2341
- logErr(e.message)
2342
- try { hive.stop() } catch { }
2343
- return 1
2344
- }
2345
- }
2346
- }
2347
- const chat = hive.doc.getArray('chat')
2348
- const savedCursor = Number(bridge.threads.get(CURSOR_KEY)) || 0
2349
- let at = savedCursor > 0 && savedCursor <= chat.length ? savedCursor : chat.length
2350
- chat.observe(() => { const all = chat.toArray(); bridge.onChat(all.slice(at), at); at = all.length })
2351
- await bridge.watchRoom()
2352
- log(`READY: [hive-slack] @${pre.botUser} bridging ${room} -> #${pre.channelName} (${CHANNEL}) — resume: ${allowParsed.ids.length ? allowParsed.ids.join(', ') : 'off'}`)
2353
- return 0
2354
- }
2355
-
2356
- // ---- main -------------------------------------------------------------------------------
2357
- // Run directly (node <file>, or through an npx / node_modules/.bin symlink)? Compared by real path:
2358
- // the old `file:///${argv[1]}` test built "file:////..." on macOS and Linux, never matched, and the
2359
- // process exited 0 having done nothing.
2360
- function __hiveIsEntry() {
2361
- if (!process.argv[1]) return false
2362
- const me = __hiveFileURLToPath(import.meta.url)
2363
- try { return __hiveRealpath(process.argv[1]) === __hiveRealpath(me) } catch { return __hiveResolve(process.argv[1]) === __hiveResolve(me) }
2364
- }
2365
- if (__hiveIsEntry()) {
2366
- main().then((code) => { if (code !== 0) process.exit(code) })
2367
- }
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 fs from 'fs'
29
+ import os from 'os'
30
+ import path from 'path'
31
+ import WebSocket from 'ws'
32
+ import { startSync, parseLink } from './sync.js'
33
+ import { scrub } from './hive-webhook.js'
34
+ import { realpathSync as __hiveRealpath } from 'fs'
35
+ import { resolve as __hiveResolve } from 'path'
36
+ import { fileURLToPath as __hiveFileURLToPath } from 'url'
37
+
38
+ const DEFAULT_API = 'https://slack.com/api'
39
+ const ROOM_THREAD_KEY = '__room__'
40
+ const CURSOR_KEY = '__cursor__'
41
+ const MAX_SEEN_EVENTS = 500
42
+
43
+ export const USAGE = `usage:
44
+ SLACK_BOT_TOKEN=xoxb-… npx -y hivecode-mcp@latest slack start "<relay|room>" --channel <id> [--name SlackBridge] [--allow <U123,U456>] [--inbound auto|poll|socket] [--dry-run] [--start-retry <ms>]
45
+ SLACK_BOT_TOKEN=xoxb-… [SLACK_APP_TOKEN=xapp-…] npx -y hivecode-mcp@latest slack check --channel <id> [--agent] [--inbound auto|poll|socket] [--timeout <ms>] [--start-retry <ms>]
46
+ SLACK_BOT_TOKEN=xoxb-… SLACK_APP_TOKEN=xapp-… npx -y hivecode-mcp@latest slack agent "<relay|room>" --channel <id> [--name <name>] [--allow <U123,U456>]
47
+ 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]
48
+ `
49
+
50
+ // A person's message can carry a subtype: thread_broadcast ("Also send to channel" on a thread
51
+ // reply) and file_share (a reply with a file). Every other subtype is not a person saying something
52
+ // new: edits, deletions, joins, bot messages. Dropping every subtype silently ignored a "stop" sent
53
+ // with "Also send to channel" (found by antigravity-4, 7 Nov 2026). hive-github.js keeps a copy
54
+ // (it loads this module lazily); tests/hive-slack-test.mjs 10a and github-cli-test CK9b pin both.
55
+ export const PERSON_SUBTYPES = new Set(['thread_broadcast', 'file_share'])
56
+ export const notFromAPerson = (ev) => Boolean(!ev || ev.bot_id || (ev.subtype && !PERSON_SUBTYPES.has(ev.subtype)))
57
+
58
+ export const NON_RETRYABLE = new Set([
59
+ 'invalid_auth',
60
+ 'token_revoked',
61
+ 'account_inactive',
62
+ 'not_authed',
63
+ 'not_in_channel',
64
+ 'channel_not_found',
65
+ 'is_archived',
66
+ 'missing_scope',
67
+ 'ekm_access_denied',
68
+ ])
69
+
70
+ function extractSlackError(err) {
71
+ if (!err) return ''
72
+ if (err.slackError) return String(err.slackError)
73
+ if (err.code && typeof err.code === 'string') return err.code
74
+ const m = /slack [^:]+:\s*([a-z0-9_]+)/i.exec(err.message || '')
75
+ return m ? m[1].toLowerCase() : ''
76
+ }
77
+
78
+ function formatSlackWhy(err) {
79
+ if (!err) return 'unknown_error'
80
+ const code = extractSlackError(err) || err.message || 'unknown_error'
81
+ if (code === 'missing_scope' && err.needed) return `missing_scope: needs ${err.needed}`
82
+ return code
83
+ }
84
+
85
+ export function isRateLimitError(err) {
86
+ if (!err) return false
87
+ if (err.status === 429) return true
88
+ const code = extractSlackError(err)
89
+ return code === 'ratelimited'
90
+ }
91
+
92
+ export function isPermanentSlackError(err) {
93
+ const code = extractSlackError(err)
94
+ return NON_RETRYABLE.has(code)
95
+ }
96
+
97
+ /** Scrub secrets/tokens (via hive-webhook.js) AND strip room links / room IDs so a room's
98
+ * capability link never leaks to an external Slack channel. */
99
+ export function scrubSlack(text, { room = '', link = '' } = {}) {
100
+ let s = scrub(String(text || ''))
101
+ if (link) s = s.split(link).join('[room link removed]')
102
+ // Any wss://...|room-... or ws://...|room-... link
103
+ s = s.replace(/\bwss?:\/\/[^\s|"'<>]+\|[^\s"'<>]+/gi, '[room link removed]')
104
+ if (room) s = s.split(room).join('[room id removed]')
105
+ s = s.replace(/\broom-[A-Za-z0-9_-]{6,}\b/g, '[room id removed]')
106
+ return s
107
+ }
108
+
109
+ // Slack allows roughly one message per second per channel and silently reorders or drops when
110
+ // you exceed it. A room can emit a dozen events in a burst, so everything leaves through one
111
+ // paced queue rather than going straight out.
112
+ //
113
+ // 429 / ratelimited is retried in-place at the head of the queue up to maxRetries (5) times,
114
+ // honouring Retry-After. Permanent auth/channel errors (invalid_auth, token_revoked,
115
+ // not_in_channel, channel_not_found) are never retried: reported once via onError, then the
116
+ // queue halts so a dead token or missing invite never hammers Slack in a loop.
117
+ export function makeQueue({
118
+ send,
119
+ minGapMs = 1000,
120
+ maxRetries = 5,
121
+ now = () => Date.now(),
122
+ timer = setTimeout,
123
+ onError = null,
124
+ }) {
125
+ const q = []
126
+ let running = false, last = 0
127
+ let halted = false, haltedErr = null
128
+
129
+ async function pump() {
130
+ if (running) return
131
+ running = true
132
+ while (q.length) {
133
+ const job = q[0]
134
+ if (halted) {
135
+ q.shift()
136
+ job.reject(haltedErr || new Error('slack queue halted'))
137
+ continue
138
+ }
139
+ const wait = Math.max(0, minGapMs - (now() - last))
140
+ if (wait > 0) await new Promise((r) => timer(r, wait))
141
+ last = now()
142
+ try {
143
+ const res = await send(job.msg)
144
+ q.shift()
145
+ job.resolve(res)
146
+ } catch (e) {
147
+ if (isRateLimitError(e) && job.attempts < maxRetries) {
148
+ job.attempts++
149
+ const retryWait = Number(e.retryAfterMs) > 0 ? Number(e.retryAfterMs) : Math.max(minGapMs, 1000)
150
+ await new Promise((r) => timer(r, retryWait))
151
+ last = now()
152
+ continue
153
+ }
154
+ if (isPermanentSlackError(e)) {
155
+ halted = true
156
+ haltedErr = e
157
+ if (typeof onError === 'function') {
158
+ try { onError(e) } catch { /* reporter must not crash the queue */ }
159
+ }
160
+ } else if (typeof onError === 'function') {
161
+ try { onError(e) } catch { }
162
+ }
163
+ q.shift()
164
+ job.reject(e)
165
+ }
166
+ }
167
+ running = false
168
+ }
169
+
170
+ return {
171
+ push: (msg) => {
172
+ if (halted) return Promise.reject(haltedErr || new Error('slack queue halted'))
173
+ return new Promise((resolve, reject) => { q.push({ msg, attempts: 0, resolve, reject }); pump() })
174
+ },
175
+ depth: () => q.length,
176
+ isHalted: () => halted,
177
+ haltedError: () => haltedErr,
178
+ }
179
+ }
180
+
181
+ export const SLACK_TIMEOUT_MS = 15000
182
+
183
+ /** POST to Slack's Web API. Kept tiny and injectable so the bridge can be tested without a
184
+ * workspace -- the thing worth testing is our routing, not Slack's HTTP.
185
+ * Applies a 15s AbortSignal timeout by default so a hung TCP socket never stalls the queue. */
186
+ export async function slackPost(
187
+ method,
188
+ body,
189
+ token,
190
+ fetchImpl = fetch,
191
+ apiBase = process.env.HIVE_SLACK_API || DEFAULT_API,
192
+ timeoutMs = Number(process.env.HIVE_SLACK_TIMEOUT_MS) || SLACK_TIMEOUT_MS,
193
+ ) {
194
+ const r = await fetchImpl(`${apiBase}/${method}`, {
195
+ method: 'POST',
196
+ headers: { 'Content-Type': 'application/json; charset=utf-8', Authorization: `Bearer ${token}` },
197
+ body: JSON.stringify(body),
198
+ signal: AbortSignal.timeout(timeoutMs),
199
+ })
200
+ const retryHeader = r.headers && typeof r.headers.get === 'function' ? Number(r.headers.get('retry-after') || 0) : 0
201
+ const rawScopes = r.headers && typeof r.headers.get === 'function'
202
+ ? r.headers.get('x-oauth-scopes')
203
+ : (r.headers && (r.headers['x-oauth-scopes'] || r.headers['X-OAuth-Scopes']))
204
+ const j = await r.json().catch(() => ({ ok: false, error: r.status === 429 ? 'ratelimited' : 'bad_json' }))
205
+ if (r.status === 429 || (j && j.error === 'ratelimited')) {
206
+ const retrySec = retryHeader || Number(j && j.retry_after) || 1
207
+ const err = new Error(`slack ${method}: ratelimited`)
208
+ err.status = 429
209
+ err.code = 'ratelimited'
210
+ err.slackError = 'ratelimited'
211
+ err.retryAfterMs = retrySec * 1000
212
+ throw err
213
+ }
214
+ if (!j || !j.ok) {
215
+ const code = (j && j.error) || `http_${r.status || 'unknown'}`
216
+ const needed = j && j.needed ? String(j.needed) : ''
217
+ const provided = j && j.provided ? String(j.provided) : ''
218
+ const detail = code === 'missing_scope' && needed ? `${code} (needs ${needed})` : code
219
+ const err = new Error(`slack ${method}: ${detail}`)
220
+ err.status = r.status
221
+ err.code = code
222
+ err.slackError = code
223
+ if (needed) err.needed = needed
224
+ if (provided) err.provided = provided
225
+ throw err
226
+ }
227
+ if (rawScopes !== undefined && rawScopes !== null && j && typeof j === 'object') {
228
+ const parsedScopes = String(rawScopes).split(',').map((s) => s.trim()).filter(Boolean)
229
+ Object.defineProperty(j, '_scopes', { value: parsedScopes, enumerable: false, configurable: true })
230
+ }
231
+ return j
232
+ }
233
+
234
+ /** Reusable threaded Slack sender for both hive-slack.js and hive-github.js.
235
+ * `threads` is any Map-like store exposing .get(key) and .set(key, ts).
236
+ * NOTE: open(key, text) and reply(key, text, { broadcast }) never throw on Slack API errors;
237
+ * they return { ok: true, ts, ... } or { ok: false, error, code, halted }. Always check .ok
238
+ * on the returned object. */
239
+ export function makeSlackThreader({
240
+ token,
241
+ channel,
242
+ threads = new Map(),
243
+ api = process.env.HIVE_SLACK_API || DEFAULT_API,
244
+ post = null,
245
+ minGapMs = 1000,
246
+ maxRetries = 5,
247
+ now = () => Date.now(),
248
+ timer = setTimeout,
249
+ onError = null,
250
+ room = '',
251
+ link = '',
252
+ }) {
253
+ const sendImpl = post || ((m) => slackPost('chat.postMessage', m, token, fetch, api))
254
+ const queue = makeQueue({ send: sendImpl, minGapMs, maxRetries, now, timer, onError })
255
+ const opening = new Map() // key -> in-flight open Promise, so synchronous bursts share one root thread
256
+
257
+ async function open(key, text) {
258
+ if (queue.isHalted()) return { ok: false, halted: true, error: queue.haltedError()?.message || 'halted' }
259
+ const clean = scrubSlack(text, { room, link })
260
+ if (key && opening.has(key)) {
261
+ try { await opening.get(key) } catch { }
262
+ }
263
+ const existing = key ? threads.get(key) : undefined
264
+ if (existing) return { ok: true, ts: existing, reused: true }
265
+ const run = (async () => {
266
+ const res = await queue.push({ channel, text: clean })
267
+ if (res && res.ts && key) threads.set(key, res.ts)
268
+ return res
269
+ })()
270
+ if (key) opening.set(key, run)
271
+ try {
272
+ const res = await run
273
+ return { ok: true, ts: res && res.ts }
274
+ } catch (e) {
275
+ return { ok: false, error: e.message, code: extractSlackError(e) }
276
+ } finally {
277
+ if (key && opening.get(key) === run) opening.delete(key)
278
+ }
279
+ }
280
+
281
+ async function reply(key, text, { broadcast = false } = {}) {
282
+ if (queue.isHalted()) return { ok: false, halted: true, error: queue.haltedError()?.message || 'halted' }
283
+ const clean = scrubSlack(text, { room, link })
284
+ if (key && opening.has(key)) {
285
+ try { await opening.get(key) } catch { }
286
+ }
287
+ const ts = key ? threads.get(key) : undefined
288
+ if (!ts) return open(key, clean)
289
+ try {
290
+ const payload = { channel, text: clean, thread_ts: ts }
291
+ if (broadcast) payload.reply_broadcast = true
292
+ const res = await queue.push(payload)
293
+ return { ok: true, ts: res && res.ts, thread_ts: ts }
294
+ } catch (e) {
295
+ return { ok: false, error: e.message, code: extractSlackError(e) }
296
+ }
297
+ }
298
+
299
+ return {
300
+ open,
301
+ reply,
302
+ threads,
303
+ queue,
304
+ halted: () => ({ halted: queue.isHalted(), error: queue.haltedError()?.message || '' }),
305
+ }
306
+ }
307
+
308
+ /** Verify token and channel membership BEFORE printing READY (Q630, Q631). */
309
+ export async function preflightSlack({
310
+ token,
311
+ channel,
312
+ api = process.env.HIVE_SLACK_API || DEFAULT_API,
313
+ fetchImpl = fetch,
314
+ } = {}) {
315
+ if (!token || !String(token).trim()) {
316
+ return { ok: false, code: 'Q630', error: 'Q630 SLACK_BOT_TOKEN is missing. Set SLACK_BOT_TOKEN=xoxb-… in your environment.' }
317
+ }
318
+ if (!channel || !String(channel).trim()) {
319
+ return { ok: false, code: 'Q631', error: 'Q631 --channel <id> is required (for example: --channel C0123456789).' }
320
+ }
321
+ let auth
322
+ try {
323
+ auth = await slackPost('auth.test', {}, token, fetchImpl, api)
324
+ } catch (e) {
325
+ if (!e || !e.slackError) {
326
+ const why = formatSlackWhy(e)
327
+ 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.` }
328
+ }
329
+ const why = formatSlackWhy(e)
330
+ return { ok: false, code: 'Q630', error: `Q630 SLACK_BOT_TOKEN was rejected by Slack (${why}). Check the token or reinstall the Slack app.` }
331
+ }
332
+ const botUser = auth.user || 'Hivecode'
333
+ let info
334
+ try {
335
+ info = await slackPost('conversations.info', { channel }, token, fetchImpl, api)
336
+ } catch (e) {
337
+ if (!e || !e.slackError) {
338
+ const why = formatSlackWhy(e)
339
+ 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.` }
340
+ }
341
+ const why = formatSlackWhy(e)
342
+ 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.` }
343
+ }
344
+ if (info && info.channel && info.channel.is_member === false) {
345
+ const chName = info.channel.name ? `#${info.channel.name}` : channel
346
+ return { ok: false, code: 'Q631', error: `Q631 @${botUser} is not a member of ${chName} (${channel}). In Slack, open ${chName} and run: /invite @${botUser}` }
347
+ }
348
+ const scopes = Array.isArray(auth && auth._scopes)
349
+ ? auth._scopes
350
+ : (Array.isArray(info && info._scopes) ? info._scopes : null)
351
+ const isPrivate = Boolean(
352
+ info && info.channel && (
353
+ info.channel.is_private === true ||
354
+ info.channel.is_group === true ||
355
+ String(channel || '').startsWith('G')
356
+ ),
357
+ )
358
+ return {
359
+ ok: true,
360
+ botUser,
361
+ botUserId: auth.user_id || '',
362
+ team: auth.team || '',
363
+ teamId: auth.team_id || '',
364
+ channelName: (info && info.channel && info.channel.name) || channel,
365
+ isPrivate,
366
+ scopes,
367
+ }
368
+ }
369
+
370
+ /** Return the list of missing inbound scopes (users:read, plus channels:history for public or groups:history for private).
371
+ * When `scopes` is null/undefined (header absent), returns [] so "unknown" is never treated as "none". */
372
+ export function missingInboundScopes({ scopes = null, isPrivate = false, channel = '' } = {}) {
373
+ if (scopes === null || scopes === undefined) return []
374
+ const have = new Set(
375
+ (Array.isArray(scopes) ? scopes : String(scopes || '').split(','))
376
+ .map((s) => String(s || '').trim())
377
+ .filter(Boolean),
378
+ )
379
+ const histScope = (isPrivate || String(channel || '').startsWith('G')) ? 'groups:history' : 'channels:history'
380
+ const missing = []
381
+ if (!have.has('users:read')) missing.push('users:read')
382
+ if (!have.has(histScope)) missing.push(histScope)
383
+ return missing
384
+ }
385
+
386
+ /** Verify that SLACK_BOT_TOKEN has the scopes required for inbound replies (users:read + channels:history or groups:history). */
387
+ export function checkInboundScopes({ scopes = null, isPrivate = false, channel = '' } = {}) {
388
+ const missing = missingInboundScopes({ scopes, isPrivate, channel })
389
+ if (missing.length) {
390
+ return {
391
+ ok: false,
392
+ code: 'Q634',
393
+ missingScopes: missing,
394
+ 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).`,
395
+ }
396
+ }
397
+ return { ok: true, missingScopes: [] }
398
+ }
399
+
400
+ /** Return the list of missing Slack Agent surface scopes (chat:write, assistant:write, users:read, plus im:history / channels:history / groups:history).
401
+ * When `scopes` is null/undefined (header absent), returns [] so "unknown" is never treated as "none". */
402
+ export function missingAgentScopes({ scopes = null, isPrivate = false, channel = '' } = {}) {
403
+ if (scopes === null || scopes === undefined) return []
404
+ const have = new Set(
405
+ (Array.isArray(scopes) ? scopes : String(scopes || '').split(','))
406
+ .map((s) => String(s || '').trim())
407
+ .filter(Boolean),
408
+ )
409
+ const ch = String(channel || '').trim()
410
+ const histScope = (!ch || ch.startsWith('D'))
411
+ ? 'im:history'
412
+ : ((isPrivate || ch.startsWith('G')) ? 'groups:history' : 'channels:history')
413
+ const required = ['chat:write', 'assistant:write', histScope, 'users:read']
414
+ return required.filter((s) => !have.has(s))
415
+ }
416
+
417
+ /** Verify that SLACK_BOT_TOKEN has the scopes required for the Slack Agent surface (hive-slack-agent.js). */
418
+ export function checkAgentScopes({ scopes = null, isPrivate = false, channel = '' } = {}) {
419
+ const missing = missingAgentScopes({ scopes, isPrivate, channel })
420
+ if (missing.length) {
421
+ return {
422
+ ok: false,
423
+ code: 'Q634',
424
+ missingScopes: missing,
425
+ 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).`,
426
+ }
427
+ }
428
+ return { ok: true, missingScopes: [] }
429
+ }
430
+
431
+ /** Read Slack warnings from both shapes Slack uses: top-level `warning` string and `response_metadata.warnings[]`. */
432
+ export function extractSlackWarnings(res) {
433
+ if (!res || typeof res !== 'object') return []
434
+ const out = []
435
+ if (typeof res.warning === 'string' && res.warning.trim()) {
436
+ for (const w of res.warning.split(',')) {
437
+ if (w.trim()) out.push(w.trim())
438
+ }
439
+ }
440
+ const metaWarnings = res.response_metadata && Array.isArray(res.response_metadata.warnings)
441
+ ? res.response_metadata.warnings
442
+ : []
443
+ for (const w of metaWarnings) {
444
+ if (typeof w === 'string' && w.trim() && !out.includes(w.trim())) out.push(w.trim())
445
+ }
446
+ return out
447
+ }
448
+
449
+ /** Verify SLACK_APP_TOKEN (xapp-…) with apps.connections.open (and inbound bot scopes when passed) before READY (Q634). */
450
+ export async function preflightSocketMode({
451
+ appToken,
452
+ scopes = null,
453
+ isPrivate = false,
454
+ channel = '',
455
+ api = process.env.HIVE_SLACK_API || DEFAULT_API,
456
+ fetchImpl = fetch,
457
+ } = {}) {
458
+ if (!appToken || !String(appToken).trim()) {
459
+ return {
460
+ ok: false,
461
+ code: 'Q634',
462
+ error: 'Q634 SLACK_APP_TOKEN is missing. Set SLACK_APP_TOKEN=xapp-… (with connections:write scope) to enable inbound Socket Mode.',
463
+ }
464
+ }
465
+ if (scopes !== null && scopes !== undefined) {
466
+ const sc = checkInboundScopes({ scopes, isPrivate, channel })
467
+ if (!sc.ok) return sc
468
+ }
469
+ try {
470
+ const res = await slackPost('apps.connections.open', {}, appToken, fetchImpl, api)
471
+ if (!res || !res.url) {
472
+ return { ok: false, code: 'Q634', error: 'Q634 Slack apps.connections.open did not return a WebSocket URL.' }
473
+ }
474
+ return { ok: true, url: res.url }
475
+ } catch (e) {
476
+ if (!e || !e.slackError) {
477
+ const why = formatSlackWhy(e)
478
+ 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.` }
479
+ }
480
+ const why = extractSlackError(e) || e.message
481
+ return {
482
+ ok: false,
483
+ code: 'Q634',
484
+ error: `Q634 SLACK_APP_TOKEN was rejected by Slack (${why}). Enable Socket Mode and generate an App-Level Token (xapp-…) with connections:write.`,
485
+ }
486
+ }
487
+ }
488
+
489
+ /** Connect to Slack Socket Mode over ws. Immediately acknowledges every envelope_id (<3s),
490
+ * deduplicates event_id across reconnects, reconnects on Slack's periodic disconnect frame,
491
+ * and halts on permanent token errors. */
492
+ export async function connectSocketMode({
493
+ appToken,
494
+ initialUrl = '',
495
+ api = process.env.HIVE_SLACK_API || DEFAULT_API,
496
+ fetchImpl = fetch,
497
+ WebSocketImpl = WebSocket,
498
+ onEvent,
499
+ onError = null,
500
+ onMultiConnection = null,
501
+ logErr = console.error,
502
+ reconnectDelayMs = 1000,
503
+ maxReconnectDelayMs = 60000,
504
+ pingIntervalMs = Number(process.env.HIVE_SLACK_PING_INTERVAL_MS) || 30000,
505
+ probeTimeoutMs = Number(process.env.HIVE_SLACK_PROBE_TIMEOUT_MS) || 5000,
506
+ helloTimeoutMs = Number(process.env.HIVE_SLACK_HELLO_TIMEOUT_MS) || 10000,
507
+ startRetryMs = Number(process.env.HIVE_SLACK_START_RETRY_MS) || 60000,
508
+ startRetryDelayMs = 1000,
509
+ timer = setTimeout,
510
+ // true: an upgrade answered with an HTTP status (a proxy that refuses WebSockets, like the
511
+ // "403 WebSocket upgrades are not allowed" on Antigravity's VMs) throws at once with
512
+ // err.wsRefused = <status>, instead of retrying for startRetryMs, so the caller can poll.
513
+ failFastOnRefusal = false,
514
+ } = {}) {
515
+ let ws = null
516
+ let stopped = false
517
+ let halted = false
518
+ let reconnectTimer = null
519
+ const seenEvents = new Set()
520
+ let lastCloseTime = 0
521
+ let warnedMulti = false
522
+ let reconnectAttempts = 0
523
+ let inOutage = false
524
+
525
+ function getNextReconnectDelay() {
526
+ const delay = Math.min(maxReconnectDelayMs, reconnectDelayMs * Math.pow(2, reconnectAttempts))
527
+ reconnectAttempts++
528
+ return delay
529
+ }
530
+
531
+ function rememberEvent(id) {
532
+ if (!id) return false
533
+ if (seenEvents.has(id)) return true
534
+ seenEvents.add(id)
535
+ if (seenEvents.size > MAX_SEEN_EVENTS) {
536
+ const oldest = seenEvents.values().next().value
537
+ seenEvents.delete(oldest)
538
+ }
539
+ return false
540
+ }
541
+
542
+ function wireSocket(sock) {
543
+ let lastActivity = Date.now()
544
+ let probeTimer = null
545
+ let watchdogTimer = null
546
+ let healthyTimer = null
547
+
548
+ function recordActivity() {
549
+ lastActivity = Date.now()
550
+ if (probeTimer) {
551
+ clearTimeout(probeTimer)
552
+ probeTimer = null
553
+ scheduleWatchdog()
554
+ }
555
+ }
556
+
557
+ function scheduleWatchdog() {
558
+ if (stopped || halted || ws !== sock || probeTimer) return
559
+ if (watchdogTimer) clearTimeout(watchdogTimer)
560
+ const silence = Date.now() - lastActivity
561
+ const delay = Math.max(50, pingIntervalMs - silence)
562
+ watchdogTimer = timer(checkWatchdog, delay)
563
+ }
564
+
565
+ function checkWatchdog() {
566
+ if (stopped || halted || ws !== sock) return
567
+ const silence = Date.now() - lastActivity
568
+ if (silence >= pingIntervalMs) {
569
+ if (!probeTimer) {
570
+ try {
571
+ if (typeof sock.ping === 'function') sock.ping()
572
+ } catch { }
573
+
574
+ probeTimer = timer(() => {
575
+ if (stopped || halted || ws !== sock) return
576
+ if (!inOutage) {
577
+ inOutage = true
578
+ if (typeof logErr === 'function') {
579
+ try {
580
+ logErr(`Slack Socket Mode connection went silent (no ping or message for ${Math.round((Date.now() - lastActivity) / 1000)} s); terminating to reconnect...`)
581
+ } catch { }
582
+ }
583
+ }
584
+ try { sock.terminate ? sock.terminate() : sock.close() } catch { }
585
+ }, probeTimeoutMs)
586
+ }
587
+ return
588
+ }
589
+ scheduleWatchdog()
590
+ }
591
+
592
+ scheduleWatchdog()
593
+
594
+ sock.on('message', (raw) => {
595
+ recordActivity()
596
+ let frame
597
+ try { frame = JSON.parse(String(raw)) } catch { return }
598
+ // Acknowledge every envelope immediately (<3s) so Slack never retries and duplicates a stop/reply.
599
+ if (frame && frame.envelope_id) {
600
+ try { sock.send(JSON.stringify({ envelope_id: frame.envelope_id })) } catch { }
601
+ }
602
+ if (!frame) return
603
+ if (frame.type === 'hello') {
604
+ if (inOutage) {
605
+ inOutage = false
606
+ if (typeof logErr === 'function') {
607
+ try { logErr('Slack Socket Mode reconnected.') } catch { }
608
+ }
609
+ }
610
+ if (healthyTimer) clearTimeout(healthyTimer)
611
+ const healthyDelay = Math.min(pingIntervalMs, reconnectDelayMs * 2)
612
+ healthyTimer = timer(() => {
613
+ if (!stopped && !halted && ws === sock) {
614
+ reconnectAttempts = 0
615
+ }
616
+ }, healthyDelay)
617
+ const num = Number(frame.num_connections) || 1
618
+ const isOwnLingering = (num === 2 && (Date.now() - lastCloseTime < 15000))
619
+ if (num > 1 && !isOwnLingering) {
620
+ if (!warnedMulti) {
621
+ warnedMulti = true
622
+ if (typeof logErr === 'function') {
623
+ try {
624
+ 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").`)
625
+ } catch { }
626
+ }
627
+ if (typeof onMultiConnection === 'function') {
628
+ try { onMultiConnection(num) } catch { }
629
+ }
630
+ }
631
+ } else if (num <= 1) {
632
+ warnedMulti = false
633
+ }
634
+ return
635
+ }
636
+ if (frame.type === 'disconnect') {
637
+ try { sock.close() } catch { }
638
+ return
639
+ }
640
+ const evId = (frame.payload && frame.payload.event_id) || frame.envelope_id || ''
641
+ if (evId && rememberEvent(evId)) return
642
+ const ev = frame.payload && frame.payload.event
643
+ if (ev && typeof onEvent === 'function') {
644
+ Promise.resolve(onEvent(ev, frame.payload)).catch((e) => {
645
+ if (typeof onError === 'function') { try { onError(e) } catch { } }
646
+ })
647
+ }
648
+ })
649
+
650
+ sock.on('ping', () => {
651
+ recordActivity()
652
+ })
653
+
654
+ sock.on('pong', () => {
655
+ recordActivity()
656
+ })
657
+
658
+ sock.on('error', (e) => {
659
+ if (typeof onError === 'function') { try { onError(e) } catch { } }
660
+ })
661
+
662
+ sock.on('close', () => {
663
+ lastCloseTime = Date.now()
664
+ if (probeTimer) { clearTimeout(probeTimer); probeTimer = null }
665
+ if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null }
666
+ if (healthyTimer) { clearTimeout(healthyTimer); healthyTimer = null }
667
+ if (ws === sock) ws = null
668
+ if (!stopped && !halted) {
669
+ const delay = getNextReconnectDelay()
670
+ reconnectTimer = timer(() => openSocket(''), delay)
671
+ }
672
+ })
673
+ }
674
+
675
+ async function openSocket(firstUrl = '') {
676
+ if (stopped || halted) return
677
+ let url = firstUrl
678
+ if (!url) {
679
+ try {
680
+ const res = await slackPost('apps.connections.open', {}, appToken, fetchImpl, api)
681
+ url = res && res.url
682
+ } catch (e) {
683
+ if (isPermanentSlackError(e)) {
684
+ halted = true
685
+ if (typeof onError === 'function') { try { onError(e) } catch { } }
686
+ return
687
+ }
688
+ if (typeof onError === 'function') { try { onError(e) } catch { } }
689
+ if (!stopped) {
690
+ const delay = getNextReconnectDelay()
691
+ reconnectTimer = timer(() => openSocket(''), delay)
692
+ }
693
+ return
694
+ }
695
+ }
696
+ if (!url || stopped || halted) return
697
+ const sock = new WebSocketImpl(url)
698
+ ws = sock
699
+ wireSocket(sock)
700
+ }
701
+
702
+ // Initial connection handshake with startRetryMs retry for transient socket/network errors and num_connections > 1
703
+ const startTime = Date.now()
704
+ let currentUrl = initialUrl
705
+ let lastWhy = ''
706
+ let lastNum = 1
707
+ let warnedRetry = false
708
+
709
+ while (true) {
710
+ if (stopped || halted) return { stop: () => {}, isHalted: () => true }
711
+ if (!currentUrl) {
712
+ try {
713
+ const res = await slackPost('apps.connections.open', {}, appToken, fetchImpl, api)
714
+ currentUrl = res && res.url
715
+ } catch (e) {
716
+ if (isPermanentSlackError(e)) {
717
+ halted = true
718
+ throw e
719
+ }
720
+ lastWhy = extractSlackError(e) || e.message
721
+ const elapsed = Date.now() - startTime
722
+ if (elapsed < startRetryMs) {
723
+ if (!warnedRetry && typeof logErr === 'function') {
724
+ warnedRetry = true
725
+ try { logErr(`Slack Socket Mode could not connect (${lastWhy}); retrying for up to ${Math.round(startRetryMs / 1000)} s...`) } catch { }
726
+ }
727
+ await new Promise((r) => timer(r, Math.min(startRetryDelayMs, startRetryMs - elapsed)))
728
+ continue
729
+ }
730
+ halted = true
731
+ stopped = true
732
+ const err = new Error(`Q634 Socket Mode could not connect: ${lastWhy}`)
733
+ err.code = 'Q634'
734
+ throw err
735
+ }
736
+ }
737
+ if (!currentUrl) {
738
+ const err = new Error('Q634 Slack apps.connections.open did not return a WebSocket URL.')
739
+ err.code = 'Q634'
740
+ throw err
741
+ }
742
+
743
+ let sock = null
744
+ const handshake = await new Promise((resolve) => {
745
+ let settled = false
746
+ const finish = (result) => {
747
+ if (settled) return
748
+ settled = true
749
+ clearTimeout(hTimer)
750
+ resolve(result)
751
+ }
752
+ const hTimer = timer(() => finish({ type: 'timeout', reason: 'no hello frame received within timeout' }), helloTimeoutMs)
753
+ try {
754
+ sock = new WebSocketImpl(currentUrl)
755
+ } catch (e) {
756
+ finish({ type: 'error', error: e, reason: e.message })
757
+ return
758
+ }
759
+ ws = sock
760
+
761
+ const onMsg = (raw) => {
762
+ let frame
763
+ try { frame = JSON.parse(String(raw)) } catch { return }
764
+ if (frame && frame.envelope_id) {
765
+ try { sock.send(JSON.stringify({ envelope_id: frame.envelope_id })) } catch { }
766
+ }
767
+ if (!frame) return
768
+ if (frame.type === 'hello') {
769
+ finish({ type: 'hello', frame, sock, onMsg, onErr, onClose })
770
+ return
771
+ }
772
+ }
773
+
774
+ const onErr = (e) => {
775
+ finish({ type: 'error', error: e, reason: e.message || String(e) })
776
+ }
777
+
778
+ const onClose = () => {
779
+ lastCloseTime = Date.now()
780
+ finish({ type: 'close', reason: 'connection closed before receiving hello frame' })
781
+ }
782
+
783
+ sock.on('message', onMsg)
784
+ sock.on('error', onErr)
785
+ sock.on('close', onClose)
786
+ })
787
+
788
+ if (handshake.type === 'hello') {
789
+ const num = Number(handshake.frame && handshake.frame.num_connections) || 1
790
+ lastNum = num
791
+ if (num <= 1) {
792
+ wireSocket(handshake.sock)
793
+ try {
794
+ handshake.sock.removeListener('message', handshake.onMsg)
795
+ handshake.sock.removeListener('close', handshake.onClose)
796
+ handshake.sock.removeListener('error', handshake.onErr)
797
+ } catch { }
798
+ break
799
+ }
800
+
801
+ lastWhy = `${num} active connections`
802
+ const elapsed = Date.now() - startTime
803
+ if (elapsed < startRetryMs) {
804
+ if (!warnedRetry && typeof logErr === 'function') {
805
+ warnedRetry = true
806
+ try { logErr(`Slack reports ${num} connections on this app token; waiting up to ${Math.round(startRetryMs / 1000)} s for the other to drop...`) } catch { }
807
+ }
808
+ try { handshake.sock.removeAllListeners && handshake.sock.removeAllListeners() } catch { }
809
+ try { handshake.sock.close() } catch { }
810
+ const waitTime = Math.min(startRetryDelayMs, Math.max(50, startRetryMs - elapsed))
811
+ await new Promise((r) => timer(r, waitTime))
812
+ currentUrl = initialUrl || ''
813
+ continue
814
+ }
815
+
816
+ halted = true
817
+ stopped = true
818
+ try { handshake.sock.close() } catch { }
819
+ 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.`)
820
+ err.code = 'Q635'
821
+ throw err
822
+ }
823
+
824
+ // Handshake failed (socket error, timeout, or close-before-hello)
825
+ lastWhy = handshake.reason || (handshake.error && handshake.error.message) || 'could not connect'
826
+ if (sock) {
827
+ try { sock.removeAllListeners && sock.removeAllListeners() } catch { }
828
+ try { sock.close() } catch { }
829
+ try { sock.terminate && sock.terminate() } catch { }
830
+ }
831
+ const refused = refusedUpgradeStatus(lastWhy)
832
+ if (failFastOnRefusal && refused) {
833
+ halted = true
834
+ stopped = true
835
+ const err = new Error(`Q634 Socket Mode refused: this network answered the WebSocket upgrade with HTTP ${refused}`)
836
+ err.code = 'Q634'
837
+ err.wsRefused = refused
838
+ throw err
839
+ }
840
+
841
+ const elapsed = Date.now() - startTime
842
+ if (elapsed < startRetryMs) {
843
+ if (!warnedRetry && typeof logErr === 'function') {
844
+ warnedRetry = true
845
+ try { logErr(`Slack Socket Mode could not connect (${lastWhy}); retrying for up to ${Math.round(startRetryMs / 1000)} s...`) } catch { }
846
+ }
847
+ const waitTime = Math.min(startRetryDelayMs, Math.max(50, startRetryMs - elapsed))
848
+ await new Promise((r) => timer(r, waitTime))
849
+ currentUrl = initialUrl || ''
850
+ continue
851
+ }
852
+
853
+ halted = true
854
+ stopped = true
855
+ const err = new Error(`Q634 Socket Mode could not connect: ${lastWhy}`)
856
+ err.code = 'Q634'
857
+ throw err
858
+ }
859
+
860
+ return {
861
+ stop: () => {
862
+ stopped = true
863
+ if (reconnectTimer) clearTimeout(reconnectTimer)
864
+ if (ws) {
865
+ try { ws.close() } catch { }
866
+ try { ws.terminate && ws.terminate() } catch { }
867
+ ws = null
868
+ }
869
+ },
870
+ isHalted: () => halted,
871
+ }
872
+ }
873
+
874
+ /** Pause every AI in the room (excluding the bridge itself when skipName is given). */
875
+ export function stopAll(hive, by = 'slack', skipName = '') {
876
+ const done = []
877
+ for (const p of (hive.members ? hive.members() : [])) {
878
+ if (p.kind !== 'ai' || (skipName && p.name === skipName)) continue
879
+ hive.control(p.name, 'paused', by)
880
+ done.push(p.name)
881
+ }
882
+ return done
883
+ }
884
+
885
+ export function resumeAll(hive, by = 'slack', skipName = '') {
886
+ const done = []
887
+ for (const p of (hive.members ? hive.members() : [])) {
888
+ if (p.kind !== 'ai' || (skipName && p.name === skipName)) continue
889
+ hive.control(p.name, 'running', by)
890
+ done.push(p.name)
891
+ }
892
+ return done
893
+ }
894
+
895
+ const CONTROL_WATCHES = new WeakMap() // controls Y.Map -> Map(target -> end that watch)
896
+
897
+ /** The AIs a "stop all" / "resume all" acts on. */
898
+ export function aiTargets(hive, skipName = '') {
899
+ return (hive.members ? hive.members() : []).filter((p) => p.kind === 'ai' && !(skipName && p.name === skipName)).map((p) => p.name)
900
+ }
901
+
902
+ /** Write a control, then watch whether the room keeps it. Resolves true (kept) or false (refused).
903
+ *
904
+ * WHY (8 Nov 2026). The relay's governor puts back a control written by anyone who may not
905
+ * control: every AI, and on a strict room anyone without a human token. It does that one round
906
+ * trip later and says nothing. The bridge used to answer Slack "paused" straight after writing,
907
+ * so a refused stop was reported as done and the agents kept running
908
+ * (tests/slack-control-confirm-test.mjs). There is no positive ack from the relay, so:
909
+ * - within settleMs, any change of this agent's control away from the state we asked for is the
910
+ * revert -> false;
911
+ * - after that, until lateMs, the control going back to exactly what it was before our write is
912
+ * a late revert -> onLateRevert() (so Slack can be corrected); any other change is someone
913
+ * else's new decision and ends the watch.
914
+ * A hive with no doc (a test fake) cannot be watched and resolves true. */
915
+ export function confirmControl(hive, target, state, by, { settleMs = 1500, lateMs = 30000, onLateRevert = null } = {}) {
916
+ const controls = hive && hive.doc && typeof hive.doc.getMap === 'function' ? hive.doc.getMap('controls') : null
917
+ const snap = (v) => (v ? JSON.stringify([v.state, v.by, v.at]) : '')
918
+ // A newer write to the same agent through this function ends the older watch: otherwise "stop"
919
+ // then "resume" within one second (same by, same `at`) reads as the room putting the old value back.
920
+ const watching = controls ? (CONTROL_WATCHES.get(controls) || CONTROL_WATCHES.set(controls, new Map()).get(controls)) : null
921
+ if (watching && watching.has(target)) { try { watching.get(target)() } catch { } }
922
+ const before = controls ? snap(controls.get(target)) : ''
923
+ hive.control(target, state, by)
924
+ if (!controls || !(settleMs > 0)) return Promise.resolve(true)
925
+ const ours = snap(controls.get(target))
926
+ if (ours === before) return Promise.resolve(true) // already exactly this; nothing to revert
927
+ return new Promise((resolve) => {
928
+ let settled = false
929
+ let t1 = null, t2 = null
930
+ const cleanup = () => {
931
+ try { controls.unobserve(obs) } catch { }
932
+ if (t1) clearTimeout(t1)
933
+ if (t2) clearTimeout(t2)
934
+ if (watching.get(target) === cleanup) watching.delete(target)
935
+ if (!settled) { settled = true; resolve(true) } // superseded by our own newer write
936
+ }
937
+ watching.set(target, cleanup)
938
+ const obs = (ev) => {
939
+ if (!ev.keysChanged || !ev.keysChanged.has(target)) return
940
+ const cur = controls.get(target)
941
+ const now = snap(cur)
942
+ if (now === ours) return
943
+ if (!settled) {
944
+ if (!cur || cur.state !== state) { settled = true; cleanup(); resolve(false) }
945
+ return
946
+ }
947
+ if (now === before && typeof onLateRevert === 'function') { try { onLateRevert() } catch { } }
948
+ cleanup()
949
+ }
950
+ controls.observe(obs)
951
+ t1 = setTimeout(() => { t1 = null; if (!settled) { settled = true; resolve(true) } }, settleMs)
952
+ t2 = setTimeout(() => { t2 = null; cleanup() }, Math.max(settleMs, lateMs))
953
+ if (t2.unref) t2.unref()
954
+ })
955
+ }
956
+
957
+ /** Find the room's real spelling of a participant name (case-insensitive). */
958
+ export function resolveName(hive, who) {
959
+ const want = String(who || '').toLowerCase()
960
+ for (const p of (hive.members ? hive.members() : [])) {
961
+ if (String(p.name).toLowerCase() === want) return p.name
962
+ }
963
+ return null
964
+ }
965
+
966
+ /** Turn a typed prompt into a deterministic control/status intent. */
967
+ export function route(text) {
968
+ const s = String(text || '').trim().toLowerCase()
969
+ let m
970
+ if ((m = s.match(/^(?:resume|unpause|start|continue)\s+(?:agent\s+)?([a-z0-9_.-]+)$/))) {
971
+ return m[1] === 'all' || m[1] === 'everything' ? { intent: 'resume-all' } : { intent: 'resume', who: m[1] }
972
+ }
973
+ if ((m = s.match(/^(?:stop|pause|halt|kill)\s+(?:agent\s+)?([a-z0-9_.-]+)$/))) {
974
+ return m[1] === 'all' || m[1] === 'everything' ? { intent: 'stop-all' } : { intent: 'stop', who: m[1] }
975
+ }
976
+ if (/^(stop|pause|halt|stop it|stop them|shut it down)\b/.test(s)) return { intent: 'stop-all' }
977
+ if (/^(resume|unpause|carry on|keep going)\b/.test(s)) return { intent: 'resume-all' }
978
+ if (/status|what.*(running|happening|going on)|who.*(here|working)|overnight/.test(s)) return { intent: 'status' }
979
+ return { intent: 'ask', text: String(text || '') }
980
+ }
981
+
982
+ /** Never let a Slack display name impersonate a room member: always attribute as slack:<handle>. */
983
+ export function formatSlackSender(handleOrUser) {
984
+ const raw = String(handleOrUser || 'user').trim().replace(/^slack:/i, '')
985
+ const clean = raw.replace(/[^A-Za-z0-9_.-]/g, '') || 'user'
986
+ return `slack:${clean}`
987
+ }
988
+
989
+ /** Refuse to run a second bridge under the same name in the same room (Q632). */
990
+ export function checkDuplicateBridge(hive, botName = 'SlackBridge') {
991
+ let dupes = []
992
+ try { dupes = hive && typeof hive.sameNameElsewhere === 'function' ? hive.sameNameElsewhere() : [] } catch { dupes = [] }
993
+ if (dupes.length) {
994
+ return {
995
+ ok: false,
996
+ code: 'Q632',
997
+ 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.`,
998
+ }
999
+ }
1000
+ return { ok: true }
1001
+ }
1002
+
1003
+ /** What a room event looks like in Slack. Plain text on purpose: blocks render badly in
1004
+ * notifications and thread previews, which is where most of these are actually read. */
1005
+ export function render(ev) {
1006
+ if (ev.kind === 'task-open') return `:inbox_tray: *${ev.text || ev.task}* — assigned to *${ev.to}*`
1007
+ if (ev.kind === 'task-done') return `:white_check_mark: *${ev.text || ev.task}* — done by *${ev.by}*`
1008
+ 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`
1009
+ if (ev.kind === 'gate-pass') return `:heavy_check_mark: gate passed for *${ev.by}* on \`${ev.file}\``
1010
+ if (ev.kind === 'chat') return `*${ev.by}*: ${ev.text}`
1011
+ return `${ev.kind}: ${ev.text || ''}`
1012
+ }
1013
+
1014
+ /** Which events reach the CHANNEL, and which only ever go inside a thread.
1015
+ * Getting this wrong is the difference between a useful feed and a muted one. */
1016
+ export const TO_CHANNEL = new Set(['task-open', 'task-done'])
1017
+
1018
+ export const USER_CACHE_TTL_MS = 10 * 60 * 1000
1019
+
1020
+ /** Shared Slack actor verification and asymmetric authorization (used by both hive-slack.js
1021
+ * and hive-github.js):
1022
+ * - caches verified users.info results for ttlMs (default 10 minutes); lookup failures are not cached
1023
+ * - refuses bots, deleted users, guests (is_restricted / is_ultra_restricted / is_stranger),
1024
+ * and external Slack Connect users (team_id !== teamId)
1025
+ * - allows 'stop' / 'stop-all' for any full workspace member, AND fails open with unverified: true
1026
+ * ("stopped; I could not confirm who you are") if users.info failed during an outage
1027
+ * - allows 'resume' / 'retry' / 'reply' only when actor.ok === true AND userId is in allow */
1028
+ export function makeSlackActorCheck({
1029
+ token,
1030
+ teamId = '',
1031
+ api = process.env.HIVE_SLACK_API || DEFAULT_API,
1032
+ fetchImpl = fetch,
1033
+ lookupUser = null,
1034
+ ttlMs = USER_CACHE_TTL_MS,
1035
+ now = () => Date.now(),
1036
+ allowFlag = '--allow',
1037
+ } = {}) {
1038
+ const userCache = new Map() // userId -> { value, expiresAt }
1039
+
1040
+ function put(userId, value) {
1041
+ userCache.set(userId, { value, expiresAt: now() + ttlMs })
1042
+ return value
1043
+ }
1044
+
1045
+ async function resolve(userId) {
1046
+ if (!userId) return { ok: false, userId: '', reason: 'missing_user' }
1047
+ const cached = userCache.get(userId)
1048
+ if (cached) {
1049
+ if (now() < cached.expiresAt) return cached.value
1050
+ userCache.delete(userId)
1051
+ }
1052
+ let res
1053
+ try {
1054
+ res = lookupUser
1055
+ ? await lookupUser(userId)
1056
+ : await slackPost('users.info', { user: userId }, token, fetchImpl, api)
1057
+ } catch (e) {
1058
+ return { ok: false, userId, handle: userId, reason: 'lookup_failed', error: e.message }
1059
+ }
1060
+ const u = res && res.user
1061
+ if (!u || u.deleted || u.is_bot) {
1062
+ return put(userId, { ok: false, userId, isBot: Boolean(u && u.is_bot), reason: 'bot_or_deleted' })
1063
+ }
1064
+ const handle = u.name || (u.profile && u.profile.display_name) || userId
1065
+ if (u.is_restricted || u.is_ultra_restricted || u.is_stranger) {
1066
+ return put(userId, { ok: false, userId, handle, reason: 'guest' })
1067
+ }
1068
+ if (teamId && u.team_id && u.team_id !== teamId) {
1069
+ return put(userId, { ok: false, userId, handle, reason: 'external_team' })
1070
+ }
1071
+ return put(userId, { ok: true, userId, handle, teamId: u.team_id || '' })
1072
+ }
1073
+
1074
+ function mayAct(actor, action, allow = []) {
1075
+ if (!actor || actor.isBot) {
1076
+ return { ok: false, ignored: true, unverified: false, reason: 'bot_or_empty', by: '', reply: '' }
1077
+ }
1078
+ const uid = actor.userId || ''
1079
+ const allowSet = allow instanceof Set
1080
+ ? allow
1081
+ : new Set(
1082
+ (Array.isArray(allow) ? allow : String(allow || '').split(','))
1083
+ .map((s) => String(s || '').trim())
1084
+ .filter(Boolean),
1085
+ )
1086
+ const isStop = action === 'stop' || action === 'stop-all'
1087
+ const by = formatSlackSender(actor.handle || uid || 'user')
1088
+
1089
+ if (!actor.ok) {
1090
+ if (actor.reason === 'lookup_failed' && isStop) {
1091
+ return { ok: true, unverified: true, by, reply: 'stopped; I could not confirm who you are' }
1092
+ }
1093
+ const reply = actor.reason === 'lookup_failed'
1094
+ ? `:no_entry: Refused: could not verify your Slack user (${uid || 'unknown'}) via users.info.`
1095
+ : `:no_entry: Refused: guests and external Slack Connect users cannot control or message this room.`
1096
+ return { ok: false, unverified: false, refused: actor.reason, by, reply }
1097
+ }
1098
+
1099
+ if (isStop) {
1100
+ return { ok: true, unverified: false, by, reply: '' }
1101
+ }
1102
+
1103
+ if (!allowSet.has(uid)) {
1104
+ return {
1105
+ ok: false,
1106
+ unverified: false,
1107
+ refused: 'not_in_allow',
1108
+ by,
1109
+ 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\`.`,
1110
+ }
1111
+ }
1112
+
1113
+ return { ok: true, unverified: false, by, reply: '' }
1114
+ }
1115
+
1116
+ return { resolve, mayAct }
1117
+ }
1118
+
1119
+ /** Parse and validate Slack member ids from --allow or SLACK_ALLOW (Q634).
1120
+ * Refuses anything not matching /^[UW][A-Z0-9]{2,}$/ (e.g. usernames or handles). */
1121
+ export function parseSlackAllow(allowRaw, flag = '--allow') {
1122
+ const raw = Array.isArray(allowRaw) ? allowRaw.join(',') : String(allowRaw || '').trim()
1123
+ if (!raw) return { ok: true, ids: [] }
1124
+ const list = raw.split(',').map((s) => s.trim()).filter(Boolean)
1125
+ const notIds = list.filter((s) => !/^[UW][A-Z0-9]{2,}$/.test(s))
1126
+ if (notIds.length) {
1127
+ return {
1128
+ ok: false,
1129
+ code: 'Q634',
1130
+ 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.`,
1131
+ }
1132
+ }
1133
+ return { ok: true, ids: list }
1134
+ }
1135
+
1136
+ export const validateSlackAllow = parseSlackAllow
1137
+
1138
+ export function makeBridge({
1139
+ hive,
1140
+ channel,
1141
+ token,
1142
+ post,
1143
+ botName = 'SlackBridge',
1144
+ onError = null,
1145
+ room = '',
1146
+ link = '',
1147
+ allow = [],
1148
+ teamId = '',
1149
+ botUserId = '',
1150
+ lookupUser = null,
1151
+ minGapMs = 1000,
1152
+ controlConfirmMs = 1500,
1153
+ controlLateMs = 30000,
1154
+ now = () => Date.now(),
1155
+ userCacheTtlMs = USER_CACHE_TTL_MS,
1156
+ api = process.env.HIVE_SLACK_API || DEFAULT_API,
1157
+ fetchImpl = fetch,
1158
+ }) {
1159
+ const threads = hive.doc.getMap('slack_threads') // task id -> thread_ts, shared + durable
1160
+ const seen = new Set()
1161
+ const allowSet = new Set(
1162
+ (Array.isArray(allow) ? allow : String(allow || '').split(','))
1163
+ .map((s) => String(s || '').trim())
1164
+ .filter(Boolean),
1165
+ )
1166
+ const actorCheck = makeSlackActorCheck({
1167
+ token,
1168
+ teamId,
1169
+ api,
1170
+ fetchImpl,
1171
+ lookupUser,
1172
+ ttlMs: userCacheTtlMs,
1173
+ now,
1174
+ })
1175
+ let reportedFatal = false
1176
+
1177
+ const handleFatal = (e) => {
1178
+ if (typeof onError === 'function') onError(e)
1179
+ if (!reportedFatal && isPermanentSlackError(e)) {
1180
+ reportedFatal = true
1181
+ console.error(`[hive-slack] STOPPED POSTING: ${e.message}`)
1182
+ }
1183
+ }
1184
+
1185
+ const threader = makeSlackThreader({
1186
+ token,
1187
+ channel,
1188
+ threads,
1189
+ post,
1190
+ minGapMs,
1191
+ onError: handleFatal,
1192
+ room: room || (hive && hive.room) || '',
1193
+ link,
1194
+ })
1195
+ const { queue } = threader
1196
+
1197
+ async function emit(ev) {
1198
+ if (queue.isHalted()) return { ok: false, halted: true }
1199
+ const text = scrubSlack(render(ev), { room: room || (hive && hive.room) || '', link })
1200
+ if (ev.task) {
1201
+ // A task's first event opens the thread; everything after lands inside it. The outcome
1202
+ // (TO_CHANNEL) is also broadcast, so the channel sees "done" without anyone opening the thread.
1203
+ return threader.reply(ev.task, text, { broadcast: TO_CHANNEL.has(ev.kind) })
1204
+ }
1205
+ // Untasked events (general room chat): open one shared __room__ thread on the first message,
1206
+ // and route every subsequent untasked chat inside it so chatter never floods the channel.
1207
+ if (!TO_CHANNEL.has(ev.kind)) {
1208
+ return threader.reply(ROOM_THREAD_KEY, text)
1209
+ }
1210
+ return queue.push({ channel, text })
1211
+ }
1212
+
1213
+ function onChat(msgs, startIdx = null) {
1214
+ for (let i = 0; i < msgs.length; i++) {
1215
+ const m = msgs[i]
1216
+ if (!m || !m.text) continue
1217
+ // THE ECHO GUARD. Anything this bridge itself put into the room must never go back out,
1218
+ // or a Slack reply becomes a room message becomes a Slack message, forever.
1219
+ if (m.by === botName || m.viaSlack) continue
1220
+ const key = `${m.by}|${m.at}|${m.text}`
1221
+ if (seen.has(key)) continue
1222
+ seen.add(key)
1223
+ if (typeof startIdx === 'number') {
1224
+ const nextCursor = startIdx + i + 1
1225
+ if (nextCursor > (Number(threads.get(CURSOR_KEY)) || 0)) threads.set(CURSOR_KEY, nextCursor)
1226
+ }
1227
+ emit({ kind: 'chat', by: m.by, text: m.text, task: m.task }).catch((e) => handleFatal(e))
1228
+ }
1229
+ }
1230
+
1231
+ /** A human replied in a Slack thread. Put it into the room, addressed, attributed as
1232
+ * `slack:<handle>` (never bare member name, preventing impersonation), and marked `viaSlack`
1233
+ * so the echo guard above will not send it straight back to Slack. */
1234
+ function deliver({ thread_ts, text, user }) {
1235
+ let task = null
1236
+ for (const [id, ts] of threads.entries()) {
1237
+ if (id === ROOM_THREAD_KEY || id === CURSOR_KEY) continue
1238
+ if (ts === thread_ts) { task = id; break }
1239
+ }
1240
+ const addressed = /^@/.test(text.trim()) ? text.trim() : text.trim()
1241
+ const by = formatSlackSender(user || 'user')
1242
+ hive.doc.getArray('chat').push([{
1243
+ by, kind: 'say', at: Date.now() * 1000, text: addressed, task, viaSlack: true,
1244
+ }])
1245
+ return { ok: true, task, by }
1246
+ }
1247
+
1248
+ async function replyInThread(thread_ts, text) {
1249
+ const clean = scrubSlack(text, { room: room || (hive && hive.room) || '', link })
1250
+ try {
1251
+ const payload = thread_ts ? { channel, text: clean, thread_ts } : { channel, text: clean }
1252
+ await queue.push(payload)
1253
+ } catch (e) {
1254
+ handleFatal(e)
1255
+ }
1256
+ }
1257
+
1258
+ // A stop or resume is reported only after the room kept it (confirmControl). A refusal says so,
1259
+ // and a revert that lands after we confirmed is corrected in the same thread.
1260
+ const refusedText = (verb, names) =>
1261
+ `:warning: The room refused that ${verb}: *${names.join('*, *')}* ${names.length === 1 ? 'is' : 'are'} still ${verb === 'stop' ? 'running' : 'paused'}. ` +
1262
+ 'This bridge may not change agents in this room. A strict or secured room needs the bridge started with a human token (docs/SLACK_SETUP.md).'
1263
+ const controlOne = (target, state, by, thread) =>
1264
+ confirmControl(hive, target, state, by, {
1265
+ settleMs: controlConfirmMs,
1266
+ lateMs: controlLateMs,
1267
+ onLateRevert: () => {
1268
+ replyInThread(thread, `:warning: The room undid *${by}*'s ${state === 'paused' ? 'pause' : 'resume'} of *${target}* after I confirmed it: *${target}* is ${state === 'paused' ? 'running' : 'paused'} again. ` +
1269
+ 'This bridge may not change agents in this room (docs/SLACK_SETUP.md).')
1270
+ },
1271
+ })
1272
+ async function controlMany(targets, state, by, thread) {
1273
+ const kept = await Promise.all(targets.map((t) => controlOne(t, state, by, thread)))
1274
+ return { done: targets.filter((_, i) => kept[i]), refused: targets.filter((_, i) => !kept[i]) }
1275
+ }
1276
+
1277
+ /** Handle an inbound Socket Mode event (`message` or `agent_session_stopped`).
1278
+ * Asymmetric authorization via makeSlackActorCheck:
1279
+ * - Guests (`is_restricted` / `is_ultra_restricted`) and Slack Connect users from other workspaces
1280
+ * (`team_id !== teamId`) are refused for everything.
1281
+ * - STOP (`stop`, `stop all`, `stop <Agent>`, or `agent_session_stopped`) is fail-safe: any full
1282
+ * workspace member may stop agents, and if `users.info` fails during an outage/rate-limit,
1283
+ * `stop` still goes through ("stopped; I could not confirm who you are").
1284
+ * - RESUME and thread replies into the room require `ev.user` to be in `--allow U123,U456`. */
1285
+ async function onInboundEvent(ev) {
1286
+ if (!ev) return { ok: false, ignored: 'empty' }
1287
+ if (notFromAPerson(ev) || (botUserId && ev.user === botUserId)) {
1288
+ return { ok: false, ignored: 'bot_or_subtype' }
1289
+ }
1290
+ if (ev.channel && channel && ev.channel !== channel) {
1291
+ return { ok: false, ignored: 'wrong_channel' }
1292
+ }
1293
+ const replyTs = ev.thread_ts || ev.ts || threads.get(ROOM_THREAD_KEY) || undefined
1294
+ const r = ev.type === 'message' && ev.text ? route(ev.text) : null
1295
+ const isStopAction =
1296
+ ev.type === 'agent_session_stopped' ||
1297
+ Boolean(r && (r.intent === 'stop-all' || r.intent === 'stop'))
1298
+
1299
+ const actor = await actorCheck.resolve(ev.user)
1300
+ if (actor.isBot) return { ok: false, ignored: 'bot_user' }
1301
+
1302
+ if (isStopAction) {
1303
+ const check = actorCheck.mayAct(actor, 'stop', allowSet)
1304
+ if (!check.ok) {
1305
+ if (check.reply) await replyInThread(replyTs, check.reply)
1306
+ return { ok: false, refused: check.refused || actor.reason }
1307
+ }
1308
+ const sender = check.by
1309
+ const unverifiedStop = Boolean(check.unverified)
1310
+ const unverifiedNote = unverifiedStop ? ` (${check.reply})` : ''
1311
+
1312
+ if (ev.type === 'agent_session_stopped' || (r && r.intent === 'stop-all')) {
1313
+ const { done: who, refused } = await controlMany(aiTargets(hive, botName), 'paused', sender, replyTs)
1314
+ const parts = []
1315
+ if (who.length) parts.push(`:octagonal_sign: *${sender}* paused *${who.join('*, *')}*. Nothing new will be picked up.${unverifiedNote}`)
1316
+ if (refused.length) parts.push(refusedText('stop', refused))
1317
+ await replyInThread(replyTs, parts.length ? parts.join('\n') : 'No agents to pause.')
1318
+ return { ok: refused.length === 0, action: 'stop-all', stopped: who, refusedBy: refused.length ? 'room' : undefined, notStopped: refused, by: sender, unverified: unverifiedStop && who.length > 0 }
1319
+ }
1320
+ const real = resolveName(hive, r.who)
1321
+ if (!real || real === botName) {
1322
+ const here = (hive.members ? hive.members() : []).filter((p) => p.name !== botName).map((p) => p.name).join(', ') || 'nobody'
1323
+ await replyInThread(replyTs, `:no_entry: I do not see *${r.who}* in this room. Here right now: ${here}.`)
1324
+ return { ok: false, refused: 'unknown_agent' }
1325
+ }
1326
+ if (!(await controlOne(real, 'paused', sender, replyTs))) {
1327
+ await replyInThread(replyTs, refusedText('stop', [real]))
1328
+ return { ok: false, refused: 'room_refused_control', who: real, by: sender }
1329
+ }
1330
+ await replyInThread(replyTs, `:octagonal_sign: *${sender}* paused *${real}*.${unverifiedNote}`)
1331
+ return { ok: true, action: 'stop', who: real, by: sender, unverified: unverifiedStop }
1332
+ }
1333
+
1334
+ if (!actor.ok) {
1335
+ const check = actorCheck.mayAct(actor, 'reply', allowSet)
1336
+ if (check.reply) await replyInThread(replyTs, check.reply)
1337
+ return { ok: false, refused: check.refused || actor.reason }
1338
+ }
1339
+
1340
+ if (ev.type !== 'message' || !ev.text || !r) return { ok: false, ignored: 'unsupported_event' }
1341
+
1342
+ // RESUME requires --allow <U123,U456>.
1343
+ if (r.intent === 'resume-all' || r.intent === 'resume') {
1344
+ const check = actorCheck.mayAct(actor, 'resume', allowSet)
1345
+ if (!check.ok) {
1346
+ if (check.reply) await replyInThread(replyTs, check.reply)
1347
+ return { ok: false, refused: check.refused || 'not_in_allow' }
1348
+ }
1349
+ const sender = check.by
1350
+ if (r.intent === 'resume-all') {
1351
+ const { done: who, refused } = await controlMany(aiTargets(hive, botName), 'running', sender, replyTs)
1352
+ const parts = []
1353
+ if (who.length) parts.push(`:arrow_forward: *${sender}* resumed *${who.join('*, *')}*.`)
1354
+ if (refused.length) parts.push(refusedText('resume', refused))
1355
+ await replyInThread(replyTs, parts.length ? parts.join('\n') : 'No agents to resume.')
1356
+ return { ok: refused.length === 0, action: 'resume-all', resumed: who, notResumed: refused, by: sender }
1357
+ }
1358
+ const real = resolveName(hive, r.who)
1359
+ if (!real || real === botName) {
1360
+ const here = (hive.members ? hive.members() : []).filter((p) => p.name !== botName).map((p) => p.name).join(', ') || 'nobody'
1361
+ await replyInThread(replyTs, `:no_entry: I do not see *${r.who}* in this room. Here right now: ${here}.`)
1362
+ return { ok: false, refused: 'unknown_agent' }
1363
+ }
1364
+ if (!(await controlOne(real, 'running', sender, replyTs))) {
1365
+ await replyInThread(replyTs, refusedText('resume', [real]))
1366
+ return { ok: false, refused: 'room_refused_control', who: real, by: sender }
1367
+ }
1368
+ await replyInThread(replyTs, `:arrow_forward: *${sender}* resumed *${real}*.`)
1369
+ return { ok: true, action: 'resume', who: real, by: sender }
1370
+ }
1371
+
1372
+ // Thread replies into the room require a tracked thread_ts AND --allow <U123,U456>.
1373
+ if (!ev.thread_ts) return { ok: false, ignored: 'not_in_thread' }
1374
+ let knownThread = ev.thread_ts === threads.get(ROOM_THREAD_KEY)
1375
+ if (!knownThread) {
1376
+ for (const [id, ts] of threads.entries()) {
1377
+ if (id !== CURSOR_KEY && ts === ev.thread_ts) { knownThread = true; break }
1378
+ }
1379
+ }
1380
+ if (!knownThread) return { ok: false, ignored: 'untracked_thread' }
1381
+ const check = actorCheck.mayAct(actor, 'reply', allowSet)
1382
+ if (!check.ok) {
1383
+ if (check.reply) await replyInThread(ev.thread_ts, check.reply)
1384
+ return { ok: false, refused: check.refused || 'not_in_allow' }
1385
+ }
1386
+ const d = deliver({ thread_ts: ev.thread_ts, text: ev.text, user: check.by })
1387
+ return { ok: true, action: 'reply', task: d.task, by: d.by }
1388
+ }
1389
+
1390
+ // ---- the room's own events: tasks and gates ------------------------------------------------
1391
+ // `slack start` used to watch the chat and nothing else, so task-open / task-done / gate-pass /
1392
+ // gate-fail were rendered by render() and never sent by anything. What was announced is kept in
1393
+ // ROOM STATE (slack_seen), so a restarted bridge does not say it all again. The first time a
1394
+ // bridge ever attaches to a room it only records what is already there: announcing a room's
1395
+ // whole history on first start would flood the channel.
1396
+ const announced = hive.doc.getMap('slack_seen')
1397
+ function unannounced() {
1398
+ const baseline = !announced.has('__init__')
1399
+ const out = []
1400
+ const note = (key, ev) => { if (announced.has(key)) return; announced.set(key, 1); if (!baseline && ev) out.push(ev) }
1401
+ for (const t of hive.doc.getMap('tasks').values()) {
1402
+ if (!t || !t.id) continue
1403
+ note(`task-open|${t.id}`, { kind: 'task-open', task: t.id, to: t.to, by: t.by, text: t.text })
1404
+ if (t.status === 'done') note(`task-done|${t.id}`, { kind: 'task-done', task: t.id, by: t.to, text: t.text })
1405
+ }
1406
+ hive.doc.getMap('gate_results').forEach((v, k) => {
1407
+ if (!v) return
1408
+ // A forced release already says so in room chat, which the bridge carries over.
1409
+ note(`gate|${k}|${v.at}`, v.forced ? null : { kind: v.pass ? 'gate-pass' : 'gate-fail', by: v.by, file: v.region })
1410
+ })
1411
+ if (baseline) announced.set('__init__', 1)
1412
+ return out
1413
+ }
1414
+
1415
+ /** Announce task and gate changes from now on. Resolves once the current state has been handled. */
1416
+ async function watchRoom() {
1417
+ const pass = async () => {
1418
+ for (const ev of unannounced()) {
1419
+ try { await emit(ev) } catch (e) { handleFatal(e) }
1420
+ }
1421
+ }
1422
+ let chain = pass()
1423
+ const kick = () => { chain = chain.then(pass) }
1424
+ const tasks = hive.doc.getMap('tasks')
1425
+ const gates = hive.doc.getMap('gate_results')
1426
+ tasks.observe(kick)
1427
+ gates.observe(kick)
1428
+ await chain
1429
+ return { stop: () => { try { tasks.unobserve(kick); gates.unobserve(kick) } catch { } } }
1430
+ }
1431
+
1432
+ const postChannel = (text) => queue.push({ channel, text })
1433
+ // Inside the room's own thread when it has one, so a notice does not flood the channel.
1434
+ const postRoomThread = (text) => {
1435
+ const ts = threads.get(ROOM_THREAD_KEY)
1436
+ queue.push(ts ? { channel, thread_ts: ts, text } : { channel, text })
1437
+ }
1438
+
1439
+ return { emit, onChat, deliver, onInboundEvent, watchRoom, threads, queue, threader, postChannel, postRoomThread }
1440
+ }
1441
+
1442
+ // ---- inbound over HTTPS polling ---------------------------------------------------------
1443
+ //
1444
+ // WHY (7 Nov 2026). Every inbound path (stop/resume, thread replies, `slack check`) used Socket
1445
+ // Mode, and Socket Mode is a WebSocket only (docs.slack.dev). Antigravity's 24/7 cloud VMs refuse
1446
+ // every WebSocket upgrade ("403 WebSocket upgrades are not allowed"), so a bridge there could post
1447
+ // to Slack and never hear it. This reads the same events over plain HTTPS: conversations.history
1448
+ // for the channel and conversations.replies for the threads the bridge tracks. Both are Tier 3
1449
+ // (50+/min), and Slack's limits are per method. The design and its failure list were argued in the
1450
+ // room by antigravity-2, antigravity-4 and Antigravity; tests/slack-poll-inbound-test.mjs is
1451
+ // antigravity-2's black-box suite for it.
1452
+ // - history comes newest-first and its messages carry no `channel`: collect, restore, sort ascending.
1453
+ // - replies always repeats the thread's root as messages[0]: skipped.
1454
+ // - every ts is acted on once: a seen set in memory, on disk and in the room (two bridges).
1455
+ // Two bridges first CLAIM a ts in the room and wait a beat, so only one delivers it.
1456
+ // - a 30 s overlap re-reads late-indexed messages; the seen set makes that free.
1457
+ // - 429: wait Retry-After. Nothing is marked seen until it was delivered: late, never lost.
1458
+ // - the cursor is Slack server time. It lives in the room AND on disk, keyed (room, channel), not
1459
+ // by pid: a relay wipe (every deploy on Render's free plan) loses the room's copy, never
1460
+ // Slack's messages, so the bridge resumes from the disk copy.
1461
+ // - a bridge that was off for days must not replay days-old commands: anything older than
1462
+ // catchUpMs is not acted on, and the bridge says so once.
1463
+ // - first attach anchors to Slack's newest message, not to this machine's clock, and never
1464
+ // replays what was already in the channel.
1465
+ export const INBOUND_DEFAULT_POLL_MS = 3000
1466
+
1467
+ /** The HTTP status a WebSocket upgrade was answered with ("Unexpected server response: 403"), or 0. */
1468
+ export function refusedUpgradeStatus(why) {
1469
+ const m = /Unexpected server response: (\d{3})/.exec(String(why || ''))
1470
+ return m ? Number(m[1]) : 0
1471
+ }
1472
+
1473
+ const safeFilePart = (s) => String(s || '').replace(/[^\w.-]/g, '_')
1474
+ /** Where a bridge keeps its Slack cursor across restarts and relay wipes. Keyed by room and channel,
1475
+ * never by process id, so a restarted bridge finds what the last one left. */
1476
+ export function slackInboundStateFile(dir, room, channel) {
1477
+ return path.join(dir || os.tmpdir(), `hive-slack-inbound-${safeFilePart(room)}-${safeFilePart(channel)}.json`)
1478
+ }
1479
+ const readStateFile = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')) || {} } catch { return {} } }
1480
+ const writeStateFile = (f, obj) => {
1481
+ try {
1482
+ fs.mkdirSync(path.dirname(f), { recursive: true })
1483
+ const tmp = `${f}.${process.pid}.tmp`
1484
+ fs.writeFileSync(tmp, JSON.stringify(obj))
1485
+ fs.renameSync(tmp, f)
1486
+ } catch { /* a state file we cannot write must not stop the bridge; the room still has its copy */ }
1487
+ }
1488
+ const tsNum = (ts) => Number(ts) || 0
1489
+
1490
+ /** Polls Slack over HTTPS and hands each new person message to onEvent, in order, once.
1491
+ * store: the room's slack_inbound map (shared by every bridge on this room).
1492
+ * threads: the bridge's slack_threads map (task id or __room__ -> thread ts).
1493
+ * isSettled(taskId): true when that task is done, so its thread is not polled. */
1494
+ export function makeSlackPoller({
1495
+ token = '',
1496
+ channel = '',
1497
+ api = process.env.HIVE_SLACK_API || DEFAULT_API,
1498
+ fetchImpl = fetch,
1499
+ store = null,
1500
+ threads = null,
1501
+ isSettled = () => false,
1502
+ diskFile = '',
1503
+ me = 'SlackBridge',
1504
+ onEvent = async () => {},
1505
+ onSkipped = null,
1506
+ logErr = console.error,
1507
+ pollMs = INBOUND_DEFAULT_POLL_MS,
1508
+ overlapSec = 30,
1509
+ catchUpMs = 30 * 60 * 1000,
1510
+ claimSettleMs = null,
1511
+ takeOverMs = 10000,
1512
+ threadRecheckMs = 30000,
1513
+ now = () => Date.now(),
1514
+ timer = setTimeout,
1515
+ } = {}) {
1516
+ const call = (method, body) => slackPost(method, body, token, fetchImpl, api)
1517
+ const settleMs = claimSettleMs != null ? claimSettleMs : Math.min(250, Math.max(20, Math.floor(pollMs / 2)))
1518
+ const disk = diskFile ? readStateFile(diskFile) : {}
1519
+ const seen = new Set((Array.isArray(disk.seen) ? disk.seen : []).map(String))
1520
+ const claimSeenAt = new Map() // ts -> when this process first saw another bridge's claim on it
1521
+ const lastPolled = new Map() // thread ts -> when this process last read its replies
1522
+ const get = (k) => (store ? store.get(k) : undefined)
1523
+ const set = (k, v) => { if (store) store.set(k, v) }
1524
+ // The room's copy wins while it exists; the disk copy carries the bridge across a relay wipe.
1525
+ let cursor = String(get('cursor') || disk.cursorTs || '')
1526
+ let floor = String(get('floor') || disk.floorTs || '')
1527
+ const threadSeenDisk = { ...(disk.threadSeen || {}) }
1528
+ const threadSeen = (ts) => String(get(`t:${ts}`) || threadSeenDisk[ts] || '')
1529
+ let agedTurn = 0
1530
+ let started = false
1531
+ let stopped = false
1532
+ let loopTimer = null
1533
+ let failures = 0
1534
+ let ticks = 0
1535
+
1536
+ // A relay wipe empties slack_threads; put back the threads the last bridge knew (never the
1537
+ // outbound chat cursor, which counts room chat, not Slack).
1538
+ if (threads && disk.threads && typeof disk.threads === 'object') {
1539
+ for (const [k, v] of Object.entries(disk.threads)) {
1540
+ if (k !== CURSOR_KEY && v && !threads.get(k)) threads.set(k, String(v))
1541
+ }
1542
+ }
1543
+
1544
+ function tracked() {
1545
+ const out = []
1546
+ if (!threads) return out
1547
+ for (const [key, ts] of threads.entries()) {
1548
+ if (key === CURSOR_KEY || !ts) continue
1549
+ if (key !== ROOM_THREAD_KEY && isSettled(key)) continue
1550
+ out.push({ key, ts: String(ts) })
1551
+ }
1552
+ return out
1553
+ }
1554
+
1555
+ function persist() {
1556
+ // Only real changes go into the room: every set is an update every peer receives.
1557
+ if (cursor && get('cursor') !== cursor) set('cursor', cursor)
1558
+ if (floor && get('floor') !== floor) set('floor', floor)
1559
+ const keepFrom = tsNum(cursor) - overlapSec * 4
1560
+ if (store && typeof store.keys === 'function') {
1561
+ for (const k of [...store.keys()]) {
1562
+ if ((k.startsWith('s:') || k.startsWith('c:')) && tsNum(k.slice(2)) < keepFrom) store.delete(k)
1563
+ }
1564
+ }
1565
+ if (!diskFile) return
1566
+ const th = {}
1567
+ if (threads) for (const [k, v] of threads.entries()) if (k !== CURSOR_KEY && v) th[k] = String(v)
1568
+ writeStateFile(diskFile, {
1569
+ cursorTs: cursor,
1570
+ floorTs: floor,
1571
+ threads: th,
1572
+ threadSeen: threadSeenDisk,
1573
+ seen: [...seen].filter((ts) => tsNum(ts) >= keepFrom).slice(-2000),
1574
+ updatedAt: new Date(now()).toISOString(),
1575
+ })
1576
+ }
1577
+
1578
+ async function tick() {
1579
+ ticks++
1580
+ const nowSec = now() / 1000
1581
+ // First attach: anchor to Slack's own newest message, never to this machine's clock, and act
1582
+ // on nothing that was already there.
1583
+ // The anchor is the newest thing Slack holds that we could be asked about: a top-level ts, a
1584
+ // root's latest_reply, or (for a tracked thread whose root is off the first page) its newest
1585
+ // reply. Anchoring to the newest top-level ts alone replayed a reply posted before the bridge
1586
+ // existed (tests/slack-poller-cursor-test.mjs P1, found by Antigravity).
1587
+ if (!cursor) {
1588
+ let high = ''
1589
+ const bump = (ts) => { if (ts && tsNum(ts) > tsNum(high)) high = String(ts) }
1590
+ const r = await call('conversations.history', { channel, limit: 100 })
1591
+ const onPage = new Set()
1592
+ for (const m of r.messages || []) { if (!m) continue; onPage.add(String(m.ts)); bump(m.ts); bump(m.latest_reply) }
1593
+ for (const t of tracked()) {
1594
+ if (onPage.has(t.ts)) continue
1595
+ try {
1596
+ let pageCursor = ''
1597
+ for (let page = 0; page < 5; page++) {
1598
+ const rr = await call('conversations.replies', { channel, ts: t.ts, limit: 200, ...(pageCursor ? { cursor: pageCursor } : {}) })
1599
+ for (const m of rr.messages || []) if (m) bump(m.ts)
1600
+ pageCursor = (rr.has_more && rr.response_metadata && rr.response_metadata.next_cursor) || ''
1601
+ if (!pageCursor) break
1602
+ }
1603
+ } catch (e) {
1604
+ if (e && e.status === 429) throw e
1605
+ // A deleted or unreadable thread cannot replay anything; anchor on the rest.
1606
+ }
1607
+ }
1608
+ cursor = high || `${Math.floor(nowSec)}.000000`
1609
+ floor = cursor
1610
+ persist()
1611
+ return { anchored: cursor, delivered: 0, skipped: 0 }
1612
+ }
1613
+ const before = cursor
1614
+ const windowFrom = tsNum(cursor) - overlapSec
1615
+
1616
+ // 1. The channel: its newest page, paged back until it reaches the cursor (minus the overlap).
1617
+ // The same page tells us which tracked threads have new replies (latest_reply).
1618
+ const top = []
1619
+ let pageCursor = ''
1620
+ for (let page = 0; page < 5; page++) {
1621
+ const r = await call('conversations.history', { channel, limit: 100, ...(pageCursor ? { cursor: pageCursor } : {}) })
1622
+ const msgs = r.messages || []
1623
+ top.push(...msgs)
1624
+ const oldestHere = msgs.length ? tsNum(msgs[msgs.length - 1].ts) : 0
1625
+ pageCursor = (r.has_more && r.response_metadata && r.response_metadata.next_cursor) || ''
1626
+ if (!pageCursor || oldestHere <= windowFrom) break
1627
+ }
1628
+ const roots = new Map()
1629
+ for (const m of top) if (m && m.ts && m.reply_count) roots.set(String(m.ts), String(m.latest_reply || ''))
1630
+
1631
+ // 2. Tracked threads. A reply is not in the channel's history unless it was also sent to the
1632
+ // channel. A root on the page is read when its latest_reply moved (or every 30 s, for a
1633
+ // late-indexed reply); a root that scrolled off the page is read in turn, one per tick.
1634
+ const toRead = []
1635
+ const aged = []
1636
+ for (const t of tracked()) {
1637
+ if (roots.has(t.ts)) {
1638
+ const latest = roots.get(t.ts)
1639
+ if ((latest && tsNum(latest) > tsNum(threadSeen(t.ts))) || now() - (lastPolled.get(t.ts) || 0) > threadRecheckMs) toRead.push(t)
1640
+ } else aged.push(t)
1641
+ }
1642
+ if (aged.length) toRead.push(aged[agedTurn++ % aged.length])
1643
+ const replies = []
1644
+ const threadHigh = new Map()
1645
+ for (const t of toRead) {
1646
+ const last = threadSeen(t.ts) || t.ts
1647
+ const oldest = Math.max(tsNum(t.ts), tsNum(last) - overlapSec)
1648
+ const r = await call('conversations.replies', { channel, ts: t.ts, oldest: String(oldest), inclusive: true, limit: 200 })
1649
+ lastPolled.set(t.ts, now())
1650
+ let high = last
1651
+ for (const m of r.messages || []) {
1652
+ if (!m || !m.ts || String(m.ts) === t.ts) continue // Slack repeats the thread's root first
1653
+ replies.push({ ...m, thread_ts: m.thread_ts || t.ts })
1654
+ if (tsNum(m.ts) > tsNum(high)) high = String(m.ts)
1655
+ }
1656
+ threadHigh.set(t.ts, high)
1657
+ }
1658
+
1659
+ // 3. One list, oldest first, each ts once (a reply sent to the channel is in both), channel restored.
1660
+ const byTs = new Map()
1661
+ for (const m of [...top.filter((x) => x && x.ts && tsNum(x.ts) > windowFrom), ...replies]) {
1662
+ const k = String(m.ts)
1663
+ if (!byTs.has(k)) byTs.set(k, { ...m, channel })
1664
+ }
1665
+ const fresh = [...byTs.values()]
1666
+ .filter((m) => !seen.has(String(m.ts)) && !get(`s:${m.ts}`) && (!floor || tsNum(m.ts) > tsNum(floor)))
1667
+ .sort((a, b) => tsNum(a.ts) - tsNum(b.ts))
1668
+ for (const m of [...byTs.values()]) if (get(`s:${m.ts}`)) seen.add(String(m.ts))
1669
+
1670
+ // 4. Too old to act on (the bridge was off): not delivered, counted, said once by the caller.
1671
+ const tooOld = nowSec - catchUpMs / 1000
1672
+ let skipped = 0
1673
+ const live = []
1674
+ for (const m of fresh) {
1675
+ if (tsNum(m.ts) < tooOld) {
1676
+ if (!notFromAPerson(m)) skipped++
1677
+ seen.add(String(m.ts)); set(`s:${m.ts}`, 1)
1678
+ } else live.push(m)
1679
+ }
1680
+
1681
+ // 5. Two bridges on one room: claim, wait a beat for the other's claim to arrive, deliver
1682
+ // only what this bridge won. A claim whose owner never delivered is taken over later.
1683
+ if (live.length && store) {
1684
+ for (const m of live) if (!get(`c:${m.ts}`)) set(`c:${m.ts}`, me)
1685
+ await new Promise((r) => timer(r, settleMs))
1686
+ }
1687
+ let delivered = 0
1688
+ for (const m of live) {
1689
+ const k = String(m.ts)
1690
+ if (store && get(`s:${k}`)) { seen.add(k); continue }
1691
+ const owner = store ? get(`c:${k}`) : me
1692
+ if (owner && owner !== me) {
1693
+ const first = claimSeenAt.get(k) || now()
1694
+ claimSeenAt.set(k, first)
1695
+ if (now() - first < takeOverMs) continue // the other bridge has it; look again next tick
1696
+ set(`c:${k}`, me)
1697
+ }
1698
+ const r = m && m.text ? route(m.text) : null
1699
+ const isControl = Boolean(
1700
+ m && (
1701
+ m.type === 'agent_session_stopped' ||
1702
+ Boolean(r && (r.intent === 'stop' || r.intent === 'stop-all' || r.intent === 'resume' || r.intent === 'resume-all'))
1703
+ )
1704
+ )
1705
+ if (isControl) {
1706
+ seen.add(k)
1707
+ set(`s:${k}`, 1)
1708
+ if (tsNum(k) > tsNum(cursor)) cursor = k
1709
+ if (m.thread_ts && tsNum(k) > tsNum(threadSeen(m.thread_ts))) {
1710
+ set(`t:${m.thread_ts}`, k)
1711
+ threadSeenDisk[m.thread_ts] = k
1712
+ }
1713
+ persist()
1714
+ }
1715
+ try { await onEvent(m) } catch (e) { try { logErr(`[hivecode] a Slack message could not be handled: ${e && e.message}`) } catch { } }
1716
+ seen.add(k); set(`s:${k}`, 1); claimSeenAt.delete(k)
1717
+ delivered++
1718
+ }
1719
+
1720
+ // 6. Only now move the marks: the channel cursor and each thread's last-read reply.
1721
+ // Slack ts is one clock per channel, so replies move the cursor too (P2: most bridge traffic
1722
+ // is thread replies; a cursor that only followed top-level messages stood still).
1723
+ for (const m of [...top, ...replies]) if (m && m.ts && tsNum(m.ts) > tsNum(cursor)) cursor = String(m.ts)
1724
+ for (const [ts, high] of threadHigh) {
1725
+ if (tsNum(high) > tsNum(threadSeen(ts))) { set(`t:${ts}`, high); threadSeenDisk[ts] = high }
1726
+ }
1727
+ persist()
1728
+ if (skipped && onSkipped) {
1729
+ try { await onSkipped({ count: skipped, since: before, catchUpMs }) } catch { }
1730
+ }
1731
+ return { delivered, skipped }
1732
+ }
1733
+
1734
+ function schedule(delay) {
1735
+ if (stopped) return
1736
+ loopTimer = timer(async () => {
1737
+ let next = pollMs
1738
+ try {
1739
+ await tick()
1740
+ if (failures) { try { logErr('[hivecode] Slack polling recovered.') } catch { } }
1741
+ failures = 0
1742
+ } catch (e) {
1743
+ if (e && (e.status === 429 || e.code === 'ratelimited')) {
1744
+ next = Math.max(pollMs, Number(e.retryAfterMs) || 1000) // nothing was marked seen: late, not lost
1745
+ } else {
1746
+ failures++
1747
+ next = Math.min(60000, pollMs * 2 ** Math.min(failures, 5))
1748
+ if (failures === 1) {
1749
+ const why = e && e.code === 'missing_scope'
1750
+ ? `${e.message}. Reinstall your Slack app with slack-app-manifest-inbound.json (docs/SLACK_SETUP.md Section 5)`
1751
+ : (e && e.message) || String(e)
1752
+ try { logErr(`[hivecode] Slack polling failed (${why}); retrying.`) } catch { }
1753
+ }
1754
+ }
1755
+ }
1756
+ schedule(next)
1757
+ }, delay)
1758
+ if (loopTimer && typeof loopTimer.unref === 'function' && process.env.HIVE_SLACK_POLL_UNREF === '1') loopTimer.unref()
1759
+ }
1760
+
1761
+ return {
1762
+ tick,
1763
+ /** Keep polling every pollMs. Two bridges on one room start out of phase (by name). */
1764
+ start() {
1765
+ if (started) return
1766
+ started = true
1767
+ let h = 0
1768
+ for (const ch of String(me)) h = (h * 31 + ch.charCodeAt(0)) >>> 0
1769
+ schedule(pollMs + (h % Math.max(1, pollMs)))
1770
+ },
1771
+ stop() { stopped = true; if (loopTimer) clearTimeout(loopTimer) },
1772
+ state: () => ({ cursor, floor, ticks, seen: seen.size }),
1773
+ }
1774
+ }
1775
+
1776
+ export async function runSlackCheck({
1777
+ token = '',
1778
+ appToken = '',
1779
+ channel = '',
1780
+ agent = false,
1781
+ timeoutMs = 120000,
1782
+ helloTimeoutMs = Number(process.env.HIVE_SLACK_HELLO_TIMEOUT_MS) || 10000,
1783
+ startRetryMs = Number(process.env.HIVE_SLACK_START_RETRY_MS) || 60000,
1784
+ startRetryDelayMs = 1000,
1785
+ api = process.env.HIVE_SLACK_API || DEFAULT_API,
1786
+ fetchImpl = fetch,
1787
+ WebSocketImpl = WebSocket,
1788
+ log = console.log,
1789
+ logErr = console.error,
1790
+ inbound = 'auto',
1791
+ pollMs = INBOUND_DEFAULT_POLL_MS,
1792
+ } = {}) {
1793
+ if (!token || !String(token).trim()) {
1794
+ logErr('Q630 SLACK_BOT_TOKEN is missing. Set SLACK_BOT_TOKEN=xoxb-… in your environment.')
1795
+ return 1
1796
+ }
1797
+ if (!channel || !String(channel).trim()) {
1798
+ logErr('Q631 --channel <id> is required (for example: --channel C0123456789).')
1799
+ return 2
1800
+ }
1801
+ if (agent && (!appToken || !String(appToken).trim())) {
1802
+ logErr('Q634 --agent requires SLACK_APP_TOKEN=xapp-… (with connections:write scope) for Socket Mode.')
1803
+ return 1
1804
+ }
1805
+
1806
+ // 1. Token (auth.test)
1807
+ let auth
1808
+ try {
1809
+ auth = await slackPost('auth.test', {}, token, fetchImpl, api)
1810
+ } catch (e) {
1811
+ if (!e || !e.slackError) {
1812
+ logErr(`Q636 could not reach Slack (${formatSlackWhy(e)}). The token was not checked; check this machine's network and try again.`)
1813
+ return 1
1814
+ }
1815
+ logErr(`Q630 SLACK_BOT_TOKEN was rejected by Slack (${formatSlackWhy(e)}). Check the token or reinstall the Slack app.`)
1816
+ return 1
1817
+ }
1818
+ const botUser = auth.user || 'Hivecode'
1819
+ const botUserId = auth.user_id || ''
1820
+ log(`✓ token: @${botUser} (${botUserId || 'bot'})`)
1821
+
1822
+ // 2. Workspace
1823
+ if (!auth.team_id) {
1824
+ logErr('Q634 Slack auth.test did not return a workspace team_id. Reinstall the Slack app.')
1825
+ return 1
1826
+ }
1827
+ log(`✓ workspace: ${auth.team || auth.team_id} (${auth.team_id})`)
1828
+
1829
+ // 3. Channel & membership (conversations.info)
1830
+ let info
1831
+ try {
1832
+ info = await slackPost('conversations.info', { channel }, token, fetchImpl, api)
1833
+ } catch (e) {
1834
+ if (!e || !e.slackError) {
1835
+ logErr(`Q636 could not reach Slack (${formatSlackWhy(e)}). The token was not checked; check this machine's network and try again.`)
1836
+ return 1
1837
+ }
1838
+ logErr(`Q631 Slack channel ${channel} could not be accessed (${formatSlackWhy(e)}). Check the channel ID and run /invite @${botUser} in the channel.`)
1839
+ return 1
1840
+ }
1841
+ if (info && info.channel && info.channel.is_member === false) {
1842
+ const chName = info.channel.name ? `#${info.channel.name}` : channel
1843
+ logErr(`Q631 @${botUser} is not a member of ${chName} (${channel}). In Slack, open ${chName} and run: /invite @${botUser}`)
1844
+ return 1
1845
+ }
1846
+ const channelName = (info && info.channel && info.channel.name) || channel
1847
+ const isPrivate = Boolean(
1848
+ info && info.channel && (
1849
+ info.channel.is_private === true ||
1850
+ info.channel.is_group === true ||
1851
+ String(channel || '').startsWith('G')
1852
+ ),
1853
+ )
1854
+ log(`✓ channel: #${channelName} (${channel}, member)`)
1855
+
1856
+ // 4. Scopes (x-oauth-scopes)
1857
+ const scopes = Array.isArray(auth && auth._scopes)
1858
+ ? auth._scopes
1859
+ : (Array.isArray(info && info._scopes) ? info._scopes : null)
1860
+ if (scopes === null) {
1861
+ log('- scopes: x-oauth-scopes header not returned by server (not verified)')
1862
+ } else if (agent) {
1863
+ const agCheck = checkAgentScopes({ scopes, isPrivate, channel })
1864
+ if (!agCheck.ok) {
1865
+ logErr(agCheck.error)
1866
+ return 1
1867
+ }
1868
+ log(`✓ scopes (outbound + inbound + agent): ${scopes.join(', ')}`)
1869
+ } else {
1870
+ const have = new Set(scopes)
1871
+ const readScope = isPrivate ? 'groups:read' : 'channels:read'
1872
+ const outMissing = []
1873
+ if (!have.has('chat:write')) outMissing.push('chat:write')
1874
+ if (!have.has(readScope)) outMissing.push(readScope)
1875
+ if (outMissing.length) {
1876
+ logErr(`Q630 SLACK_BOT_TOKEN is missing required outbound scope(s): ${outMissing.join(', ')}. Reinstall your Slack app from slack-app-manifest.json.`)
1877
+ return 1
1878
+ }
1879
+ if (appToken || inbound === 'poll') {
1880
+ const inCheck = checkInboundScopes({ scopes, isPrivate, channel })
1881
+ if (!inCheck.ok) {
1882
+ logErr(inCheck.error)
1883
+ return 1
1884
+ }
1885
+ log(`✓ scopes (outbound + inbound): ${scopes.join(', ')}`)
1886
+ } else {
1887
+ log(`✓ scopes (outbound): ${scopes.join(', ')} (SLACK_APP_TOKEN not set; inbound reply check skipped)`)
1888
+ }
1889
+ }
1890
+
1891
+ // Outbound-only mode (when SLACK_APP_TOKEN is not set): verify outbound post + thread reply, never claim inbound replies work
1892
+ if ((!appToken || !String(appToken).trim()) && inbound !== 'poll') {
1893
+ log(`This posts one message in #${channelName} (and one reply in its thread)`)
1894
+ try {
1895
+ const rootPost = await slackPost('chat.postMessage', { channel, text: 'Hivecode check: outbound posting works.' }, token, fetchImpl, api)
1896
+ if (rootPost && rootPost.ts) {
1897
+ 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)
1898
+ }
1899
+ } catch (e) {
1900
+ logErr(`Q630 Slack chat.postMessage failed (${formatSlackWhy(e)}).`)
1901
+ return 1
1902
+ }
1903
+ log(`✓ outbound post: delivered to #${channelName} (set SLACK_APP_TOKEN to verify inbound replies)`)
1904
+ return 0
1905
+ }
1906
+
1907
+ // 5. Socket Mode connect (must receive Slack's `hello` frame within helloTimeoutMs BEFORE we post the prompt)
1908
+ // --inbound poll, or a network that refuses Socket Mode's WebSocket: the reply is read from the
1909
+ // check thread over HTTPS instead (conversations.replies). The agent surface needs Socket Mode.
1910
+ let pollInbound = inbound === 'poll'
1911
+ if (pollInbound && agent) {
1912
+ logErr('Q634 --agent needs Socket Mode (the native stop button is a Socket Mode event); --inbound poll cannot check it.')
1913
+ return 1
1914
+ }
1915
+ const sm = pollInbound ? { ok: true, url: '' } : await preflightSocketMode({ appToken, scopes: agent ? null : scopes, isPrivate, channel, api, fetchImpl })
1916
+ if (!sm.ok) {
1917
+ logErr(sm.error)
1918
+ return 1
1919
+ }
1920
+
1921
+ let sock = null
1922
+ const closeSock = () => {
1923
+ if (!sock) return
1924
+ try { sock.removeAllListeners && sock.removeAllListeners() } catch { }
1925
+ try { sock.close() } catch { }
1926
+ try { sock.terminate && sock.terminate() } catch { }
1927
+ sock = null
1928
+ }
1929
+
1930
+ let checkTs = ''
1931
+ let replyResolver = null
1932
+ const replyPromise = new Promise((resolve) => { replyResolver = resolve })
1933
+
1934
+ let currentUrl = sm.url
1935
+ const startTime = Date.now()
1936
+
1937
+ while (!pollInbound) {
1938
+ if (!currentUrl) {
1939
+ try {
1940
+ const res = await slackPost('apps.connections.open', {}, appToken, fetchImpl, api)
1941
+ currentUrl = res && res.url
1942
+ } catch (e) {
1943
+ closeSock()
1944
+ if (!e || !e.slackError) {
1945
+ logErr(`Q636 could not reach Slack (${formatSlackWhy(e)}). The token was not checked; check this machine's network and try again.`)
1946
+ return 1
1947
+ }
1948
+ logErr(`Q634 Slack apps.connections.open failed (${formatSlackWhy(e)}).`)
1949
+ return 1
1950
+ }
1951
+ }
1952
+ if (!currentUrl) {
1953
+ closeSock()
1954
+ logErr('Q634 Slack apps.connections.open did not return a WebSocket URL.')
1955
+ return 1
1956
+ }
1957
+
1958
+ const res = await new Promise((resolve) => {
1959
+ let settled = false
1960
+ const finish = (val) => {
1961
+ if (settled) return
1962
+ settled = true
1963
+ clearTimeout(hTimer)
1964
+ resolve(val)
1965
+ }
1966
+ const hTimer = setTimeout(() => finish({ timedOut: true }), helloTimeoutMs)
1967
+ try {
1968
+ sock = new WebSocketImpl(currentUrl)
1969
+ } catch (e) {
1970
+ finish({ error: e })
1971
+ return
1972
+ }
1973
+ sock.on('error', (e) => finish({ error: e }))
1974
+ sock.on('close', () => {
1975
+ finish({ closed: true })
1976
+ if (replyResolver) replyResolver(false)
1977
+ })
1978
+ sock.on('message', (raw) => {
1979
+ let frame
1980
+ try { frame = JSON.parse(String(raw)) } catch { return }
1981
+ if (frame && frame.envelope_id) {
1982
+ try { sock && sock.send(JSON.stringify({ envelope_id: frame.envelope_id })) } catch { }
1983
+ }
1984
+ if (!frame) return
1985
+ if (frame.type === 'hello') {
1986
+ finish({ ok: true, frame })
1987
+ return
1988
+ }
1989
+ const ev = frame.payload && frame.payload.event
1990
+ if (
1991
+ ev &&
1992
+ (ev.type === 'message' || (agent && ev.type === 'agent_session_stopped')) &&
1993
+ !notFromAPerson(ev) &&
1994
+ ev.user &&
1995
+ ev.user !== botUserId &&
1996
+ (!ev.channel || ev.channel === channel) &&
1997
+ checkTs &&
1998
+ (ev.thread_ts || ev.ts) === checkTs
1999
+ ) {
2000
+ if (replyResolver) replyResolver(true)
2001
+ }
2002
+ })
2003
+ })
2004
+
2005
+ const refused = res.error ? refusedUpgradeStatus(res.error.message) : 0
2006
+ if (refused && inbound === 'auto' && !agent) {
2007
+ closeSock()
2008
+ pollInbound = true
2009
+ logErr(`[hivecode] Slack replies are read over HTTPS every ${pollMs >= 1000 ? `${Math.round(pollMs / 100) / 10} s` : `${pollMs} ms`} (this network refused a WebSocket to Slack, HTTP ${refused}).`)
2010
+ break
2011
+ }
2012
+ if (res.timedOut || res.closed || res.error) {
2013
+ closeSock()
2014
+ logErr(refused
2015
+ ? `Q634 this network refused Socket Mode's WebSocket (HTTP ${refused}). Run slack check without --agent to read replies over HTTPS instead.`
2016
+ : 'Q634 Socket Mode did not open (no hello frame received within timeout). Check SLACK_APP_TOKEN (connections:write) and network access to Slack.')
2017
+ return 1
2018
+ }
2019
+
2020
+ const num = Number(res.frame && res.frame.num_connections) || 1
2021
+ if (num <= 1) {
2022
+ break
2023
+ }
2024
+
2025
+ const elapsed = Date.now() - startTime
2026
+ if (elapsed < startRetryMs) {
2027
+ closeSock()
2028
+ const waitTime = Math.min(startRetryDelayMs, Math.max(50, startRetryMs - elapsed))
2029
+ await new Promise((r) => setTimeout(r, waitTime))
2030
+ currentUrl = ''
2031
+ continue
2032
+ }
2033
+
2034
+ closeSock()
2035
+ 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.`)
2036
+ return 1
2037
+ }
2038
+
2039
+ log(pollInbound ? '✓ inbound: replies are read over HTTPS (conversations.replies)' : '✓ Socket Mode: connected')
2040
+ // Reading the check thread over HTTPS: any person's reply, not the bot's own posts.
2041
+ const pollForReply = async () => {
2042
+ const until = Date.now() + timeoutMs
2043
+ while (Date.now() < until) {
2044
+ let wait = pollMs
2045
+ try {
2046
+ const r = await slackPost('conversations.replies', { channel, ts: checkTs, limit: 100 }, token, fetchImpl, api)
2047
+ if ((r.messages || []).some((m) => m && String(m.ts) !== checkTs && !notFromAPerson(m) && m.user && m.user !== botUserId)) return true
2048
+ } catch (e) {
2049
+ if (e && e.code === 'missing_scope') {
2050
+ const sc = checkInboundScopes({ scopes: [], isPrivate, channel })
2051
+ logErr(sc.ok ? `Q634 ${e.message}` : sc.error)
2052
+ return false
2053
+ }
2054
+ if (e && e.retryAfterMs) wait = Math.max(pollMs, e.retryAfterMs)
2055
+ }
2056
+ await new Promise((r) => setTimeout(r, wait))
2057
+ }
2058
+ return false
2059
+ }
2060
+
2061
+ // 6. Real proof: post prompt and wait up to timeoutMs for any non-bot reply in that thread
2062
+ log(`This posts one message in #${channelName} (and one reply in its thread)`)
2063
+ try {
2064
+ const postRes = await slackPost(
2065
+ 'chat.postMessage',
2066
+ { channel, text: 'Hivecode check: reply anything in this thread' },
2067
+ token,
2068
+ fetchImpl,
2069
+ api,
2070
+ )
2071
+ checkTs = (postRes && postRes.ts) || ''
2072
+ } catch (e) {
2073
+ closeSock()
2074
+ logErr(`Q630 Slack chat.postMessage failed (${formatSlackWhy(e)}).`)
2075
+ return 1
2076
+ }
2077
+
2078
+ let waitTimer = null
2079
+ const onSigInt = () => {
2080
+ if (replyResolver) replyResolver(false)
2081
+ }
2082
+ if (agent) {
2083
+ process.once('SIGINT', onSigInt)
2084
+ }
2085
+
2086
+ try {
2087
+ if (agent) {
2088
+ let statusRes
2089
+ try {
2090
+ statusRes = await slackPost(
2091
+ 'agents.sessions.setStatus',
2092
+ { status: 'processing', channel_id: channel, thread_ts: checkTs },
2093
+ token,
2094
+ fetchImpl,
2095
+ api,
2096
+ )
2097
+ } catch (e) {
2098
+ const code = extractSlackError(e)
2099
+ if (code === 'feature_disabled') {
2100
+ 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).')
2101
+ } else if (code === 'thread_ts_required') {
2102
+ logErr('Q634 Slack agents.sessions.setStatus failed (thread_ts_required): Slack requires thread_ts for thread-based agent sessions in DMs.')
2103
+ } else {
2104
+ logErr(`Q634 Slack agents.sessions.setStatus failed (${formatSlackWhy(e)}). Reinstall from slack-app-manifest-agent.json.`)
2105
+ }
2106
+ return 1
2107
+ }
2108
+ const warnings = extractSlackWarnings(statusRes)
2109
+ if (warnings.includes('missing_agent_session_stopped_event_subscription')) {
2110
+ 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.')
2111
+ return 1
2112
+ }
2113
+ log('✓ agent session: native stop button subscribed (agents.sessions.setStatus)')
2114
+ }
2115
+
2116
+ const arrived = await Promise.race([
2117
+ pollInbound ? pollForReply() : replyPromise,
2118
+ new Promise((resolve) => { waitTimer = setTimeout(() => resolve(false), timeoutMs) }),
2119
+ ])
2120
+ clearTimeout(waitTimer)
2121
+
2122
+ if (!arrived) {
2123
+ const manifestName = agent ? 'slack-app-manifest-agent.json' : 'slack-app-manifest-inbound.json'
2124
+ logErr(`Q634 replies did not arrive. Event subscriptions are probably missing: reinstall from ${manifestName}`)
2125
+ return 1
2126
+ }
2127
+
2128
+ await slackPost(
2129
+ 'chat.postMessage',
2130
+ { channel, thread_ts: checkTs, text: '✓ Hivecode check: replies reach Hivecode.' },
2131
+ token,
2132
+ fetchImpl,
2133
+ api,
2134
+ ).catch(() => {})
2135
+ log('✓ replies reach Hivecode')
2136
+ return 0
2137
+ } finally {
2138
+ clearTimeout(waitTimer)
2139
+ if (agent) {
2140
+ process.removeListener('SIGINT', onSigInt)
2141
+ if (checkTs) {
2142
+ await slackPost(
2143
+ 'agents.sessions.setStatus',
2144
+ { status: 'active', channel_id: channel, thread_ts: checkTs },
2145
+ token,
2146
+ fetchImpl,
2147
+ api,
2148
+ ).catch(() => {})
2149
+ }
2150
+ }
2151
+ closeSock()
2152
+ }
2153
+ }
2154
+
2155
+ export async function main(argv = process.argv.slice(2), { log = console.log, logErr = console.error, fetchImpl = fetch, env = process.env, WebSocketImpl = WebSocket } = {}) {
2156
+ let mode = 'start'
2157
+ let CHANNEL = env.SLACK_CHANNEL || ''
2158
+ let NAME = 'SlackBridge'
2159
+ let ALLOW = env.SLACK_ALLOW || ''
2160
+ let allowSource = env.SLACK_ALLOW ? 'SLACK_ALLOW' : '--allow'
2161
+ let LINK = ''
2162
+ let dryRun = false
2163
+ let agent = false
2164
+ let timeoutMs = Number(env.HIVE_SLACK_CHECK_TIMEOUT_MS) || 120000
2165
+ let helloTimeoutMs = Number(env.HIVE_SLACK_HELLO_TIMEOUT_MS) || 10000
2166
+ let startRetryMs = Number(env.HIVE_SLACK_START_RETRY_MS) || 60000
2167
+ // auto: Socket Mode when SLACK_APP_TOKEN is set, HTTPS polling if the network refuses the
2168
+ // WebSocket; poll: HTTPS polling only (needs no SLACK_APP_TOKEN); socket: Socket Mode only.
2169
+ let inbound = String(env.HIVE_SLACK_INBOUND || 'auto').toLowerCase()
2170
+ for (let i = 0; i < argv.length; i++) {
2171
+ const a = argv[i]
2172
+ if ((a === 'start' || a === 'check') && !LINK && i === 0) { mode = a; continue }
2173
+ if (a === '--inbound') { inbound = String(argv[++i] || '').toLowerCase(); continue }
2174
+ if (a === '--help' || a === '-h') { log(USAGE.trim()); return 0 }
2175
+ if (a === '--dry-run') { dryRun = true; continue }
2176
+ if (a === '--agent') { agent = true; continue }
2177
+ if (a === '--channel') { CHANNEL = argv[++i] || ''; continue }
2178
+ if (a === '--name') { NAME = argv[++i] || 'SlackBridge'; continue }
2179
+ if (a === '--allow') { ALLOW = argv[++i] || ''; allowSource = '--allow'; continue }
2180
+ if (a === '--timeout') { timeoutMs = Number(argv[++i]) || timeoutMs; continue }
2181
+ if (a === '--hello-timeout') { helloTimeoutMs = Number(argv[++i]) || helloTimeoutMs; continue }
2182
+ if (a === '--start-retry') { startRetryMs = Number(argv[++i]) || startRetryMs; continue }
2183
+ if (a.startsWith('--')) { logErr(USAGE.trim()); return 2 }
2184
+ if (!LINK) LINK = a
2185
+ }
2186
+ if (!['auto', 'poll', 'socket'].includes(inbound)) {
2187
+ logErr(`Q630 --inbound must be auto, poll or socket (got "${inbound}").`)
2188
+ return 2
2189
+ }
2190
+ const TOKEN = env.SLACK_BOT_TOKEN || ''
2191
+ const APP_TOKEN = env.SLACK_APP_TOKEN || ''
2192
+ const pollMs = Math.max(50, Number(env.HIVE_SLACK_POLL_MS) || INBOUND_DEFAULT_POLL_MS)
2193
+ if (mode === 'check') {
2194
+ return await runSlackCheck({
2195
+ token: TOKEN,
2196
+ appToken: APP_TOKEN,
2197
+ channel: CHANNEL,
2198
+ agent,
2199
+ inbound,
2200
+ pollMs,
2201
+ timeoutMs,
2202
+ helloTimeoutMs,
2203
+ startRetryMs,
2204
+ fetchImpl,
2205
+ WebSocketImpl,
2206
+ log,
2207
+ logErr,
2208
+ })
2209
+ }
2210
+ if (!LINK) {
2211
+ logErr(USAGE.trim())
2212
+ return 2
2213
+ }
2214
+ const allowParsed = parseSlackAllow(ALLOW, allowSource)
2215
+ if (!allowParsed.ok) {
2216
+ logErr(allowParsed.error)
2217
+ return 1
2218
+ }
2219
+ const pre = await preflightSlack({ token: TOKEN, channel: CHANNEL, fetchImpl })
2220
+ if (!pre.ok) {
2221
+ logErr(pre.error)
2222
+ return 1
2223
+ }
2224
+ let sockPre = null
2225
+ if (inbound === 'poll') {
2226
+ // Polling reads channel history with the bot token: it needs the inbound scopes, not an app token.
2227
+ const sc = checkInboundScopes({ scopes: pre.scopes, isPrivate: pre.isPrivate, channel: CHANNEL })
2228
+ if (!sc.ok) {
2229
+ logErr(sc.error)
2230
+ return 1
2231
+ }
2232
+ } else if (APP_TOKEN) {
2233
+ sockPre = await preflightSocketMode({
2234
+ appToken: APP_TOKEN,
2235
+ scopes: pre.scopes,
2236
+ isPrivate: pre.isPrivate,
2237
+ channel: CHANNEL,
2238
+ fetchImpl,
2239
+ })
2240
+ if (!sockPre.ok) {
2241
+ logErr(sockPre.error)
2242
+ return 1
2243
+ }
2244
+ }
2245
+ const { relay, room, token: linkToken } = parseLink(LINK)
2246
+ if (!relay || !room) {
2247
+ logErr(`Q630 "${LINK}" is not a valid room link (expected wss://relay|room-…).`)
2248
+ return 2
2249
+ }
2250
+ if (dryRun) {
2251
+ const mode = inbound === 'poll' ? 'outbound + inbound HTTPS polling' : sockPre ? 'outbound + inbound Socket Mode' : 'outbound'
2252
+ 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'}.`)
2253
+ return 0
2254
+ }
2255
+ const dir = (env.TEMP || process.env.TEMP || '.') + '/hive-slack-' + room + '-' + process.pid
2256
+ // NOT kind 'ai'. The bridge presses the stop button for the Slack people it has checked (a
2257
+ // full workspace member may stop; resume needs --allow), and the relay's governor reverts any
2258
+ // write to `controls` from an AI: "the party being stopped never gets the switch". As 'ai',
2259
+ // every "stop" from Slack was undone by the relay while Slack and room chat both said
2260
+ // "paused" (found 8 Nov 2026 by tests/slack-poll-inbound-test.mjs S4, the first test to run
2261
+ // the bridge against the real server.js). hive-slack-agent.js already joins this way. The relay
2262
+ // does not trust a declared 'human' (it records 'unknown', which may control on an open relay);
2263
+ // a secured room still needs a maintainer or admin token for this.
2264
+ const hive = startSync({ relay, room, dir, name: NAME, kind: 'human', token: linkToken || env.HIVE_TOKEN || '', syncFiles: false, log: () => {} })
2265
+ await new Promise((resolve) => {
2266
+ let done = false
2267
+ const finish = () => { if (!done) { done = true; resolve() } }
2268
+ hive.provider.on('sync', (s) => { if (s) finish() })
2269
+ setTimeout(finish, 8000)
2270
+ })
2271
+ const dup = checkDuplicateBridge(hive, NAME)
2272
+ if (!dup.ok) {
2273
+ logErr(dup.error)
2274
+ try { hive.stop() } catch { }
2275
+ return 1
2276
+ }
2277
+ const bridge = makeBridge({
2278
+ hive,
2279
+ channel: CHANNEL,
2280
+ token: TOKEN,
2281
+ botName: NAME,
2282
+ room,
2283
+ link: LINK,
2284
+ allow: ALLOW,
2285
+ teamId: pre.teamId || '',
2286
+ botUserId: pre.botUserId || '',
2287
+ fetchImpl,
2288
+ post: (m) => slackPost('chat.postMessage', m, TOKEN, fetchImpl),
2289
+ // Slack asks for about one message per second per channel; tests may go faster.
2290
+ minGapMs: Math.max(0, Number(env.HIVE_SLACK_MIN_GAP_MS) || 1000),
2291
+ // How long a stop/resume waits to see whether the room kept it (confirmControl).
2292
+ controlConfirmMs: Math.max(0, Number(env.HIVE_SLACK_CONTROL_CONFIRM_MS) || 1500),
2293
+ })
2294
+ // Inbound over HTTPS: --inbound poll, or a network that refused Socket Mode's WebSocket.
2295
+ const startPolling = async (why) => {
2296
+ const stateDir = env.HIVE_SLACK_STATE_DIR || env.TEMP || env.TMPDIR || os.tmpdir()
2297
+ const tasks = hive.doc.getMap('tasks')
2298
+ const poller = makeSlackPoller({
2299
+ token: TOKEN,
2300
+ channel: CHANNEL,
2301
+ fetchImpl,
2302
+ store: hive.doc.getMap('slack_inbound'),
2303
+ threads: bridge.threads,
2304
+ isSettled: (id) => { const t = tasks.get(id); return Boolean(t && t.status === 'done') },
2305
+ diskFile: slackInboundStateFile(stateDir, room, CHANNEL),
2306
+ me: NAME,
2307
+ pollMs,
2308
+ catchUpMs: Number(env.HIVE_SLACK_CATCHUP_MS) || 30 * 60 * 1000,
2309
+ onEvent: (ev) => bridge.onInboundEvent(ev),
2310
+ onSkipped: ({ count, since, catchUpMs }) => {
2311
+ const mins = Math.round(catchUpMs / 60000)
2312
+ const window = mins >= 1 ? `${mins} min` : `${Math.round(catchUpMs / 1000)} s`
2313
+ const from = new Date(tsNum(since) * 1000).toISOString().slice(0, 16).replace('T', ' ')
2314
+ const to = new Date().toISOString().slice(0, 16).replace('T', ' ')
2315
+ bridge.postRoomThread(`:hourglass: Hivecode was offline from ${from} to ${to} UTC; ${count} Slack message${count === 1 ? '' : 's'} older than ${window} ${count === 1 ? 'was' : 'were'} not acted on. Send it again if it still matters.`)
2316
+ },
2317
+ logErr,
2318
+ })
2319
+ try {
2320
+ await poller.tick() // anchors the cursor, or catches up; a missing scope is loud here
2321
+ } catch (e) {
2322
+ if (e && e.code === 'missing_scope') {
2323
+ const sc = checkInboundScopes({ scopes: [], isPrivate: pre.isPrivate, channel: CHANNEL })
2324
+ logErr(sc.ok ? `Q634 ${e.message}` : sc.error)
2325
+ return null
2326
+ }
2327
+ logErr(`[hivecode] Slack polling could not start (${(e && e.message) || e}); retrying in the background.`)
2328
+ }
2329
+ poller.start()
2330
+ logErr(`[hivecode] Slack replies are read over HTTPS every ${pollMs >= 1000 ? `${Math.round(pollMs / 100) / 10} s` : `${pollMs} ms`} (${why}). A "stop" from Slack takes up to that long to arrive.`)
2331
+ return poller
2332
+ }
2333
+ if (inbound === 'poll') {
2334
+ if (!(await startPolling('--inbound poll'))) { try { hive.stop() } catch { }; return 1 }
2335
+ } else if (sockPre) {
2336
+ try {
2337
+ await connectSocketMode({
2338
+ appToken: APP_TOKEN,
2339
+ initialUrl: sockPre.url,
2340
+ fetchImpl,
2341
+ WebSocketImpl,
2342
+ logErr,
2343
+ helloTimeoutMs,
2344
+ startRetryMs,
2345
+ failFastOnRefusal: inbound === 'auto',
2346
+ onEvent: (ev) => bridge.onInboundEvent(ev),
2347
+ onMultiConnection: (n) => {
2348
+ bridge.postChannel(
2349
+ `: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.`,
2350
+ )
2351
+ },
2352
+ })
2353
+ } catch (e) {
2354
+ if (e && e.wsRefused && inbound === 'auto') {
2355
+ // The bot token reads history as long as the app has the inbound scopes it was installed with.
2356
+ if (!(await startPolling(`this network refused a WebSocket to Slack (HTTP ${e.wsRefused})`))) { try { hive.stop() } catch { }; return 1 }
2357
+ } else {
2358
+ logErr(e.message)
2359
+ try { hive.stop() } catch { }
2360
+ return 1
2361
+ }
2362
+ }
2363
+ }
2364
+ const chat = hive.doc.getArray('chat')
2365
+ const savedCursor = Number(bridge.threads.get(CURSOR_KEY)) || 0
2366
+ let at = savedCursor > 0 && savedCursor <= chat.length ? savedCursor : chat.length
2367
+ chat.observe(() => { const all = chat.toArray(); bridge.onChat(all.slice(at), at); at = all.length })
2368
+ await bridge.watchRoom()
2369
+ log(`READY: [hive-slack] @${pre.botUser} bridging ${room} -> #${pre.channelName} (${CHANNEL}) — resume: ${allowParsed.ids.length ? allowParsed.ids.join(', ') : 'off'}`)
2370
+ return 0
2371
+ }
2372
+
2373
+ // ---- main -------------------------------------------------------------------------------
2374
+ // Run directly (node <file>, or through an npx / node_modules/.bin symlink)? Compared by real path:
2375
+ // the old `file:///${argv[1]}` test built "file:////..." on macOS and Linux, never matched, and the
2376
+ // process exited 0 having done nothing.
2377
+ function __hiveIsEntry() {
2378
+ if (!process.argv[1]) return false
2379
+ const me = __hiveFileURLToPath(import.meta.url)
2380
+ try { return __hiveRealpath(process.argv[1]) === __hiveRealpath(me) } catch { return __hiveResolve(process.argv[1]) === __hiveResolve(me) }
2381
+ }
2382
+ if (__hiveIsEntry()) {
2383
+ main().then((code) => { if (code !== 0) process.exit(code) })
2384
+ }