Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 50 additions & 32 deletions src/slack/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,37 @@ export function resolveSessionId(

export const SESSION_ID_RE = /(?::link:\s*`|Session:\s*)([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/;

export function extractSessionIdFromMessage(m: any): string | null {
// 1. Try text field first
const text = (m.text || "").replace(/<@[A-Z0-9]+>/g, "").trim();
const textMatch = text.match(SESSION_ID_RE);
if (textMatch) return textMatch[1] ?? null;

// 2. Try blocks (markdown blocks from Assistant API may not populate text)
if (Array.isArray(m.blocks)) {
for (const block of m.blocks) {
// Direct text/content fields (markdown blocks)
const blockText = block.text ?? block.content ?? "";
const blockMatch = blockText.match(SESSION_ID_RE);
if (blockMatch) return blockMatch[1] ?? null;

// rich_text blocks: Slack may convert markdown blocks to rich_text in conversations.replies
if (block.type === "rich_text" && Array.isArray(block.elements)) {
for (const section of block.elements) {
if (!Array.isArray(section.elements)) continue;
const sectionText = section.elements
.map((elem: any) => (elem.type === "emoji" ? `:${elem.name}:` : (elem.text ?? "")))
.join("");
const sectionMatch = sectionText.match(SESSION_ID_RE);
if (sectionMatch) return sectionMatch[1] ?? null;
}
}
}
}

return null;
}

async function fetchThreadContext(
client: any,
channel: string,
Expand All @@ -92,11 +123,11 @@ async function fetchThreadContext(
const role = isBot ? "assistant" : "user";
const text = (m.text || "").replace(/<@[A-Z0-9]+>/g, "").trim();

// Extract session ID from bot messages
// Extract session ID from bot messages (check text + blocks)
if (isBot) {
const sessionMatch = text.match(SESSION_ID_RE);
if (sessionMatch) {
lastSessionId = sessionMatch[1];
const sessionId = extractSessionIdFromMessage(m);
if (sessionId) {
lastSessionId = sessionId;
}
}

Expand All @@ -105,7 +136,7 @@ async function fetchThreadContext(

if (messages.length === 0) return { context: "", lastSessionId };

console.log(`[thread] Loaded ${messages.length} prior message(s) from thread`);
console.log(`[thread] Loaded ${messages.length} prior message(s) from thread, lastSessionId: ${lastSessionId}`);
const context = `Below is the prior conversation in this Slack thread for context:\n\n${messages.join("\n")}\n\n---\n`;
return { context, lastSessionId };
} catch (err: any) {
Expand Down Expand Up @@ -194,9 +225,9 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue, ca
.catch(() => {}),
]);

// Cancelled: send error + session info as ephemeral only
// Cancelled: send cancellation notice as ephemeral, session info as persistent
if (isCancelled) {
const ephemeralParts: Promise<void>[] = [
const parts: Promise<void>[] = [
client.chat.postEphemeral({
channel: event.channel,
user: event.user,
Expand All @@ -206,16 +237,15 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue, ca
];
if (sessionId) {
const sessionMsg = formatSessionInfo(sessionId, resolved.repoPath, config.claudePath);
ephemeralParts.push(
client.chat.postEphemeral({
parts.push(
client.chat.postMessage({
channel: event.channel,
user: event.user,
thread_ts: event.ts,
...sessionMsg,
}),
);
}
await Promise.all(ephemeralParts);
await Promise.all(parts);
} else {
// noreply: skip thread reply, send session info only to requester via ephemeral
if (!noreply) {
Expand All @@ -225,14 +255,14 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue, ca
}
}

// Post session info
// Post session info (always persistent for session continuity)
if (sessionId) {
const sessionMsg = formatSessionInfo(sessionId, resolved.repoPath, config.claudePath);
await postReplyOrEphemeral(
client,
{ channel: event.channel, threadTs: noreply ? event.thread_ts : event.ts, user: event.user, noreply },
sessionMsg,
);
await client.chat.postMessage({
channel: event.channel,
thread_ts: event.thread_ts ?? event.ts,
...sessionMsg,
});
}
}

Expand Down Expand Up @@ -343,7 +373,6 @@ export function createAssistant(config: CCSlackConfig, queue: TaskQueue, cancelM
const startTime = Date.now();
console.log(`[claude] Starting stream: claude -p "${prompt.slice(0, 50)}..." in ${resolved.repoPath}`);

let cancelled = false;
try {
await queue.enqueue(async () => {
const streamer = client.chatStream({
Expand Down Expand Up @@ -387,9 +416,7 @@ export function createAssistant(config: CCSlackConfig, queue: TaskQueue, cancelM
console.log(`[thinking] ${preview}`);
} else if (evt.type === "error") {
console.log(`[claude] Error: ${evt.error}`);
if (evt.error === "cancelled") {
cancelled = true;
} else {
if (evt.error !== "cancelled") {
await streamer.append({ markdown_text: `\n\nError: ${evt.error}` });
}
} else if (evt.type === "result") {
Expand All @@ -404,19 +431,10 @@ export function createAssistant(config: CCSlackConfig, queue: TaskQueue, cancelM
}
});

// Post session info: ephemeral if cancelled, public otherwise
// Post session info (always persistent for session continuity)
if (sessionId) {
const sessionMsg = formatSessionInfo(sessionId, resolved.repoPath, config.claudePath);
if (cancelled) {
await client.chat.postEphemeral({
channel: ev.channel,
user: ev.user,
thread_ts: ev.thread_ts || ev.ts,
...sessionMsg,
});
} else {
await say(sessionMsg);
}
await say(sessionMsg);
}
} catch (err: any) {
console.log(`[claude] Unhandled error: ${err.message}`);
Expand Down
110 changes: 109 additions & 1 deletion tests/slack/handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { describe, expect, it, mock } from "bun:test";
import type { CCSlackConfig } from "../../src/config";
import { createReactionCancelHandler, resolveRepoPath, resolveSessionId, SESSION_ID_RE } from "../../src/slack/handler";
import {
createReactionCancelHandler,
extractSessionIdFromMessage,
resolveRepoPath,
resolveSessionId,
SESSION_ID_RE,
} from "../../src/slack/handler";
import { formatSessionInfo } from "../../src/slack/responder";

const mockConfig: CCSlackConfig = {
Expand Down Expand Up @@ -133,6 +139,108 @@ describe("SESSION_ID_RE", () => {
});
});

describe("extractSessionIdFromMessage", () => {
const testUuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";

it("extracts session ID from text field (Session: format)", () => {
const msg = { text: `Session: ${testUuid}`, bot_id: "B123" };
expect(extractSessionIdFromMessage(msg)).toBe(testUuid);
});

it("extracts session ID from text field (:link: format)", () => {
const msg = { text: `:link: \`${testUuid}\``, bot_id: "B123" };
expect(extractSessionIdFromMessage(msg)).toBe(testUuid);
});

