Skip to content

Commit 0a13f48

Browse files
authored
bridge: add πŸ‘€ on receive and βœ… on reply emoji reactions (#167)
1 parent e50fcec commit 0a13f48

3 files changed

Lines changed: 124 additions & 1 deletion

File tree

β€Žslack-bridge/bridge.mjsβ€Ž

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,41 @@ const threadLookup = new Map(); // "channel:thread_ts" β†’ thread-N
7474
let threadCounter = 0;
7575
const MAX_THREADS = 10_000;
7676

77+
// Track inbound message timestamps pending a βœ… reaction.
78+
// Key: "channel:thread_ts" (the thread root), Value: { channel, messageTs, receivedAt }
79+
// When the agent replies via /send with a matching thread_ts, we react with βœ…
80+
// on the original inbound message and remove the entry.
81+
const pendingAckReactions = new Map();
82+
const PENDING_ACK_TTL_MS = 10 * 60 * 1000; // 10 minutes
83+
84+
/**
85+
* When the agent sends a reply in a thread, resolve the pending ack by
86+
* adding a βœ… reaction to the original inbound message and removing the entry.
87+
* Also prunes expired entries.
88+
*/
89+
function resolveAckReaction(channel, threadTs) {
90+
const now = Date.now();
91+
for (const [key, entry] of pendingAckReactions) {
92+
if (now - entry.receivedAt > PENDING_ACK_TTL_MS) {
93+
pendingAckReactions.delete(key);
94+
}
95+
}
96+
97+
const threadKey = `${channel}:${threadTs}`;
98+
const pending = pendingAckReactions.get(threadKey);
99+
if (!pending) return;
100+
101+
pendingAckReactions.delete(threadKey);
102+
app.client.reactions.add({
103+
token: process.env.SLACK_BOT_TOKEN,
104+
channel: pending.channel,
105+
timestamp: pending.messageTs,
106+
name: "white_check_mark",
107+
}).catch((err) => {
108+
console.warn(`βœ… check reaction failed: ${err.message}`);
109+
});
110+
}
111+
77112
/**
78113
* Evict the oldest entries when the registry exceeds MAX_THREADS.
79114
* Maps iterate in insertion order, so the first entries are the oldest.
@@ -259,6 +294,24 @@ async function handleMessage(userMessage, event, say) {
259294

260295
console.log(`πŸ’¬ from <@${event.user}>: ${userMessage}`);
261296

297+
// React with πŸ‘€ immediately so the user knows we saw their message.
298+
app.client.reactions.add({
299+
token: process.env.SLACK_BOT_TOKEN,
300+
channel: event.channel,
301+
timestamp: event.ts,
302+
name: "eyes",
303+
}).catch((err) => {
304+
console.warn(`πŸ‘€ eyes reaction failed: ${err.message}`);
305+
});
306+
307+
// Track this message so we can add βœ… when the agent replies.
308+
const threadKey = `${event.channel}:${event.thread_ts || event.ts}`;
309+
pendingAckReactions.set(threadKey, {
310+
channel: event.channel,
311+
messageTs: event.ts,
312+
receivedAt: Date.now(),
313+
});
314+
262315
try {
263316
// Always re-resolve the socket before sending (handles agent restarts).
264317
// Capture into a local to avoid TOCTOU with concurrent handleMessage calls.
@@ -422,6 +475,12 @@ function startApiServer() {
422475
});
423476

424477
console.log(`πŸ“€ Sent to ${channel}: ${text.slice(0, 80)}${text.length > 80 ? "..." : ""}`);
478+
479+
// If this is a threaded reply, check for a pending βœ… ack reaction.
480+
if (thread_ts) {
481+
resolveAckReaction(channel, thread_ts);
482+
}
483+
425484
res.writeHead(200, { "Content-Type": "application/json" });
426485
res.end(JSON.stringify({ ok: true, ts: result.ts, channel: result.channel }));
427486

@@ -460,6 +519,10 @@ function startApiServer() {
460519
});
461520

462521
console.log(`πŸ“€ Reply to ${thread_id} (${thread.channel}): ${text.slice(0, 80)}${text.length > 80 ? "..." : ""}`);
522+
523+
// Check for a pending βœ… ack reaction on the /reply path too.
524+
resolveAckReaction(thread.channel, thread.thread_ts);
525+
463526
res.writeHead(200, { "Content-Type": "application/json" });
464527
res.end(JSON.stringify({ ok: true, ts: result.ts, channel: result.channel }));
465528

β€Žslack-bridge/broker-bridge.mjsβ€Ž

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,37 @@ const threadLookup = new Map();
149149
let threadCounter = 0;
150150
const MAX_THREADS = 10_000;
151151

152+
// Track inbound message timestamps pending a βœ… reaction.
153+
// Key: "channel:thread_ts" (the thread root), Value: { channel, messageTs, receivedAt }
154+
// When the agent replies via /send with a matching thread_ts, we react with βœ…
155+
// on the original inbound message and remove the entry.
156+
const pendingAckReactions = new Map();
157+
const PENDING_ACK_TTL_MS = 10 * 60 * 1000; // 10 minutes
158+
159+
/**
160+
* When the agent sends a reply in a thread, resolve the pending ack by
161+
* adding a βœ… reaction to the original inbound message and removing the entry.
162+
* Also prunes expired entries.
163+
*/
164+
function resolveAckReaction(channel, threadTs) {
165+
const now = Date.now();
166+
// Prune expired entries while we're here
167+
for (const [key, entry] of pendingAckReactions) {
168+
if (now - entry.receivedAt > PENDING_ACK_TTL_MS) {
169+
pendingAckReactions.delete(key);
170+
}
171+
}
172+
173+
const threadKey = `${channel}:${threadTs}`;
174+
const pending = pendingAckReactions.get(threadKey);
175+
if (!pending) return;
176+
177+
pendingAckReactions.delete(threadKey);
178+
_react(pending.channel, pending.messageTs, "white_check_mark").catch((err) => {
179+
logWarn(`βœ… check reaction failed: ${err.message}`);
180+
});
181+
}
182+
152183
let socketPath = null;
153184

154185
let cryptoState = null;
@@ -695,6 +726,21 @@ async function handleUserMessage(userMessage, event) {
695726
logWarn(`⚠️ Suspicious patterns from <@${event.user}>: ${suspicious.join(", ")}`);
696727
}
697728

729+
// React with πŸ‘€ immediately so the user knows we saw their message.
730+
const ackChannel = event.channel;
731+
const ackMessageTs = event.ts;
732+
_react(ackChannel, ackMessageTs, "eyes").catch((err) => {
733+
logWarn(`πŸ‘€ eyes reaction failed: ${err.message}`);
734+
});
735+
736+
// Track this message so we can add βœ… when the agent replies.
737+
const threadKey = `${ackChannel}:${event.thread_ts || ackMessageTs}`;
738+
pendingAckReactions.set(threadKey, {
739+
channel: ackChannel,
740+
messageTs: ackMessageTs,
741+
receivedAt: Date.now(),
742+
});
743+
698744
refreshSocket();
699745
const currentSocket = socketPath;
700746
if (!currentSocket) {
@@ -988,6 +1034,11 @@ function startApiServer() {
9881034
actionRequestBody: { text: safeText },
9891035
});
9901036

1037+
// If this is a threaded reply, check for a pending βœ… ack reaction.
1038+
if (thread_ts) {
1039+
resolveAckReaction(channel, thread_ts);
1040+
}
1041+
9911042
res.writeHead(200, { "Content-Type": "application/json" });
9921043
res.end(JSON.stringify({ ok: true, ts: result.ts }));
9931044
return;
@@ -1020,6 +1071,9 @@ function startApiServer() {
10201071
actionRequestBody: { text: safeText },
10211072
});
10221073

