import { App } from "@slack/bolt"; import { execFileSync } from "child_process"; import { writeFileSync, readFileSync, unlinkSync, mkdtempSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; import { ClaudeBridge } from "./bridge"; import { formatForSlack } from "./format"; import { isAudioFile, transcribeBuffer } from "../voice/transcribe"; import { synthesize } from "../voice/speak"; process.on("uncaughtException", (err) => { console.error("[fatal] uncaught exception:", err); }); process.on("unhandledRejection", (reason) => { console.error("[fatal] unhandled rejection:", reason); }); const botToken = process.env.SLACK_BOT_TOKEN; const appToken = process.env.SLACK_APP_TOKEN; if (!botToken || !appToken) { console.error( "Missing SLACK_BOT_TOKEN or SLACK_APP_TOKEN. Set them in environment or source secrets/slack.env" ); process.exit(1); } const app = new App({ token: botToken, appToken: appToken, socketMode: true, }); const SLACK_STATE_FILE = join(__dirname, "../../channels/logs/sessions/.slack-session"); const claude = new ClaudeBridge(); claude.channel = "slack"; claude.model = "sonnet"; claude.setStateFile(SLACK_STATE_FILE); const IMAGE_MIME_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]); const HEIF_MIME_TYPES = new Set(["image/heif", "image/heic", "image/heif-sequence", "image/heic-sequence"]); function isImageFile(f: any): boolean { return IMAGE_MIME_TYPES.has(f.mimetype) || HEIF_MIME_TYPES.has(f.mimetype) || /\.hei[cf]$/i.test(f.name || ""); } async function fetchFileBuffer(client: any, f: any): Promise<{ buf: Buffer; name: string; mime: string }> { if (!f.url_private && !f.url_private_download) { const info = await client.files.info({ file: f.id }); f.url_private = info.file?.url_private; f.mimetype = f.mimetype || info.file?.mimetype; f.name = f.name || info.file?.name; } const url = f.url_private || f.url_private_download; const res = await fetch(url, { headers: { Authorization: `Bearer ${botToken}` } }); let buf = Buffer.from(await res.arrayBuffer()); let mime = f.mimetype || "application/octet-stream"; let name = f.name || "file"; if (HEIF_MIME_TYPES.has(mime) || /\.hei[cf]$/i.test(name)) { const tmp = mkdtempSync(join(tmpdir(), "heif-")); const src = join(tmp, name); const dst = join(tmp, name.replace(/\.hei[cf]$/i, ".jpg")); writeFileSync(src, buf); try { execFileSync("sips", ["-s", "format", "jpeg", src, "--out", dst]); buf = readFileSync(dst); mime = "image/jpeg"; name = name.replace(/\.hei[cf]$/i, ".jpg"); console.log(`[slack] converted HEIF → JPEG (${buf.length} bytes)`); } catch (err) { console.error("[slack] HEIF conversion failed:", err); } try { unlinkSync(src); unlinkSync(dst); } catch {} } return { buf, name, mime }; } async function handleMessage( client: any, channel: string, text: string, audioFiles: any[], imageFiles: any[], threadTs?: string ) { const transcripts: string[] = []; const imageContents: Array> = []; for (const f of audioFiles) { const { buf, name, mime } = await fetchFileBuffer(client, f); console.log(`[slack] transcribing: ${name} (${mime})`); const transcript = await transcribeBuffer(buf, name, mime); console.log(`[slack] transcript: ${transcript.slice(0, 100)}`); transcripts.push(`[Voice message: "${transcript}"]`); } for (const f of imageFiles) { const { buf, name, mime } = await fetchFileBuffer(client, f); console.log(`[slack] image: ${name} (${mime}, ${buf.length} bytes)`); imageContents.push({ type: "image", source: { type: "base64", media_type: mime, data: buf.toString("base64") }, }); } const fullText = [text, ...transcripts].filter(Boolean).join("\n\n"); const content: Array> = [...imageContents]; if (fullText) content.push({ type: "text", text: fullText }); const response = await claude.sendContent(content); const replyText = response || "(no response)"; console.log(`[slack] sending reply (${replyText.length} chars)`); if (audioFiles.length > 0) { try { const voiceBuf = await synthesize(replyText); console.log(`[slack] uploading voice reply (${voiceBuf.length} bytes)`); await client.filesUploadV2({ channel_id: channel, thread_ts: threadTs, filename: "reply.mp3", file: voiceBuf, initial_comment: formatForSlack(replyText)[0], }); } catch (err) { console.error("[slack] TTS upload failed, falling back to text:", err); for (const chunk of formatForSlack(replyText)) { await client.chat.postMessage({ channel, text: chunk, thread_ts: threadTs }); } } } else { for (const chunk of formatForSlack(replyText)) { await client.chat.postMessage({ channel, text: chunk, thread_ts: threadTs }); } } console.log(`[slack] replied to ${channel}`); } app.event("message", async ({ event, say, client }) => { if ("bot_id" in event) return; const subtype = ("subtype" in event && event.subtype) || null; if (subtype && subtype !== "file_share") return; const text = ("text" in event && event.text) || ""; const files = ("files" in event && (event as any).files) || []; const audioFiles = files.filter(isAudioFile); const imageFiles = files.filter(isImageFile); if (!text && audioFiles.length === 0 && imageFiles.length === 0) return; const channel = ("channel" in event && event.channel) as string; const threadTs = ("thread_ts" in event && event.thread_ts) || undefined; console.log(`[slack] message from ${event.user}: "${text}" (${audioFiles.length} audio, ${imageFiles.length} images)`); const slackThreadId = `slack-${Date.now()}`; // For new sessions: update sessionFile as soon as the session starts const onSessionReady = (_sessionId: string) => { }; claude.once("sessionReady", onSessionReady); try { await handleMessage(client, channel, text, audioFiles, imageFiles, threadTs); claude.removeListener("sessionReady", onSessionReady); } catch (err) { claude.removeListener("sessionReady", onSessionReady); console.error("[slack] error:", err); await say("Something went wrong. Try again or `/clear` to reset."); } }); app.event("app_mention", async ({ event, say }) => { const text = event.text.replace(/<@[A-Z0-9]+>/g, "").trim(); if (!text) return; console.log(`[slack] mention from ${event.user}: ${text}`); const mentionThreadId = `slack-mention-${Date.now()}`; const onSessionReadyMention = (_sessionId: string) => { }; claude.once("sessionReady", onSessionReadyMention); try { const response = await claude.send(text); claude.removeListener("sessionReady", onSessionReadyMention); for (const chunk of formatForSlack(response || "(no response)")) { await say({ text: chunk, thread_ts: event.ts }); } } catch (err) { claude.removeListener("sessionReady", onSessionReadyMention); console.error("[slack] error:", err); await say({ text: "Something went wrong. Try again or `/clear` to reset.", thread_ts: event.ts }); } }); app.command("/clear", async ({ ack, respond }) => { await ack(); claude.reset(); console.log("[slack] /clear — session reset"); await respond("Session cleared. Next message starts a fresh conversation."); }); (async () => { await app.start(); console.log("[ace] Slack bot is running (Socket Mode)"); console.log("[ace] Claude subprocess will start on first message"); })();