hivecode-mcp 0.14.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/SLACK_SETUP.md +24 -0
- package/hive-github.js +2002 -1944
- package/hive-mcp-http.js +12 -1
- package/hive-mcp.js +2129 -2093
- package/hive-mux.js +2 -1
- package/hive-relay-health.js +5 -1
- package/hive-room-key.js +2 -1
- package/hive-setup.js +4 -2
- package/hive-slack-agent.js +14 -2
- package/hive-slack.js +595 -37
- package/hive-transport.js +234 -9
- package/package.json +192 -192
- package/reconcile.cjs +17 -10
- package/sync.js +5031 -4994
package/hive-slack.js
CHANGED
|
@@ -25,9 +25,15 @@
|
|
|
25
25
|
// Q635 multiple concurrent Socket Mode processes on same app token
|
|
26
26
|
// Q636 Slack API unreachable (network, DNS, timeout; token not rejected)
|
|
27
27
|
|
|
28
|
+
import fs from 'fs'
|
|
29
|
+
import os from 'os'
|
|
30
|
+
import path from 'path'
|
|
28
31
|
import WebSocket from 'ws'
|
|
29
32
|
import { startSync, parseLink } from './sync.js'
|
|
30
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'
|
|
31
37
|
|
|
32
38
|
const DEFAULT_API = 'https://slack.com/api'
|
|
33
39
|
const ROOM_THREAD_KEY = '__room__'
|
|
@@ -35,12 +41,20 @@ const CURSOR_KEY = '__cursor__'
|
|
|
35
41
|
const MAX_SEEN_EVENTS = 500
|
|
36
42
|
|
|
37
43
|
export const USAGE = `usage:
|
|
38
|
-
SLACK_BOT_TOKEN=xoxb-… npx -y hivecode-mcp@latest slack start "<relay|room>" --channel <id> [--name SlackBridge] [--allow <U123,U456>] [--dry-run] [--start-retry <ms>]
|
|
39
|
-
SLACK_BOT_TOKEN=xoxb-… [SLACK_APP_TOKEN=xapp-…] npx -y hivecode-mcp@latest slack check --channel <id> [--agent] [--timeout <ms>] [--start-retry <ms>]
|
|
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>]
|
|
40
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>]
|
|
41
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]
|
|
42
48
|
`
|
|
43
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
|
+
|
|
44
58
|
export const NON_RETRYABLE = new Set([
|
|
45
59
|
'invalid_auth',
|
|
46
60
|
'token_revoked',
|
|
@@ -493,6 +507,10 @@ export async function connectSocketMode({
|
|
|
493
507
|
startRetryMs = Number(process.env.HIVE_SLACK_START_RETRY_MS) || 60000,
|
|
494
508
|
startRetryDelayMs = 1000,
|
|
495
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,
|
|
496
514
|
} = {}) {
|
|
497
515
|
let ws = null
|
|
498
516
|
let stopped = false
|
|
@@ -810,6 +828,15 @@ export async function connectSocketMode({
|
|
|
810
828
|
try { sock.close() } catch { }
|
|
811
829
|
try { sock.terminate && sock.terminate() } catch { }
|
|
812
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
|
+
}
|
|
813
840
|
|
|
814
841
|
const elapsed = Date.now() - startTime
|
|
815
842
|
if (elapsed < startRetryMs) {
|
|
@@ -865,6 +892,68 @@ export function resumeAll(hive, by = 'slack', skipName = '') {
|
|
|
865
892
|
return done
|
|
866
893
|
}
|
|
867
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
|
+
|
|
868
957
|
/** Find the room's real spelling of a participant name (case-insensitive). */
|
|
869
958
|
export function resolveName(hive, who) {
|
|
870
959
|
const want = String(who || '').toLowerCase()
|
|
@@ -1060,6 +1149,8 @@ export function makeBridge({
|
|
|
1060
1149
|
botUserId = '',
|
|
1061
1150
|
lookupUser = null,
|
|
1062
1151
|
minGapMs = 1000,
|
|
1152
|
+
controlConfirmMs = 1500,
|
|
1153
|
+
controlLateMs = 30000,
|
|
1063
1154
|
now = () => Date.now(),
|
|
1064
1155
|
userCacheTtlMs = USER_CACHE_TTL_MS,
|
|
1065
1156
|
api = process.env.HIVE_SLACK_API || DEFAULT_API,
|
|
@@ -1164,6 +1255,25 @@ export function makeBridge({
|
|
|
1164
1255
|
}
|
|
1165
1256
|
}
|
|
1166
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
|
+
|
|
1167
1277
|
/** Handle an inbound Socket Mode event (`message` or `agent_session_stopped`).
|
|
1168
1278
|
* Asymmetric authorization via makeSlackActorCheck:
|
|
1169
1279
|
* - Guests (`is_restricted` / `is_ultra_restricted`) and Slack Connect users from other workspaces
|
|
@@ -1174,7 +1284,7 @@ export function makeBridge({
|
|
|
1174
1284
|
* - RESUME and thread replies into the room require `ev.user` to be in `--allow U123,U456`. */
|
|
1175
1285
|
async function onInboundEvent(ev) {
|
|
1176
1286
|
if (!ev) return { ok: false, ignored: 'empty' }
|
|
1177
|
-
if (ev
|
|
1287
|
+
if (notFromAPerson(ev) || (botUserId && ev.user === botUserId)) {
|
|
1178
1288
|
return { ok: false, ignored: 'bot_or_subtype' }
|
|
1179
1289
|
}
|
|
1180
1290
|
if (ev.channel && channel && ev.channel !== channel) {
|
|
@@ -1200,14 +1310,12 @@ export function makeBridge({
|
|
|
1200
1310
|
const unverifiedNote = unverifiedStop ? ` (${check.reply})` : ''
|
|
1201
1311
|
|
|
1202
1312
|
if (ev.type === 'agent_session_stopped' || (r && r.intent === 'stop-all')) {
|
|
1203
|
-
const who =
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
)
|
|
1210
|
-
return { ok: true, action: 'stop-all', stopped: who, by: sender, unverified: unverifiedStop && who.length > 0 }
|
|
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 }
|
|
1211
1319
|
}
|
|
1212
1320
|
const real = resolveName(hive, r.who)
|
|
1213
1321
|
if (!real || real === botName) {
|
|
@@ -1215,7 +1323,10 @@ export function makeBridge({
|
|
|
1215
1323
|
await replyInThread(replyTs, `:no_entry: I do not see *${r.who}* in this room. Here right now: ${here}.`)
|
|
1216
1324
|
return { ok: false, refused: 'unknown_agent' }
|
|
1217
1325
|
}
|
|
1218
|
-
|
|
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
|
+
}
|
|
1219
1330
|
await replyInThread(replyTs, `:octagonal_sign: *${sender}* paused *${real}*.${unverifiedNote}`)
|
|
1220
1331
|
return { ok: true, action: 'stop', who: real, by: sender, unverified: unverifiedStop }
|
|
1221
1332
|
}
|
|
@@ -1237,12 +1348,12 @@ export function makeBridge({
|
|
|
1237
1348
|
}
|
|
1238
1349
|
const sender = check.by
|
|
1239
1350
|
if (r.intent === 'resume-all') {
|
|
1240
|
-
const who =
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
)
|
|
1245
|
-
return { ok:
|
|
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 }
|
|
1246
1357
|
}
|
|
1247
1358
|
const real = resolveName(hive, r.who)
|
|
1248
1359
|
if (!real || real === botName) {
|
|
@@ -1250,7 +1361,10 @@ export function makeBridge({
|
|
|
1250
1361
|
await replyInThread(replyTs, `:no_entry: I do not see *${r.who}* in this room. Here right now: ${here}.`)
|
|
1251
1362
|
return { ok: false, refused: 'unknown_agent' }
|
|
1252
1363
|
}
|
|
1253
|
-
|
|
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
|
+
}
|
|
1254
1368
|
await replyInThread(replyTs, `:arrow_forward: *${sender}* resumed *${real}*.`)
|
|
1255
1369
|
return { ok: true, action: 'resume', who: real, by: sender }
|
|
1256
1370
|
}
|
|
@@ -1316,8 +1430,330 @@ export function makeBridge({
|
|
|
1316
1430
|
}
|
|
1317
1431
|
|
|
1318
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
|
+
}
|
|
1319
1472
|
|
|
1320
|
-
|
|
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
|
+
}
|
|
1321
1757
|
}
|
|
1322
1758
|
|
|
1323
1759
|
export async function runSlackCheck({
|
|
@@ -1334,6 +1770,8 @@ export async function runSlackCheck({
|
|
|
1334
1770
|
WebSocketImpl = WebSocket,
|
|
1335
1771
|
log = console.log,
|
|
1336
1772
|
logErr = console.error,
|
|
1773
|
+
inbound = 'auto',
|
|
1774
|
+
pollMs = INBOUND_DEFAULT_POLL_MS,
|
|
1337
1775
|
} = {}) {
|
|
1338
1776
|
if (!token || !String(token).trim()) {
|
|
1339
1777
|
logErr('Q630 SLACK_BOT_TOKEN is missing. Set SLACK_BOT_TOKEN=xoxb-… in your environment.')
|
|
@@ -1421,7 +1859,7 @@ export async function runSlackCheck({
|
|
|
1421
1859
|
logErr(`Q630 SLACK_BOT_TOKEN is missing required outbound scope(s): ${outMissing.join(', ')}. Reinstall your Slack app from slack-app-manifest.json.`)
|
|
1422
1860
|
return 1
|
|
1423
1861
|
}
|
|
1424
|
-
if (appToken) {
|
|
1862
|
+
if (appToken || inbound === 'poll') {
|
|
1425
1863
|
const inCheck = checkInboundScopes({ scopes, isPrivate, channel })
|
|
1426
1864
|
if (!inCheck.ok) {
|
|
1427
1865
|
logErr(inCheck.error)
|
|
@@ -1434,7 +1872,7 @@ export async function runSlackCheck({
|
|
|
1434
1872
|
}
|
|
1435
1873
|
|
|
1436
1874
|
// Outbound-only mode (when SLACK_APP_TOKEN is not set): verify outbound post + thread reply, never claim inbound replies work
|
|
1437
|
-
if (!appToken || !String(appToken).trim()) {
|
|
1875
|
+
if ((!appToken || !String(appToken).trim()) && inbound !== 'poll') {
|
|
1438
1876
|
log(`This posts one message in #${channelName} (and one reply in its thread)`)
|
|
1439
1877
|
try {
|
|
1440
1878
|
const rootPost = await slackPost('chat.postMessage', { channel, text: 'Hivecode check: outbound posting works.' }, token, fetchImpl, api)
|
|
@@ -1450,7 +1888,14 @@ export async function runSlackCheck({
|
|
|
1450
1888
|
}
|
|
1451
1889
|
|
|
1452
1890
|
// 5. Socket Mode connect (must receive Slack's `hello` frame within helloTimeoutMs BEFORE we post the prompt)
|
|
1453
|
-
|
|
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 })
|
|
1454
1899
|
if (!sm.ok) {
|
|
1455
1900
|
logErr(sm.error)
|
|
1456
1901
|
return 1
|
|
@@ -1472,7 +1917,7 @@ export async function runSlackCheck({
|
|
|
1472
1917
|
let currentUrl = sm.url
|
|
1473
1918
|
const startTime = Date.now()
|
|
1474
1919
|
|
|
1475
|
-
while (
|
|
1920
|
+
while (!pollInbound) {
|
|
1476
1921
|
if (!currentUrl) {
|
|
1477
1922
|
try {
|
|
1478
1923
|
const res = await slackPost('apps.connections.open', {}, appToken, fetchImpl, api)
|
|
@@ -1528,8 +1973,7 @@ export async function runSlackCheck({
|
|
|
1528
1973
|
if (
|
|
1529
1974
|
ev &&
|
|
1530
1975
|
(ev.type === 'message' || (agent && ev.type === 'agent_session_stopped')) &&
|
|
1531
|
-
!ev
|
|
1532
|
-
!ev.subtype &&
|
|
1976
|
+
!notFromAPerson(ev) &&
|
|
1533
1977
|
ev.user &&
|
|
1534
1978
|
ev.user !== botUserId &&
|
|
1535
1979
|
(!ev.channel || ev.channel === channel) &&
|
|
@@ -1541,9 +1985,18 @@ export async function runSlackCheck({
|
|
|
1541
1985
|
})
|
|
1542
1986
|
})
|
|
1543
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
|
+
}
|
|
1544
1995
|
if (res.timedOut || res.closed || res.error) {
|
|
1545
1996
|
closeSock()
|
|
1546
|
-
logErr(
|
|
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.')
|
|
1547
2000
|
return 1
|
|
1548
2001
|
}
|
|
1549
2002
|
|
|
@@ -1566,7 +2019,27 @@ export async function runSlackCheck({
|
|
|
1566
2019
|
return 1
|
|
1567
2020
|
}
|
|
1568
2021
|
|
|
1569
|
-
log('✓ Socket Mode: connected')
|
|
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
|
+
}
|
|
1570
2043
|
|
|
1571
2044
|
// 6. Real proof: post prompt and wait up to timeoutMs for any non-bot reply in that thread
|
|
1572
2045
|
log(`This posts one message in #${channelName} (and one reply in its thread)`)
|
|
@@ -1624,7 +2097,7 @@ export async function runSlackCheck({
|
|
|
1624
2097
|
}
|
|
1625
2098
|
|
|
1626
2099
|
const arrived = await Promise.race([
|
|
1627
|
-
replyPromise,
|
|
2100
|
+
pollInbound ? pollForReply() : replyPromise,
|
|
1628
2101
|
new Promise((resolve) => { waitTimer = setTimeout(() => resolve(false), timeoutMs) }),
|
|
1629
2102
|
])
|
|
1630
2103
|
clearTimeout(waitTimer)
|
|
@@ -1674,9 +2147,13 @@ export async function main(argv = process.argv.slice(2), { log = console.log, lo
|
|
|
1674
2147
|
let timeoutMs = Number(env.HIVE_SLACK_CHECK_TIMEOUT_MS) || 120000
|
|
1675
2148
|
let helloTimeoutMs = Number(env.HIVE_SLACK_HELLO_TIMEOUT_MS) || 10000
|
|
1676
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()
|
|
1677
2153
|
for (let i = 0; i < argv.length; i++) {
|
|
1678
2154
|
const a = argv[i]
|
|
1679
2155
|
if ((a === 'start' || a === 'check') && !LINK && i === 0) { mode = a; continue }
|
|
2156
|
+
if (a === '--inbound') { inbound = String(argv[++i] || '').toLowerCase(); continue }
|
|
1680
2157
|
if (a === '--help' || a === '-h') { log(USAGE.trim()); return 0 }
|
|
1681
2158
|
if (a === '--dry-run') { dryRun = true; continue }
|
|
1682
2159
|
if (a === '--agent') { agent = true; continue }
|
|
@@ -1689,14 +2166,21 @@ export async function main(argv = process.argv.slice(2), { log = console.log, lo
|
|
|
1689
2166
|
if (a.startsWith('--')) { logErr(USAGE.trim()); return 2 }
|
|
1690
2167
|
if (!LINK) LINK = a
|
|
1691
2168
|
}
|
|
2169
|
+
if (!['auto', 'poll', 'socket'].includes(inbound)) {
|
|
2170
|
+
logErr(`Q630 --inbound must be auto, poll or socket (got "${inbound}").`)
|
|
2171
|
+
return 2
|
|
2172
|
+
}
|
|
1692
2173
|
const TOKEN = env.SLACK_BOT_TOKEN || ''
|
|
1693
2174
|
const APP_TOKEN = env.SLACK_APP_TOKEN || ''
|
|
2175
|
+
const pollMs = Math.max(50, Number(env.HIVE_SLACK_POLL_MS) || INBOUND_DEFAULT_POLL_MS)
|
|
1694
2176
|
if (mode === 'check') {
|
|
1695
2177
|
return await runSlackCheck({
|
|
1696
2178
|
token: TOKEN,
|
|
1697
2179
|
appToken: APP_TOKEN,
|
|
1698
2180
|
channel: CHANNEL,
|
|
1699
2181
|
agent,
|
|
2182
|
+
inbound,
|
|
2183
|
+
pollMs,
|
|
1700
2184
|
timeoutMs,
|
|
1701
2185
|
helloTimeoutMs,
|
|
1702
2186
|
startRetryMs,
|
|
@@ -1721,7 +2205,14 @@ export async function main(argv = process.argv.slice(2), { log = console.log, lo
|
|
|
1721
2205
|
return 1
|
|
1722
2206
|
}
|
|
1723
2207
|
let sockPre = null
|
|
1724
|
-
if (
|
|
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) {
|
|
1725
2216
|
sockPre = await preflightSocketMode({
|
|
1726
2217
|
appToken: APP_TOKEN,
|
|
1727
2218
|
scopes: pre.scopes,
|
|
@@ -1740,12 +2231,20 @@ export async function main(argv = process.argv.slice(2), { log = console.log, lo
|
|
|
1740
2231
|
return 2
|
|
1741
2232
|
}
|
|
1742
2233
|
if (dryRun) {
|
|
1743
|
-
const mode = sockPre ? 'outbound + inbound Socket Mode' : 'outbound'
|
|
2234
|
+
const mode = inbound === 'poll' ? 'outbound + inbound HTTPS polling' : sockPre ? 'outbound + inbound Socket Mode' : 'outbound'
|
|
1744
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'}.`)
|
|
1745
2236
|
return 0
|
|
1746
2237
|
}
|
|
1747
2238
|
const dir = (env.TEMP || process.env.TEMP || '.') + '/hive-slack-' + room + '-' + process.pid
|
|
1748
|
-
|
|
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: () => {} })
|
|
1749
2248
|
await new Promise((resolve) => {
|
|
1750
2249
|
let done = false
|
|
1751
2250
|
const finish = () => { if (!done) { done = true; resolve() } }
|
|
@@ -1770,8 +2269,53 @@ export async function main(argv = process.argv.slice(2), { log = console.log, lo
|
|
|
1770
2269
|
botUserId: pre.botUserId || '',
|
|
1771
2270
|
fetchImpl,
|
|
1772
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),
|
|
1773
2276
|
})
|
|
1774
|
-
|
|
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) {
|
|
1775
2319
|
try {
|
|
1776
2320
|
await connectSocketMode({
|
|
1777
2321
|
appToken: APP_TOKEN,
|
|
@@ -1781,6 +2325,7 @@ export async function main(argv = process.argv.slice(2), { log = console.log, lo
|
|
|
1781
2325
|
logErr,
|
|
1782
2326
|
helloTimeoutMs,
|
|
1783
2327
|
startRetryMs,
|
|
2328
|
+
failFastOnRefusal: inbound === 'auto',
|
|
1784
2329
|
onEvent: (ev) => bridge.onInboundEvent(ev),
|
|
1785
2330
|
onMultiConnection: (n) => {
|
|
1786
2331
|
bridge.postChannel(
|
|
@@ -1789,9 +2334,14 @@ export async function main(argv = process.argv.slice(2), { log = console.log, lo
|
|
|
1789
2334
|
},
|
|
1790
2335
|
})
|
|
1791
2336
|
} catch (e) {
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
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
|
+
}
|
|
1795
2345
|
}
|
|
1796
2346
|
}
|
|
1797
2347
|
const chat = hive.doc.getArray('chat')
|
|
@@ -1804,6 +2354,14 @@ export async function main(argv = process.argv.slice(2), { log = console.log, lo
|
|
|
1804
2354
|
}
|
|
1805
2355
|
|
|
1806
2356
|
// ---- main -------------------------------------------------------------------------------
|
|
1807
|
-
|
|
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()) {
|
|
1808
2366
|
main().then((code) => { if (code !== 0) process.exit(code) })
|
|
1809
2367
|
}
|