1074+
// Check for a pending βœ… ack reaction on the /reply path too.
1075+
resolveAckReaction(thread.channel, thread.thread_ts);
1076+
10231077
res.writeHead(200, { "Content-Type": "application/json" });
10241078
res.end(JSON.stringify({ ok: true, ts: result.ts }));
10251079
return;

β€Žtest/broker-bridge.integration.test.mjsβ€Ž

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,13 @@ describe("broker pull bridge semi-integration", () => {
493493
expect(receivedCommands.some((cmd) => cmd.type === "get_message")).toBe(false);
494494

495495
expect(sendPayloads.some((payload) => payload.action === "chat.postMessage")).toBe(false);
496-
expect(sendPayloads.some((payload) => payload.action === "reactions.add")).toBe(false);
496+
497+
// Bridge now sends an πŸ‘€ reaction on inbound messages (fire-and-forget)
498+
const reactionPayloads = sendPayloads.filter((payload) => payload.action === "reactions.add");
499+
expect(reactionPayloads.length).toBe(1);
500+
expect(reactionPayloads[0].routing.channel).toBe("C123");
501+
expect(reactionPayloads[0].routing.timestamp).toBe("1730000000.000100");
502+
expect(reactionPayloads[0].routing.emoji).toBe("eyes");
497503
});
498504

499505
it("uses protocol-versioned inbox.pull signatures with wait_seconds by default", async () => {

0 commit comments

Comments
Β (0)