diff --git a/src/slack/handler.ts b/src/slack/handler.ts index f0397bd..6a04f37 100644 --- a/src/slack/handler.ts +++ b/src/slack/handler.ts @@ -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, @@ -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; } } @@ -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) { @@ -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[] = [ + const parts: Promise[] = [ client.chat.postEphemeral({ channel: event.channel, user: event.user, @@ -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) { @@ -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, + }); } } @@ -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({ @@ -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") { @@ -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}`); diff --git a/tests/slack/handler.test.ts b/tests/slack/handler.test.ts index 46b8daf..7cbe5dd 100644 --- a/tests/slack/handler.test.ts +++ b/tests/slack/handler.test.ts @@ -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 = { @@ -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;