@adaptic/maestro 1.10.7 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/cadence/launchd-cloud-relay-wrapper.sh +95 -0
- package/scripts/cloud-relay/README.md +59 -0
- package/scripts/cloud-relay/index.mjs +233 -0
- package/scripts/cloud-relay/package.json +15 -0
- package/scripts/cloud-relay/railway.json +13 -0
- package/scripts/poller/slack-cloud-relay-client.mjs +326 -0
- package/scripts/slack-responded.sh +45 -1
- package/scripts/slack-send.sh +19 -0
package/package.json
CHANGED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# launchd-socket-mode-wrapper.sh — Bootstraps env for slack-cloud-relay-client.mjs
|
|
3
|
+
# under launchd.
|
|
4
|
+
#
|
|
5
|
+
# Mirrors launchd-wrapper.sh exactly: launchd's bare env doesn't include
|
|
6
|
+
# HOME, PATH, or AGENT_ROOT, so we hydrate them before exec'ing the
|
|
7
|
+
# Socket Mode listener. Logs land on the external SSD when available
|
|
8
|
+
# (same fallback semantics as the main daemon wrapper).
|
|
9
|
+
#
|
|
10
|
+
# This wrapper is exec'd by ai.adaptic.{firstname}-slack-cloud-relay.plist.
|
|
11
|
+
|
|
12
|
+
set -e
|
|
13
|
+
|
|
14
|
+
AGENT_ROOT="$(cd "$(dirname "$0")/../.." && pwd -P)"
|
|
15
|
+
export AGENT_ROOT
|
|
16
|
+
export HOME="${HOME:-/Users/$(whoami)}"
|
|
17
|
+
export USER="${USER:-$(whoami)}"
|
|
18
|
+
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
|
|
19
|
+
|
|
20
|
+
# ── SSD redirect ────────────────────────────────────────────────────────────
|
|
21
|
+
# If an external SSD is mounted at /Volumes/{name}, redirect:
|
|
22
|
+
# - Claude Code per-cwd temp (CLAUDE_CODE_TMPDIR)
|
|
23
|
+
# - Listener stdout/stderr (via shell redirection at exec time)
|
|
24
|
+
#
|
|
25
|
+
# Detection mirrors launchd-wrapper.sh — first volume under /Volumes that's
|
|
26
|
+
# not a system mount; MAESTRO_SSD_VOLUME env var overrides if multiple SSDs.
|
|
27
|
+
|
|
28
|
+
SSD_VOLUME="${MAESTRO_SSD_VOLUME:-}"
|
|
29
|
+
if [ -z "$SSD_VOLUME" ]; then
|
|
30
|
+
for v in /Volumes/*-SSD /Volumes/*SSD* /Volumes/maestro-data; do
|
|
31
|
+
if [ -d "$v" ] && [ "$v" != "/Volumes/Macintosh HD" ]; then
|
|
32
|
+
SSD_VOLUME="$v"
|
|
33
|
+
break
|
|
34
|
+
fi
|
|
35
|
+
done
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
AGENT_NAME="$(basename "$AGENT_ROOT" | sed 's/-ai$//')"
|
|
39
|
+
SSD_AGENT_ROOT=""
|
|
40
|
+
SSD_WRITABLE=0
|
|
41
|
+
if [ -n "$SSD_VOLUME" ] && [ -d "$SSD_VOLUME" ]; then
|
|
42
|
+
SSD_AGENT_ROOT="$SSD_VOLUME/maestro/$AGENT_NAME"
|
|
43
|
+
if mkdir -p "$SSD_AGENT_ROOT/claude-tmp" "$SSD_AGENT_ROOT/logs/slack-cloud-relay" 2>/dev/null && \
|
|
44
|
+
touch "$SSD_AGENT_ROOT/.write-test-$$" 2>/dev/null; then
|
|
45
|
+
rm -f "$SSD_AGENT_ROOT/.write-test-$$"
|
|
46
|
+
SSD_WRITABLE=1
|
|
47
|
+
export CLAUDE_CODE_TMPDIR="$SSD_AGENT_ROOT/claude-tmp"
|
|
48
|
+
fi
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
cd "$AGENT_ROOT"
|
|
52
|
+
|
|
53
|
+
# Resolve node binary — prefer nvm, fall back to homebrew, then system.
|
|
54
|
+
NODE_BIN=""
|
|
55
|
+
for candidate in \
|
|
56
|
+
"$HOME/.nvm/versions/node/v24.11.1/bin/node" \
|
|
57
|
+
"$HOME/.nvm/versions/node/v24/bin/node" \
|
|
58
|
+
"$HOME/.nvm/versions/node/v22/bin/node" \
|
|
59
|
+
"$HOME/.nvm/versions/node/v20/bin/node" \
|
|
60
|
+
/opt/homebrew/bin/node \
|
|
61
|
+
/usr/local/bin/node \
|
|
62
|
+
/usr/bin/node; do
|
|
63
|
+
if [ -x "$candidate" ]; then
|
|
64
|
+
NODE_BIN="$candidate"
|
|
65
|
+
break
|
|
66
|
+
fi
|
|
67
|
+
done
|
|
68
|
+
if [ -z "$NODE_BIN" ] && [ -d "$HOME/.nvm/versions/node" ]; then
|
|
69
|
+
NODE_BIN=$(ls -1d "$HOME/.nvm/versions/node"/v*/bin/node 2>/dev/null | sort -V | tail -1)
|
|
70
|
+
fi
|
|
71
|
+
if [ -z "$NODE_BIN" ] || [ ! -x "$NODE_BIN" ]; then
|
|
72
|
+
echo "[slack-cloud-relay-wrapper] FATAL: could not find node binary" >&2
|
|
73
|
+
exit 127
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
# Node 22.4+ is required for the global WebSocket. Warn (don't fail) on
|
|
77
|
+
# older versions — the user might have polyfilled via `--experimental-websocket`
|
|
78
|
+
# or installed the `ws` package as a fallback.
|
|
79
|
+
NODE_VERSION="$("$NODE_BIN" --version 2>/dev/null || echo 'v0.0.0')"
|
|
80
|
+
NODE_MAJOR="$(echo "$NODE_VERSION" | sed -E 's/^v([0-9]+).*/\1/')"
|
|
81
|
+
if [ "$NODE_MAJOR" -lt 22 ] 2>/dev/null; then
|
|
82
|
+
echo "[slack-cloud-relay-wrapper] WARNING: Node $NODE_VERSION is older than v22 — global WebSocket may be missing." >&2
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
# Exec the listener. Prefer SSD log path if writable, otherwise fall back
|
|
86
|
+
# to internal disk so the listener stays up even when macOS denies launchd
|
|
87
|
+
# write access to /Volumes/{name}.
|
|
88
|
+
if [ "$SSD_WRITABLE" = "1" ]; then
|
|
89
|
+
LISTENER_LOG="$SSD_AGENT_ROOT/logs/slack-cloud-relay/listener-$(date +%Y-%m-%d).log"
|
|
90
|
+
exec "$NODE_BIN" "$AGENT_ROOT/scripts/poller/slack-cloud-relay-client.mjs" >> "$LISTENER_LOG" 2>&1
|
|
91
|
+
else
|
|
92
|
+
LISTENER_LOG="$AGENT_ROOT/logs/polling/slack-cloud-relay-$(date +%Y-%m-%d).log"
|
|
93
|
+
mkdir -p "$(dirname "$LISTENER_LOG")" 2>/dev/null || true
|
|
94
|
+
exec "$NODE_BIN" "$AGENT_ROOT/scripts/poller/slack-cloud-relay-client.mjs" >> "$LISTENER_LOG" 2>&1
|
|
95
|
+
fi
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Cloud Relay — Slack Events to Mac mini
|
|
2
|
+
|
|
3
|
+
Public HTTPS service that proxies Slack Events API webhooks into a
|
|
4
|
+
Mac-mini-resident maestro daemon over Server-Sent Events.
|
|
5
|
+
|
|
6
|
+
## Why
|
|
7
|
+
|
|
8
|
+
Slack Socket Mode only delivers events for the **bot user** that owns the
|
|
9
|
+
app. DMs sent to the agent's underlying **user account** (e.g.,
|
|
10
|
+
`U099N1JE0LA` for Ravi) are invisible to Socket Mode — the bot is not a
|
|
11
|
+
member of those conversations. To get real-time push for those DMs we
|
|
12
|
+
need user-scope event subscriptions, which require an HTTPS endpoint, and
|
|
13
|
+
Mac minis don't have public IPs. This relay closes the gap.
|
|
14
|
+
|
|
15
|
+
## Architecture
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
Slack ──HTTPS POST──▶ Railway (this relay) ──SSE──▶ Mac mini (slack-cloud-relay-client.mjs)
|
|
19
|
+
│ │
|
|
20
|
+
│ └──▶ writeInboxItem() → state/inbox/slack/
|
|
21
|
+
│
|
|
22
|
+
└── verifies x-slack-signature, fans events to all SSE clients
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Deploy
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
cd scripts/cloud-relay
|
|
29
|
+
railway init
|
|
30
|
+
railway up
|
|
31
|
+
railway variables set \
|
|
32
|
+
SLACK_SIGNING_SECRET=<from Slack app Basic Information> \
|
|
33
|
+
RELAY_SHARED_SECRET=<long random string, share with Mac mini>
|
|
34
|
+
railway domain # → returns https://<project>.up.railway.app
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Then in the Slack app config:
|
|
38
|
+
1. Event Subscriptions → Enable Events → set Request URL to
|
|
39
|
+
`https://<project>.up.railway.app/slack/events`.
|
|
40
|
+
2. Subscribe to **User events** (`message.im`, `message.mpim`,
|
|
41
|
+
`message.channels`, `message.groups`, `app_mention`).
|
|
42
|
+
3. Save changes and reinstall the app.
|
|
43
|
+
|
|
44
|
+
Set the Mac mini's `.env`:
|
|
45
|
+
```
|
|
46
|
+
CLOUD_RELAY_URL=https://<project>.up.railway.app
|
|
47
|
+
RELAY_SHARED_SECRET=<same string as on Railway>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Endpoints
|
|
51
|
+
|
|
52
|
+
- `POST /slack/events` — Slack Events API. Verifies `x-slack-signature`
|
|
53
|
+
using `SLACK_SIGNING_SECRET`; responds to `url_verification` challenges;
|
|
54
|
+
fans event callbacks out to connected SSE clients.
|
|
55
|
+
- `GET /events/stream` — SSE stream. Requires
|
|
56
|
+
`Authorization: Bearer <RELAY_SHARED_SECRET>`. New connections receive
|
|
57
|
+
the last 100 events as catch-up; heartbeats every 25 s keep the stream
|
|
58
|
+
alive through proxies.
|
|
59
|
+
- `GET /health` — Liveness probe (returns connection + queue counts).
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maestro — Slack Events Cloud Relay
|
|
3
|
+
*
|
|
4
|
+
* Public HTTPS service (deployed on Railway) that bridges Slack user-scope
|
|
5
|
+
* Events API webhooks into a Mac-mini-resident maestro daemon over SSE.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: Socket Mode is bot-scoped, so DMs sent to the agent's
|
|
8
|
+
* USER account (not its bot user) are invisible to a Socket Mode listener.
|
|
9
|
+
* To get real-time push for those DMs, the Slack app needs user-scope event
|
|
10
|
+
* subscriptions, which require an HTTPS webhook URL — and Mac minis don't
|
|
11
|
+
* have public IPs. This relay sits in front: Slack POSTs events here, the
|
|
12
|
+
* Mac mini holds an authenticated SSE connection, and events get pushed
|
|
13
|
+
* back over the open stream in milliseconds.
|
|
14
|
+
*
|
|
15
|
+
* Endpoints:
|
|
16
|
+
* POST /slack/events — Slack Events API receiver. Verifies the v0
|
|
17
|
+
* HMAC signature, handles url_verification on
|
|
18
|
+
* registration, fans event_callback envelopes
|
|
19
|
+
* out to every connected SSE client.
|
|
20
|
+
* GET /events/stream — Server-Sent Events feed for Mac-mini clients
|
|
21
|
+
* holding an open connection. Bearer-token
|
|
22
|
+
* authenticated against RELAY_SHARED_SECRET.
|
|
23
|
+
* New connections receive the last ~100 events
|
|
24
|
+
* so a brief disconnect doesn't drop messages.
|
|
25
|
+
* GET /health — Liveness probe used by Railway / external
|
|
26
|
+
* uptime monitors.
|
|
27
|
+
*
|
|
28
|
+
* Env (all required):
|
|
29
|
+
* PORT — provided by Railway
|
|
30
|
+
* SLACK_SIGNING_SECRET — from Slack app "Basic Information" page
|
|
31
|
+
* RELAY_SHARED_SECRET — operator-chosen long random string; the Mac
|
|
32
|
+
* mini client must send the same value in its
|
|
33
|
+
* Authorization: Bearer <...> header
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import http from "node:http";
|
|
37
|
+
import crypto from "node:crypto";
|
|
38
|
+
|
|
39
|
+
const PORT = parseInt(process.env.PORT || "3100", 10);
|
|
40
|
+
const SIGNING_SECRET = process.env.SLACK_SIGNING_SECRET || "";
|
|
41
|
+
const RELAY_SHARED_SECRET = process.env.RELAY_SHARED_SECRET || "";
|
|
42
|
+
|
|
43
|
+
if (!SIGNING_SECRET) {
|
|
44
|
+
console.error("FATAL: SLACK_SIGNING_SECRET env var is required");
|
|
45
|
+
process.exit(78);
|
|
46
|
+
}
|
|
47
|
+
if (!RELAY_SHARED_SECRET) {
|
|
48
|
+
console.error("FATAL: RELAY_SHARED_SECRET env var is required");
|
|
49
|
+
process.exit(78);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Connected Mac-mini SSE clients (typically 1, but designed for fan-out).
|
|
53
|
+
const sseClients = new Set();
|
|
54
|
+
|
|
55
|
+
// Bounded ring buffer of recent events for reconnect replay. Slack-side
|
|
56
|
+
// "retry on 5xx" already gives us at-least-once delivery, so this is just
|
|
57
|
+
// for the case where the Mac mini's SSE connection drops between an event
|
|
58
|
+
// arriving and being acked: when it reconnects within ~5 minutes it gets
|
|
59
|
+
// the missing events replayed instead of waiting for Slack's next retry.
|
|
60
|
+
const REPLAY_CAPACITY = 100;
|
|
61
|
+
const eventQueue = [];
|
|
62
|
+
|
|
63
|
+
function log(level, message, data) {
|
|
64
|
+
const ts = new Date().toISOString();
|
|
65
|
+
const line = JSON.stringify({ ts, level, message, ...(data || {}) });
|
|
66
|
+
if (level === "error") console.error(line);
|
|
67
|
+
else console.log(line);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Verify Slack's v0 HMAC signature. Per Slack docs:
|
|
72
|
+
* sig_basestring = "v0:" + timestamp + ":" + raw_body
|
|
73
|
+
* expected = "v0=" + hex(hmac_sha256(SIGNING_SECRET, sig_basestring))
|
|
74
|
+
* Also enforce the 5-minute timestamp window to block replay attacks.
|
|
75
|
+
*/
|
|
76
|
+
function verifySlackSignature(timestamp, body, signature) {
|
|
77
|
+
if (!timestamp || !signature) return false;
|
|
78
|
+
const tsNum = parseInt(timestamp, 10);
|
|
79
|
+
if (!Number.isFinite(tsNum)) return false;
|
|
80
|
+
const ageSec = Math.abs(Date.now() / 1000 - tsNum);
|
|
81
|
+
if (ageSec > 5 * 60) return false;
|
|
82
|
+
const basestring = `v0:${timestamp}:${body}`;
|
|
83
|
+
const expected = `v0=${crypto.createHmac("sha256", SIGNING_SECRET).update(basestring).digest("hex")}`;
|
|
84
|
+
if (expected.length !== signature.length) return false;
|
|
85
|
+
try {
|
|
86
|
+
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function fanOut(envelope) {
|
|
93
|
+
eventQueue.push(envelope);
|
|
94
|
+
if (eventQueue.length > REPLAY_CAPACITY) eventQueue.shift();
|
|
95
|
+
const data = `data: ${JSON.stringify(envelope)}\n\n`;
|
|
96
|
+
const dead = [];
|
|
97
|
+
for (const client of sseClients) {
|
|
98
|
+
try {
|
|
99
|
+
client.write(data);
|
|
100
|
+
} catch {
|
|
101
|
+
dead.push(client);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
for (const c of dead) sseClients.delete(c);
|
|
105
|
+
log("info", "fanned-out", { sse_clients: sseClients.size, queue_size: eventQueue.length });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const server = http.createServer((req, res) => {
|
|
109
|
+
// Health probe
|
|
110
|
+
if (req.method === "GET" && req.url === "/health") {
|
|
111
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
112
|
+
res.end(JSON.stringify({
|
|
113
|
+
ok: true,
|
|
114
|
+
sse_clients: sseClients.size,
|
|
115
|
+
queue_size: eventQueue.length,
|
|
116
|
+
uptime_s: Math.round(process.uptime()),
|
|
117
|
+
}));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Slack Events API endpoint
|
|
122
|
+
if (req.method === "POST" && req.url === "/slack/events") {
|
|
123
|
+
let body = "";
|
|
124
|
+
req.on("data", (chunk) => { body += chunk; });
|
|
125
|
+
req.on("end", () => {
|
|
126
|
+
const ts = req.headers["x-slack-request-timestamp"];
|
|
127
|
+
const sig = req.headers["x-slack-signature"];
|
|
128
|
+
|
|
129
|
+
if (!verifySlackSignature(ts, body, sig)) {
|
|
130
|
+
log("warn", "rejected-bad-signature", { ts_header: !!ts });
|
|
131
|
+
res.writeHead(401, { "Content-Type": "text/plain" });
|
|
132
|
+
res.end("invalid signature");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let payload;
|
|
137
|
+
try {
|
|
138
|
+
payload = JSON.parse(body);
|
|
139
|
+
} catch {
|
|
140
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
141
|
+
res.end("bad json");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// One-time URL verification when the operator first registers the
|
|
146
|
+
// webhook in the Slack app config. Slack expects an echo of the
|
|
147
|
+
// `challenge` field within 3 seconds.
|
|
148
|
+
if (payload.type === "url_verification") {
|
|
149
|
+
log("info", "url-verification-challenge", { challenge_len: (payload.challenge || "").length });
|
|
150
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
151
|
+
res.end(JSON.stringify({ challenge: payload.challenge }));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Event callback. Slack requires a 200 within 3 seconds or it
|
|
156
|
+
// retries, so ack immediately and fan out after.
|
|
157
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
158
|
+
res.end("ok");
|
|
159
|
+
if (payload.type === "event_callback") {
|
|
160
|
+
fanOut(payload);
|
|
161
|
+
} else {
|
|
162
|
+
log("info", "ignored-envelope-type", { type: payload.type });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
req.on("error", (err) => {
|
|
166
|
+
log("error", "request-error", { error: err.message });
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// SSE stream to Mac mini
|
|
172
|
+
if (req.method === "GET" && req.url === "/events/stream") {
|
|
173
|
+
const auth = req.headers.authorization || "";
|
|
174
|
+
const want = `Bearer ${RELAY_SHARED_SECRET}`;
|
|
175
|
+
if (auth.length !== want.length || !crypto.timingSafeEqual(Buffer.from(auth), Buffer.from(want))) {
|
|
176
|
+
res.writeHead(401, { "Content-Type": "text/plain" });
|
|
177
|
+
res.end("unauthorized");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
res.writeHead(200, {
|
|
182
|
+
"Content-Type": "text/event-stream",
|
|
183
|
+
"Cache-Control": "no-cache, no-transform",
|
|
184
|
+
"Connection": "keep-alive",
|
|
185
|
+
"X-Accel-Buffering": "no",
|
|
186
|
+
});
|
|
187
|
+
res.write(": connected\n\n");
|
|
188
|
+
sseClients.add(res);
|
|
189
|
+
log("info", "sse-client-connected", { sse_clients: sseClients.size, remote: req.socket.remoteAddress });
|
|
190
|
+
|
|
191
|
+
// Replay the recent backlog so a brief disconnect doesn't drop events.
|
|
192
|
+
for (const ev of eventQueue) {
|
|
193
|
+
try { res.write(`data: ${JSON.stringify(ev)}\n\n`); } catch { /* */ }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Heartbeat — many proxies idle-timeout long-lived streams; a comment
|
|
197
|
+
// line every 25s keeps the connection visibly alive without polluting
|
|
198
|
+
// the event payloads.
|
|
199
|
+
const hb = setInterval(() => {
|
|
200
|
+
try {
|
|
201
|
+
res.write(": hb\n\n");
|
|
202
|
+
} catch {
|
|
203
|
+
clearInterval(hb);
|
|
204
|
+
}
|
|
205
|
+
}, 25_000);
|
|
206
|
+
|
|
207
|
+
req.on("close", () => {
|
|
208
|
+
sseClients.delete(res);
|
|
209
|
+
clearInterval(hb);
|
|
210
|
+
log("info", "sse-client-disconnected", { sse_clients: sseClients.size });
|
|
211
|
+
});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
216
|
+
res.end("not found");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
server.listen(PORT, () => {
|
|
220
|
+
log("info", "cloud-relay-listening", { port: PORT });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Graceful shutdown on SIGTERM (Railway sends this on redeploy / scale-down).
|
|
224
|
+
const shutdown = (signal) => {
|
|
225
|
+
log("info", "shutdown-requested", { signal });
|
|
226
|
+
for (const client of sseClients) {
|
|
227
|
+
try { client.end(); } catch { /* */ }
|
|
228
|
+
}
|
|
229
|
+
server.close(() => process.exit(0));
|
|
230
|
+
setTimeout(() => process.exit(0), 5_000).unref();
|
|
231
|
+
};
|
|
232
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
233
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@adaptic/maestro-cloud-relay",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Public HTTPS relay that proxies Slack Events API into a Mac-mini-resident maestro daemon over SSE.",
|
|
7
|
+
"main": "index.mjs",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "node index.mjs"
|
|
10
|
+
},
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=20"
|
|
13
|
+
},
|
|
14
|
+
"license": "UNLICENSED"
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://railway.com/railway.schema.json",
|
|
3
|
+
"build": {
|
|
4
|
+
"builder": "NIXPACKS"
|
|
5
|
+
},
|
|
6
|
+
"deploy": {
|
|
7
|
+
"startCommand": "node index.mjs",
|
|
8
|
+
"healthcheckPath": "/health",
|
|
9
|
+
"healthcheckTimeout": 100,
|
|
10
|
+
"restartPolicyType": "ON_FAILURE",
|
|
11
|
+
"restartPolicyMaxRetries": 10
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maestro — Slack Cloud-Relay Client
|
|
3
|
+
*
|
|
4
|
+
* Persistent SSE consumer that connects to the Railway-hosted cloud-relay
|
|
5
|
+
* (scripts/cloud-relay/) and turns the Slack user-scope event stream into
|
|
6
|
+
* inbox YAML files via writeInboxItem. Drop-in replacement for the
|
|
7
|
+
* bot-scoped Socket Mode listener when the agent's primary message
|
|
8
|
+
* surface is the agent's USER account rather than a bot user.
|
|
9
|
+
*
|
|
10
|
+
* Lifecycle:
|
|
11
|
+
* 1. Honour .emergency-stop before any I/O.
|
|
12
|
+
* 2. Open an HTTPS SSE connection to CLOUD_RELAY_URL/events/stream with
|
|
13
|
+
* a Bearer header containing RELAY_SHARED_SECRET.
|
|
14
|
+
* 3. Parse `data:` lines as event_callback envelopes; reuse the
|
|
15
|
+
* shouldKeepEvent + eventToInboxItem helpers from slack-socket-mode
|
|
16
|
+
* so the inbox YAML shape is identical to what Socket Mode produces.
|
|
17
|
+
* 4. On disconnect, reconnect with exponential back-off + jitter.
|
|
18
|
+
*
|
|
19
|
+
* Env (read from .env):
|
|
20
|
+
* CLOUD_RELAY_URL — Railway service base URL (https://...)
|
|
21
|
+
* RELAY_SHARED_SECRET — auth bearer; must match Railway env var
|
|
22
|
+
* SLACK_BOT_TOKEN — used by shouldKeepEvent for own/peer
|
|
23
|
+
* filtering (no requests made on this path)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { config as loadEnv } from "dotenv";
|
|
27
|
+
import { join, dirname, resolve } from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
import { existsSync, readFileSync, appendFileSync, mkdirSync } from "node:fs";
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
shouldKeepEvent,
|
|
33
|
+
eventToInboxItem,
|
|
34
|
+
} from "./slack-socket-mode.mjs";
|
|
35
|
+
import { writeInboxItem } from "./utils.mjs";
|
|
36
|
+
|
|
37
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
const AGENT_REPO_DIR = process.env.AGENT_DIR || resolve(__dirname, "../..");
|
|
39
|
+
loadEnv({ path: join(AGENT_REPO_DIR, ".env") });
|
|
40
|
+
|
|
41
|
+
const CLOUD_RELAY_URL = (process.env.CLOUD_RELAY_URL || "").replace(/\/$/, "");
|
|
42
|
+
const RELAY_SHARED_SECRET = process.env.RELAY_SHARED_SECRET || "";
|
|
43
|
+
|
|
44
|
+
const RECONNECT_INITIAL_MS = 2_000;
|
|
45
|
+
const RECONNECT_MAX_MS = 60_000;
|
|
46
|
+
const HEARTBEAT_GRACE_MS = 90_000; // SSE heartbeat is 25s — be generous before declaring dead
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Identity loading (mirrors slack-socket-mode)
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
function loadAgent() {
|
|
53
|
+
const path = join(AGENT_REPO_DIR, "config", "agent.json");
|
|
54
|
+
if (!existsSync(path)) return { firstName: "Agent" };
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
57
|
+
} catch {
|
|
58
|
+
return { firstName: "Agent" };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function loadPeerSlackIds() {
|
|
63
|
+
// Read agents/index.json if present; otherwise empty set (lone agent).
|
|
64
|
+
const peers = new Set();
|
|
65
|
+
const idxPath = join(AGENT_REPO_DIR, "agents", "index.json");
|
|
66
|
+
if (!existsSync(idxPath)) return peers;
|
|
67
|
+
try {
|
|
68
|
+
const idx = JSON.parse(readFileSync(idxPath, "utf-8"));
|
|
69
|
+
for (const a of idx.agents || []) {
|
|
70
|
+
if (a.slackMemberId) peers.add(a.slackMemberId);
|
|
71
|
+
}
|
|
72
|
+
} catch { /* */ }
|
|
73
|
+
return peers;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Logging
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
const LOG_DIR = join(AGENT_REPO_DIR, "logs", "polling");
|
|
81
|
+
try { mkdirSync(LOG_DIR, { recursive: true }); } catch { /* */ }
|
|
82
|
+
|
|
83
|
+
function log(level, message, data) {
|
|
84
|
+
const ts = new Date().toISOString();
|
|
85
|
+
const line = { ts, level, message, ...(data || {}) };
|
|
86
|
+
if (level === "error") console.error(`[slack-cloud-relay] ${message}`, data || "");
|
|
87
|
+
else console.log(`[slack-cloud-relay] ${message}`, data || "");
|
|
88
|
+
try {
|
|
89
|
+
appendFileSync(
|
|
90
|
+
join(LOG_DIR, `${ts.slice(0, 10)}-slack-cloud-relay.jsonl`),
|
|
91
|
+
JSON.stringify(line) + "\n",
|
|
92
|
+
);
|
|
93
|
+
} catch { /* best-effort */ }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// SSE parser — robust to multi-line `data:` and `:` comment heartbeats
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Push raw bytes from the relay into a stateful parser. Yields complete
|
|
102
|
+
* event payloads (the JSON in each `data:` block) as strings. Per the SSE
|
|
103
|
+
* spec, events are terminated by a blank line; `data:` may appear on
|
|
104
|
+
* multiple lines and concatenate with newlines, `:` lines are comments.
|
|
105
|
+
*/
|
|
106
|
+
function createSseParser() {
|
|
107
|
+
let buffer = "";
|
|
108
|
+
let dataLines = [];
|
|
109
|
+
return {
|
|
110
|
+
push(chunk) {
|
|
111
|
+
buffer += chunk;
|
|
112
|
+
const out = [];
|
|
113
|
+
let nlIdx;
|
|
114
|
+
while ((nlIdx = buffer.indexOf("\n")) >= 0) {
|
|
115
|
+
const line = buffer.slice(0, nlIdx).replace(/\r$/, "");
|
|
116
|
+
buffer = buffer.slice(nlIdx + 1);
|
|
117
|
+
if (line === "") {
|
|
118
|
+
// End of event — flush
|
|
119
|
+
if (dataLines.length > 0) {
|
|
120
|
+
out.push(dataLines.join("\n"));
|
|
121
|
+
dataLines = [];
|
|
122
|
+
}
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (line.startsWith(":")) continue; // comment (heartbeat)
|
|
126
|
+
if (line.startsWith("data:")) {
|
|
127
|
+
dataLines.push(line.slice(5).replace(/^ /, ""));
|
|
128
|
+
}
|
|
129
|
+
// We ignore "event:" / "id:" / "retry:" lines — the relay
|
|
130
|
+
// doesn't emit them.
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Exponential back-off (mirrors slack-socket-mode)
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
function nextBackoffMs(currentMs) {
|
|
142
|
+
const doubled = Math.min(RECONNECT_MAX_MS, Math.max(RECONNECT_INITIAL_MS, currentMs * 2));
|
|
143
|
+
const jitter = Math.floor(Math.random() * 0.25 * doubled);
|
|
144
|
+
return Math.min(RECONNECT_MAX_MS, doubled + jitter);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Main loop
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
let stopped = false;
|
|
152
|
+
let backoffMs = RECONNECT_INITIAL_MS;
|
|
153
|
+
|
|
154
|
+
async function connect(identity, peerSlackIds) {
|
|
155
|
+
if (stopped) return;
|
|
156
|
+
if (existsSync(join(AGENT_REPO_DIR, ".emergency-stop"))) {
|
|
157
|
+
log("info", ".emergency-stop present — refusing to connect");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (!CLOUD_RELAY_URL || !RELAY_SHARED_SECRET) {
|
|
161
|
+
log("error", "CLOUD_RELAY_URL or RELAY_SHARED_SECRET not set — cannot connect");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const url = `${CLOUD_RELAY_URL}/events/stream`;
|
|
166
|
+
log("info", "connecting", { url });
|
|
167
|
+
|
|
168
|
+
let response;
|
|
169
|
+
try {
|
|
170
|
+
response = await fetch(url, {
|
|
171
|
+
headers: {
|
|
172
|
+
Authorization: `Bearer ${RELAY_SHARED_SECRET}`,
|
|
173
|
+
Accept: "text/event-stream",
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
} catch (err) {
|
|
177
|
+
log("error", `connect failed: ${err.message}`);
|
|
178
|
+
scheduleReconnect(identity, peerSlackIds);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!response.ok) {
|
|
183
|
+
log("error", `relay rejected: HTTP ${response.status}`);
|
|
184
|
+
scheduleReconnect(identity, peerSlackIds);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
log("info", "connected");
|
|
189
|
+
backoffMs = RECONNECT_INITIAL_MS; // reset on a successful connect
|
|
190
|
+
|
|
191
|
+
const reader = response.body.getReader();
|
|
192
|
+
const decoder = new TextDecoder();
|
|
193
|
+
const parser = createSseParser();
|
|
194
|
+
let lastByteAt = Date.now();
|
|
195
|
+
|
|
196
|
+
// Heartbeat watchdog — if we haven't seen ANY bytes in HEARTBEAT_GRACE_MS,
|
|
197
|
+
// declare the connection dead and reconnect. Relay sends `: hb\n\n` every
|
|
198
|
+
// 25s, so a 90s grace catches both true silence and a proxy that froze.
|
|
199
|
+
const watchdog = setInterval(() => {
|
|
200
|
+
if (Date.now() - lastByteAt > HEARTBEAT_GRACE_MS) {
|
|
201
|
+
log("warn", "watchdog tripped — no bytes from relay", {
|
|
202
|
+
silent_ms: Date.now() - lastByteAt,
|
|
203
|
+
});
|
|
204
|
+
clearInterval(watchdog);
|
|
205
|
+
try { reader.cancel(); } catch { /* */ }
|
|
206
|
+
}
|
|
207
|
+
}, 30_000);
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
while (true) {
|
|
211
|
+
const { done, value } = await reader.read();
|
|
212
|
+
if (done) {
|
|
213
|
+
log("info", "stream closed by relay");
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
lastByteAt = Date.now();
|
|
217
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
218
|
+
const events = parser.push(chunk);
|
|
219
|
+
for (const raw of events) {
|
|
220
|
+
try {
|
|
221
|
+
const webhook = JSON.parse(raw);
|
|
222
|
+
handleWebhook(webhook, identity, peerSlackIds);
|
|
223
|
+
} catch (err) {
|
|
224
|
+
log("warn", `bad event JSON: ${err.message}`, { sample: raw.slice(0, 200) });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} catch (err) {
|
|
229
|
+
log("warn", `stream error: ${err.message}`);
|
|
230
|
+
} finally {
|
|
231
|
+
clearInterval(watchdog);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
scheduleReconnect(identity, peerSlackIds);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function scheduleReconnect(identity, peerSlackIds) {
|
|
238
|
+
if (stopped) return;
|
|
239
|
+
const delay = backoffMs;
|
|
240
|
+
backoffMs = nextBackoffMs(backoffMs);
|
|
241
|
+
log("info", "reconnect scheduled", { delay_ms: delay, next_backoff_ms: backoffMs });
|
|
242
|
+
setTimeout(() => connect(identity, peerSlackIds), delay);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Translate a Slack Events API webhook envelope into the shape that
|
|
247
|
+
* eventToInboxItem expects (Socket Mode-style `{payload: {event: ...}}`)
|
|
248
|
+
* then write it to the inbox.
|
|
249
|
+
*
|
|
250
|
+
* Events API payload shape:
|
|
251
|
+
* { token, team_id, api_app_id, type: "event_callback", event: {...}, event_id, event_time }
|
|
252
|
+
*
|
|
253
|
+
* Socket Mode payload shape that eventToInboxItem reads:
|
|
254
|
+
* envelope.payload.event
|
|
255
|
+
*
|
|
256
|
+
* We adapt by wrapping: { type: "events_api", payload: webhook }.
|
|
257
|
+
*/
|
|
258
|
+
function handleWebhook(webhook, identity, peerSlackIds) {
|
|
259
|
+
if (!webhook || webhook.type !== "event_callback") {
|
|
260
|
+
log("info", "ignored-non-event-callback", { type: webhook?.type });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const envelope = { type: "events_api", payload: webhook };
|
|
265
|
+
const decision = shouldKeepEvent(envelope, {
|
|
266
|
+
ownSlackId: identity.slackMemberId,
|
|
267
|
+
peerSlackIds,
|
|
268
|
+
});
|
|
269
|
+
if (!decision.keep) {
|
|
270
|
+
log("info", "event-dropped", { reason: decision.reason, event_id: webhook.event_id });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let item;
|
|
275
|
+
try {
|
|
276
|
+
item = eventToInboxItem(envelope, {
|
|
277
|
+
ownSlackId: identity.slackMemberId,
|
|
278
|
+
principalSlackId: identity.principalSlackId,
|
|
279
|
+
agentFirstName: identity.firstName,
|
|
280
|
+
});
|
|
281
|
+
} catch (err) {
|
|
282
|
+
log("error", `translation failed: ${err.message}`);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
// Stamp the source so audit can tell relay-delivered events apart from
|
|
286
|
+
// poller- or socket-mode-delivered ones.
|
|
287
|
+
item.source = "cloud-relay";
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
writeInboxItem("slack", item);
|
|
291
|
+
log("info", "inbox-item-written", {
|
|
292
|
+
id: item.id,
|
|
293
|
+
channel: item.channel,
|
|
294
|
+
sender: item.sender,
|
|
295
|
+
event_id: webhook.event_id,
|
|
296
|
+
});
|
|
297
|
+
} catch (err) {
|
|
298
|
+
log("error", `writeInboxItem failed: ${err.message}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// Bootstrap
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
const identity = loadAgent();
|
|
307
|
+
const peerSlackIds = loadPeerSlackIds();
|
|
308
|
+
|
|
309
|
+
const shutdown = (signal, code) => {
|
|
310
|
+
log("info", `received ${signal}, shutting down (exit ${code})`);
|
|
311
|
+
stopped = true;
|
|
312
|
+
// Match the slack-socket-mode pattern: SIGTERM signals a respawn from
|
|
313
|
+
// launchd (exit 143), SIGINT is human-initiated stop (exit 0).
|
|
314
|
+
setTimeout(() => process.exit(code), 250);
|
|
315
|
+
};
|
|
316
|
+
process.on("SIGTERM", () => shutdown("SIGTERM", 143));
|
|
317
|
+
process.on("SIGINT", () => shutdown("SIGINT", 0));
|
|
318
|
+
process.on("unhandledRejection", (reason) => {
|
|
319
|
+
log("error", `unhandled rejection: ${reason?.message || reason}`);
|
|
320
|
+
});
|
|
321
|
+
process.on("uncaughtException", (err) => {
|
|
322
|
+
log("error", `uncaught exception: ${err.message}`);
|
|
323
|
+
setTimeout(() => process.exit(1), 250);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
connect(identity, peerSlackIds);
|
|
@@ -159,6 +159,49 @@ EOF
|
|
|
159
159
|
exit 1 # Not yet handled
|
|
160
160
|
;;
|
|
161
161
|
|
|
162
|
+
release)
|
|
163
|
+
# release <channel_id> <message_ts> [session_id]
|
|
164
|
+
# Releases an unconfirmed lock so a legitimate retry isn't blocked by a
|
|
165
|
+
# 24h TTL. Called by slack-send.sh's exit trap when a send fails or the
|
|
166
|
+
# script crashes after acquiring the lock but before confirming the send.
|
|
167
|
+
#
|
|
168
|
+
# Safety:
|
|
169
|
+
# - Refuse to release if the lock has been confirmed (real send happened)
|
|
170
|
+
# - If session_id is provided, only release locks owned by that session
|
|
171
|
+
# (prevents a buggy caller from blowing away a sibling's in-flight lock)
|
|
172
|
+
#
|
|
173
|
+
# Lock metadata is stored in `meta` (acquire/confirm both write to it)
|
|
174
|
+
# using the format produced by the acquire/confirm sections above:
|
|
175
|
+
# session: "<id>"
|
|
176
|
+
# status: acquired (or confirmed)
|
|
177
|
+
CHANNEL="${2:?Channel ID required}"
|
|
178
|
+
MSG_TS="${3:?Message TS required}"
|
|
179
|
+
SESSION_ID="${4:-unknown}"
|
|
180
|
+
|
|
181
|
+
LOCK_DIR="$LOCK_BASE_DIR/${CHANNEL}-${MSG_TS}"
|
|
182
|
+
META="$LOCK_DIR/meta"
|
|
183
|
+
|
|
184
|
+
if [ ! -d "$LOCK_DIR" ]; then
|
|
185
|
+
echo "NOT_HELD"
|
|
186
|
+
exit 0
|
|
187
|
+
fi
|
|
188
|
+
|
|
189
|
+
if [ -f "$META" ] && grep -qE "^status:[[:space:]]*confirmed" "$META" 2>/dev/null; then
|
|
190
|
+
echo "ALREADY_CONFIRMED"
|
|
191
|
+
exit 0
|
|
192
|
+
fi
|
|
193
|
+
|
|
194
|
+
if [ -f "$META" ] && [ "$SESSION_ID" != "unknown" ]; then
|
|
195
|
+
if ! grep -qE "^session:[[:space:]]*\"?${SESSION_ID}\"?[[:space:]]*$" "$META" 2>/dev/null; then
|
|
196
|
+
echo "NOT_OWNED"
|
|
197
|
+
exit 0
|
|
198
|
+
fi
|
|
199
|
+
fi
|
|
200
|
+
|
|
201
|
+
rm -rf "$LOCK_DIR"
|
|
202
|
+
echo "RELEASED"
|
|
203
|
+
;;
|
|
204
|
+
|
|
162
205
|
mark)
|
|
163
206
|
# mark <message_ts> — backward-compatible mark
|
|
164
207
|
MSG_TS="${2:?Message TS required}"
|
|
@@ -175,11 +218,12 @@ EOF
|
|
|
175
218
|
;;
|
|
176
219
|
|
|
177
220
|
*)
|
|
178
|
-
echo "Usage: slack-responded.sh {acquire|confirm|check|mark|clean} [args...]"
|
|
221
|
+
echo "Usage: slack-responded.sh {acquire|confirm|release|check|mark|clean} [args...]"
|
|
179
222
|
echo ""
|
|
180
223
|
echo "Commands:"
|
|
181
224
|
echo " acquire <channel> <msg_ts> [session_id] — Atomically claim a response lock"
|
|
182
225
|
echo " confirm <channel> <msg_ts> [preview] — Record successful send"
|
|
226
|
+
echo " release <channel> <msg_ts> [session_id] — Release unconfirmed lock (send failed)"
|
|
183
227
|
echo " check <msg_ts> [channel] — Check if already handled"
|
|
184
228
|
echo " mark <msg_ts> — Legacy: mark as handled"
|
|
185
229
|
echo " clean — Purge old entries"
|
package/scripts/slack-send.sh
CHANGED
|
@@ -118,6 +118,21 @@ if [ -n "$RESPONDING_TO" ]; then
|
|
|
118
118
|
exit 0
|
|
119
119
|
fi
|
|
120
120
|
DEDUP_ACQUIRED="1"
|
|
121
|
+
|
|
122
|
+
# Lock-release safety net. If this script exits BEFORE confirming the
|
|
123
|
+
# lock (e.g. validation failed, Slack API errored, python script crashed
|
|
124
|
+
# during message conversion, caller killed us), we must release the lock
|
|
125
|
+
# so the next legitimate retry isn't blocked for 24 hours. The trap is
|
|
126
|
+
# cleared after the post-send confirm; if the confirm runs, we leave
|
|
127
|
+
# the lock in place (it's now a real send marker, not a placeholder).
|
|
128
|
+
cleanup_unconfirmed_lock() {
|
|
129
|
+
local exit_code=$?
|
|
130
|
+
if [ -n "$DEDUP_ACQUIRED" ] && [ -z "$DEDUP_CONFIRMED" ]; then
|
|
131
|
+
"$SCRIPT_DIR/slack-responded.sh" release "$CHANNEL" "$RESPONDING_TO" "$SESSION_ID" 2>/dev/null || true
|
|
132
|
+
fi
|
|
133
|
+
return "$exit_code"
|
|
134
|
+
}
|
|
135
|
+
trap cleanup_unconfirmed_lock EXIT
|
|
121
136
|
fi
|
|
122
137
|
|
|
123
138
|
# Show typing indicator before sending (non-blocking, fire-and-forget)
|
|
@@ -230,7 +245,11 @@ if [ "$STATUS" = "invalid_auth" ] || [ "$STATUS" = "token_revoked" ] || [ "$STAT
|
|
|
230
245
|
fi
|
|
231
246
|
|
|
232
247
|
# Post-send: confirm the response lock with message details (audit trail)
|
|
248
|
+
# and mark the trap as fulfilled so the exit-handler does NOT release the
|
|
249
|
+
# now-legitimate confirmation marker. Any non-OK status leaves DEDUP_CONFIRMED
|
|
250
|
+
# unset, so the EXIT trap releases the lock for legitimate retry.
|
|
233
251
|
if [ "$STATUS" = "OK" ] && [ -n "$RESPONDING_TO" ] && [ -n "$DEDUP_ACQUIRED" ]; then
|
|
234
252
|
PREVIEW=$(echo "$MESSAGE" | head -c 200)
|
|
235
253
|
"$SCRIPT_DIR/slack-responded.sh" confirm "$CHANNEL" "$RESPONDING_TO" "$PREVIEW" 2>/dev/null || true
|
|
254
|
+
DEDUP_CONFIRMED="1"
|
|
236
255
|
fi
|