Skip to content

Commit d19502c

Browse files
vltbaudbotbaudbot-agentbenvinegar
authored
feat: add unanswered Slack mention detection to heartbeat (#186)
Co-authored-by: baudbot-agent <baudbot-agent@users.noreply.github.com> Co-authored-by: Ben Vinegar <ben@benv.ca>
1 parent 3da88c5 commit d19502c

4 files changed

Lines changed: 370 additions & 7 deletions

File tree

pi/extensions/heartbeat.test.mjs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,82 @@ function parseTodo(content) {
6363
}
6464
}
6565

66+
function hasReplyLogEntry(replyLogContent, threadTs) {
67+
const lines = replyLogContent.split("\n");
68+
for (const line of lines) {
69+
const trimmed = line.trim();
70+
if (!trimmed) continue;
71+
try {
72+
const entry = JSON.parse(trimmed);
73+
if (entry?.thread_ts === threadTs) return true;
74+
} catch {
75+
// Ignore malformed JSONL lines.
76+
}
77+
}
78+
return false;
79+
}
80+
81+
function hasOutboundSendCommand(sessionJsonlContent, threadTs) {
82+
const escapedThreadTs = threadTs.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
83+
const threadTsPattern = new RegExp(`["']thread_ts["']\\s*:\\s*["']${escapedThreadTs}["']`);
84+
85+
for (const line of sessionJsonlContent.split("\n")) {
86+
const trimmed = line.trim();
87+
if (!trimmed) continue;
88+
89+
let parsed;
90+
try {
91+
parsed = JSON.parse(trimmed);
92+
} catch {
93+
continue;
94+
}
95+
96+
if (parsed?.type !== "message") continue;
97+
if (parsed?.message?.role !== "assistant") continue;
98+
const items = parsed?.message?.content;
99+
if (!Array.isArray(items)) continue;
100+
101+
for (const item of items) {
102+
if (item?.type !== "toolCall") continue;
103+
if (item?.name !== "bash") continue;
104+
const command = typeof item?.arguments?.command === "string" ? item.arguments.command : "";
105+
if (!command.includes("curl")) continue;
106+
if (!command.includes("/send")) continue;
107+
if (!threadTsPattern.test(command)) continue;
108+
return true;
109+
}
110+
}
111+
112+
return false;
113+
}
114+
115+
function slackTsToMs(ts) {
116+
const parsed = Number.parseFloat(ts);
117+
if (!Number.isFinite(parsed) || parsed <= 0) return null;
118+
return Math.floor(parsed * 1000);
119+
}
120+
121+
function extractMentionThreadTs(logTail) {
122+
const mentionThreadTs = new Set();
123+
124+
for (const line of logTail.split("\n")) {
125+
if (!line.includes("app_mention")) continue;
126+
127+
const threadMatch = line.match(/\bthread_ts:\s*(\d+\.\d+)/);
128+
if (threadMatch?.[1]) {
129+
mentionThreadTs.add(threadMatch[1]);
130+
continue;
131+
}
132+
133+
const tsMatch = line.match(/\bts:\s*(\d+\.\d+)/);
134+
if (tsMatch?.[1]) {
135+
mentionThreadTs.add(tsMatch[1]);
136+
}
137+
}
138+
139+
return [...mentionThreadTs];
140+
}
141+
66142
// ── Test helpers ────────────────────────────────────────────────────────────
67143