it("extracts session ID from blocks when text is empty", () => {
const msg = {
text: "",
bot_id: "B123",
blocks: [
{
type: "markdown",
text: `---\n:link: \`${testUuid}\`\n\`\`\`\ncd /repo && claude --resume ${testUuid}\n\`\`\``,
},
],
};
expect(extractSessionIdFromMessage(msg)).toBe(testUuid);
});

it("extracts session ID from blocks when text has no session info", () => {
const msg = {
text: "Some other text without session info",
bot_id: "B123",
blocks: [
{ type: "markdown", text: "Here is my response..." },
{ type: "markdown", text: `---\n:link: \`${testUuid}\`` },
],
};
expect(extractSessionIdFromMessage(msg)).toBe(testUuid);
});

it("returns null when no session ID found anywhere", () => {
const msg = { text: "Just a regular message", bot_id: "B123", blocks: [] };
expect(extractSessionIdFromMessage(msg)).toBeNull();
});

it("returns null when message has no text and no blocks", () => {
const msg = { bot_id: "B123" };
expect(extractSessionIdFromMessage(msg)).toBeNull();
});

it("prefers text field over blocks", () => {
const textUuid = "11111111-2222-3333-4444-555555555555";
const blockUuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
const msg = {
text: `Session: ${textUuid}`,
bot_id: "B123",
blocks: [{ type: "markdown", text: `:link: \`${blockUuid}\`` }],
};
expect(extractSessionIdFromMessage(msg)).toBe(textUuid);
});

it("extracts session ID from rich_text blocks (Slack conversion of markdown blocks)", () => {
const msg = {
text: "",
bot_id: "B123",
blocks: [
{
type: "rich_text",
elements: [
{
type: "rich_text_section",
elements: [
{ type: "emoji", name: "link" },
{ type: "text", text: ` \`${testUuid}\`` },
],
},
],
},
],
};
expect(extractSessionIdFromMessage(msg)).toBe(testUuid);
});

it("extracts session ID from rich_text with Session: prefix", () => {
const msg = {
text: "",
bot_id: "B123",
blocks: [
{
type: "rich_text",
elements: [
{
type: "rich_text_section",
elements: [{ type: "text", text: `Session: ${testUuid}` }],
},
],
},
],
};
expect(extractSessionIdFromMessage(msg)).toBe(testUuid);
});
});

describe("createReactionCancelHandler", () => {
const config = mockConfig;

Expand Down