Skip to content

Commit d4cc583

Browse files
author
Baudbot
committed
bridge: add thread-aware reply routing with friendly thread IDs
- Add in-memory thread registry that assigns deterministic friendly IDs (thread-1, thread-2, ...) to each unique channel+thread_ts pair - Enrich forwarded messages with Thread-ID line after security wrapper so the agent can reference threads without raw timestamps - Add POST /reply endpoint that accepts {thread_id, text} and looks up the channel+thread_ts from the registry to post replies - Update API docs and startup console output with /reply endpoint - No changes to security.mjs (protected file)
1 parent fc892c5 commit d4cc583

1 file changed

Lines changed: 79 additions & 3 deletions

File tree

slack-bridge/bridge.mjs

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,31 @@ console.log(`🔒 Access control: ${ALLOWED_USERS.length} allowed user(s)`);
6868
const slackRateLimiter = createRateLimiter({ maxRequests: 5, windowMs: 60_000 });
6969
const apiRateLimiter = createRateLimiter({ maxRequests: 30, windowMs: 60_000 });
7070

71+
// ── Thread Registry ─────────────────────────────────────────────────────────
72+
// Maps friendly thread IDs (e.g. "thread-1") to { channel, thread_ts }.
73+
// Deterministic: same channel+thread_ts always maps to the same ID.
74+
75+
const threadRegistry = new Map(); // thread-N → { channel, thread_ts }
76+
const threadLookup = new Map(); // "channel:thread_ts" → thread-N
77+
let threadCounter = 0;
78+
79+
/**
80+
* Get or create a friendly thread ID for a channel + thread_ts pair.
81+
* Returns the thread ID string (e.g. "thread-3").
82+
*/
83+
function getThreadId(channel, threadTs) {
84+
const key = `${channel}:${threadTs}`;
85+
let id = threadLookup.get(key);
86+
if (!id) {
87+
threadCounter++;
88+
id = `thread-${threadCounter}`;
89+
threadRegistry.set(id, { channel, thread_ts: threadTs });
90+
threadLookup.set(key, id);
91+
console.log(`🧵 Registered ${id} → channel=${channel} thread_ts=${threadTs}`);
92+
}
93+
return id;
94+
}
95+
7196
// ── Session Socket ──────────────────────────────────────────────────────────
7297

7398
function findSessionSocket(targetId) {
@@ -248,14 +273,18 @@ async function handleMessage(userMessage, event, say) {
248273
}
249274

250275
// Wrap the message with security boundaries before sending to agent
251-
const contextMessage = wrapExternalContent({
276+
const wrappedMessage = wrapExternalContent({
252277
text: userMessage,
253278
source: "Slack",
254279
user: event.user,
255280
channel: event.channel,
256281
threadTs: event.ts,
257282
});
258283

284+
// Enrich with friendly thread ID so the agent can use /reply endpoint
285+
const threadId = getThreadId(event.channel, event.ts);
286+
const contextMessage = `${wrappedMessage}\nThread-ID: ${threadId}`;
287+
259288
const reply = await enqueue(() => sendToAgent(currentSocket, contextMessage));
260289
const formatted = formatForSlack(reply);
261290
await say({ text: formatted, thread_ts: event.ts });
@@ -305,13 +334,18 @@ app.event("message", async ({ event, say }) => {
305334
const text = event.text?.trim();
306335
if (!text) return;
307336
// Don't filter bot_id here — Sentry posts as a bot
308-
const contextMessage = wrapExternalContent({
337+
const wrappedSentryMessage = wrapExternalContent({
309338
text,
310339
source: "Slack (#bots-sentry)",
311340
user: event.user || event.bot_id || "sentry-bot",
312341
channel: event.channel,
313342
threadTs: event.ts,
314343
});
344+
345+
// Enrich with friendly thread ID
346+
const sentryThreadId = getThreadId(event.channel, event.ts);
347+
const contextMessage = `${wrappedSentryMessage}\nThread-ID: ${sentryThreadId}`;
348+
315349
try {
316350
// Re-resolve socket before sending (capture local to avoid TOCTOU)
317351
refreshSocket();
@@ -344,6 +378,9 @@ app.event("message", async ({ event, say }) => {
344378
// POST http://localhost:7890/send
345379
// { "channel": "C07...", "text": "hello", "thread_ts": "1234.5678" }
346380
//
381+
// POST http://localhost:7890/reply
382+
// { "thread_id": "thread-1", "text": "hello" }
383+
//
347384
// POST http://localhost:7890/react
348385
// { "channel": "C07...", "timestamp": "1234.5678", "emoji": "white_check_mark" }
349386

@@ -408,6 +445,44 @@ function startApiServer() {
408445
res.writeHead(200, { "Content-Type": "application/json" });
409446
res.end(JSON.stringify({ ok: true, ts: result.ts, channel: result.channel }));
410447

448+
} else if (pathname === "/reply") {
449+
// Look up thread by friendly ID and post a reply
450+
const { thread_id, text } = params;
451+
452+
if (typeof thread_id !== "string" || !thread_id) {
453+
res.writeHead(400, { "Content-Type": "application/json" });
454+
res.end(JSON.stringify({ error: "thread_id must be a non-empty string" }));
455+
return;
456+
}
457+
if (typeof text !== "string" || text.length === 0) {
458+
res.writeHead(400, { "Content-Type": "application/json" });
459+
res.end(JSON.stringify({ error: "text must be a non-empty string" }));
460+
return;
461+
}
462+
if (text.length > 4000) {
463+
res.writeHead(400, { "Content-Type": "application/json" });
464+
res.end(JSON.stringify({ error: "text too long (max 4000)" }));
465+
return;
466+
}
467+
468+
const thread = threadRegistry.get(thread_id);
469+
if (!thread) {
470+
res.writeHead(404, { "Content-Type": "application/json" });
471+
res.end(JSON.stringify({ error: `Unknown thread_id: ${thread_id}` }));
472+
return;
473+
}
474+
475+
const result = await app.client.chat.postMessage({
476+
token: process.env.SLACK_BOT_TOKEN,
477+
channel: thread.channel,
478+
text,
479+
thread_ts: thread.thread_ts,
480+
});
481+
482+
console.log(`📤 Reply to ${thread_id} (${thread.channel}): ${text.slice(0, 80)}${text.length > 80 ? "..." : ""}`);
483+
res.writeHead(200, { "Content-Type": "application/json" });
484+
res.end(JSON.stringify({ ok: true, ts: result.ts, channel: result.channel }));
485+
411486
} else if (pathname === "/react") {
412487
const validationError = validateReactParams(params);
413488
if (validationError) {
@@ -429,7 +504,7 @@ function startApiServer() {
429504

430505
} else {
431506
res.writeHead(404, { "Content-Type": "application/json" });
432-
res.end(JSON.stringify({ error: "Not found. Endpoints: POST /send, POST /react" }));
507+
res.end(JSON.stringify({ error: "Not found. Endpoints: POST /send, POST /reply, POST /react" }));
433508
}
434509
} catch (err) {
435510
console.error("API error:", err.message);
@@ -441,6 +516,7 @@ function startApiServer() {
441516
server.listen(API_PORT, "127.0.0.1", () => {
442517
console.log(`📡 Outbound API listening on http://127.0.0.1:${API_PORT}`);
443518
console.log(` POST /send {"channel":"C...","text":"...","thread_ts":"..."}`);
519+
console.log(` POST /reply {"thread_id":"thread-1","text":"..."}`);
444520
console.log(` POST /react {"channel":"C...","timestamp":"...","emoji":"..."}`);
445521
});
446522
}

0 commit comments

Comments
 (0)