68144
// ── Tests ───────────────────────────────────────────────────────────────────
@@ -312,6 +388,95 @@ Not part of JSON.`;
312388
});
313389
});
314390

391+
describe("heartbeat v2: unanswered mention log parsing", () => {
392+
it("extracts app_mention ts from broker-bridge log format", () => {
393+
const log =
394+
"[2026-02-28T21:10:00.000Z] 👤 message from <@U123> in C123 (type: app_mention, thread_ts: 1772313000.000001, ts: 1772313000.123456)";
395+
assert.deepEqual(extractMentionThreadTs(log), ["1772313000.000001"]);
396+
});
397+
398+
it("falls back to message ts when thread_ts is absent", () => {
399+
const log = "[2026-02-28T21:10:00.000Z] 👤 message from <@U123> in C123 (type: app_mention, ts: 1772313000.123456)";
400+
assert.deepEqual(extractMentionThreadTs(log), ["1772313000.123456"]);
401+
});
402+
403+
it("extracts app_mention ts from socket-mode bridge log format", () => {
404+
const log = "📣 app_mention from <@U123> in C123 ts: 1772313001.654321";
405+
assert.deepEqual(extractMentionThreadTs(log), ["1772313001.654321"]);
406+
});
407+
408+
it("prefers thread_ts over message ts when both are present", () => {
409+
const log =
410+
"📣 app_mention from <@U123> in C123 thread_ts: 1772313000.000001 ts: 1772313001.654321";
411+
assert.deepEqual(extractMentionThreadTs(log), ["1772313000.000001"]);
412+
});
413+
414+
it("ignores non-app_mention log lines", () => {
415+
const log = [
416+
"💬 from <@U123>: hello",
417+
"[2026-02-28T21:10:00.000Z] 👤 message from <@U123> in C123 (type: message, ts: 1772313000.123456)",
418+
"🧵 Registered thread-1 → channel=C123 thread_ts=1772313000.123456",
419+
].join("\n");
420+
assert.deepEqual(extractMentionThreadTs(log), []);
421+
});
422+
423+
it("converts slack ts to milliseconds", () => {
424+
assert.equal(slackTsToMs("1772313000.123456"), 1772313000123);
425+
assert.equal(slackTsToMs("0"), null);
426+
assert.equal(slackTsToMs("not-a-ts"), null);
427+
});
428+
});
429+
430+
describe("heartbeat v2: unanswered mention reply detection", () => {
431+
it("matches exact thread_ts entries in reply log jsonl", () => {
432+
const log = [
433+
'{"thread_ts":"1234.5678","replied_at":"2026-02-27T00:00:00Z"}',
434+
'{"thread_ts":"2345.6789","replied_at":"2026-02-27T00:05:00Z"}',
435+
].join("\n");
436+
437+
assert.equal(hasReplyLogEntry(log, "1234.5678"), true);
438+
assert.equal(hasReplyLogEntry(log, "9999.0000"), false);
439+
});
440+
441+
it("ignores malformed reply-log lines", () => {
442+
const log = ['{"thread_ts":"1234.5678"}', 'not-json', '{"thread_ts":"2345.6789"}'].join("\n");
443+
assert.equal(hasReplyLogEntry(log, "2345.6789"), true);
444+
});
445+
446+
it("detects outbound curl /send with matching thread_ts", () => {
447+
const session = JSON.stringify({
448+
type: "message",
449+
message: {
450+
role: "assistant",
451+
content: [
452+
{
453+
type: "toolCall",
454+
name: "bash",
455+
arguments: {
456+
command:
457+
"curl -s -X POST http://127.0.0.1:7890/send -H 'Content-Type: application/json' -d '{\"channel\":\"C123\",\"text\":\"hi\",\"thread_ts\":\"1234.5678\"}'",
458+
},
459+
},
460+
],
461+
},
462+
});
463+
464+
assert.equal(hasOutboundSendCommand(session, "1234.5678"), true);
465+
});
466+
467+
it("does not treat inbound text containing thread_ts as a reply", () => {
468+
const inboundOnly = JSON.stringify({
469+
type: "message",
470+
message: {
471+
role: "user",
472+
content: [{ type: "text", text: "inbound event metadata: thread_ts=1234.5678" }],
473+
},
474+
});
475+
476+
assert.equal(hasOutboundSendCommand(inboundOnly, "1234.5678"), false);
477+
});
478+
});
479+
315480
describe("heartbeat v2: hasMatchingInProgressTodo logic", () => {
316481
// Replicate the matching logic from the extension
317482
function matchesWorktree(content, worktreeName) {

0 commit comments

Comments
 (0)