import { createServer, type IncomingMessage } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
const {
GOBARE_API = "https://api.gobare.dev", GOBARE_TOKEN, GOBARE_WEBHOOK_SECRET,
SLACK_API = "https://slack.com/api", SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET,
} = process.env as Record<string, string>;
async function gobare(method: string, path: string, body?: unknown, headers: Record<string, string> = {}): Promise<any> {
for (let attempt = 1; ; attempt++) {
const res = await fetch(`${GOBARE_API}${path}`, {
method, body: body === undefined ? undefined : JSON.stringify(body),
headers: { authorization: `Bearer ${GOBARE_TOKEN}`, "content-type": "application/json", ...headers },
}).catch(() => null);
if (res && res.status < 500) {
if (!res.ok) throw new Error(`${method} ${path} answered ${res.status}: ${await res.text()}`);
return res.json();
}
if (attempt === 4) throw new Error(`${method} ${path}: ${res ? res.status : "network error"} after ${attempt} attempts`);
await new Promise((r) => setTimeout(r, attempt * 2000));
}
}
async function say(channel: string, thread_ts: string, text: string): Promise<void> {
const res = await fetch(`${SLACK_API}/chat.postMessage`, {
method: "POST",
headers: { authorization: `Bearer ${SLACK_BOT_TOKEN}`, "content-type": "application/json; charset=utf-8" },
body: JSON.stringify({ channel, thread_ts, text }),
});
const answer = await res.json();
if (!answer.ok) throw new Error(`chat.postMessage: ${answer.error}`);
}
function hmacMatches(secret: string, material: string, presented: string, prefix = ""): boolean {
const expected = prefix + createHmac("sha256", secret).update(material).digest("hex");
return expected.length === presented.length && timingSafeEqual(Buffer.from(expected), Buffer.from(presented));
}
async function sessionForThread(ts: string): Promise<any | null> {
const found = await gobare("GET", `/v1/sessions?metadata=slack_thread:${ts}&limit=1`);
return found.data[0] ? gobare("GET", `/v1/sessions/${found.data[0].id}`) : null;
}
// ── Slack → Gobare ──────────────────────────────────────────────────────────
export function verifySlack(body: string, headers: IncomingMessage["headers"]): { status: number; body?: string; payload?: any } {
const timestamp = String(headers["x-slack-request-timestamp"] ?? "");
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return { status: 400 };
if (!hmacMatches(SLACK_SIGNING_SECRET, `v0:${timestamp}:${body}`, String(headers["x-slack-signature"] ?? ""), "v0=")) return { status: 401 };
const payload = JSON.parse(body);
if (payload.type === "url_verification") return { status: 200, body: payload.challenge };
return { status: 200, payload };
}
export async function onSlack(payload: any, retried = false): Promise<void> {
const event = payload.event;
if (!event || event.bot_id || event.subtype) return;
if (event.type === "app_mention" && !event.thread_ts) {
await gobare("POST", "/v1/sessions", {
title: event.text.slice(0, 80),
metadata: { slack_channel: event.channel, slack_thread: event.ts, slack_user: event.user },
agent: { instructions: "You are working for a team in Slack. If something is genuinely ambiguous, ask one short question with your question tool; otherwise decide and continue. Keep the final answer under 150 words." },
input: event.text.replace(/<@[A-Z0-9]+>\s*/g, ""),
}, { "idempotency-key": `slack-${payload.event_id}` });
// A retry replays the same session through the idempotency key; only the first attempt says so.
if (!retried) await say(event.channel, event.ts, "On it — I'll reply in this thread.");
return;
}
if (event.type === "message" && event.thread_ts) {
const session = await sessionForThread(event.thread_ts);
if (!session) return;
const question = session.required_actions.find((a: any) => a.type === "question");
const input = question
? { type: "input.question_answer", call_id: question.call_id, answer: event.text }
: { type: "input.message", content: [{ type: "input_text", text: event.text }] };
await gobare("POST", `/v1/sessions/${session.id}/events`, { events: [input] }, { "idempotency-key": `slack-${payload.event_id}` });
}
}
// ── Gobare → Slack ──────────────────────────────────────────────────────────
export async function onGobare(body: string, headers: IncomingMessage["headers"]): Promise<{ status: number }> {
const timestamp = String(headers["x-gobare-timestamp"] ?? "");
if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60_000) return { status: 400 };
if (!hmacMatches(GOBARE_WEBHOOK_SECRET, `${timestamp}.${body}`, String(headers["x-gobare-signature"] ?? ""))) return { status: 401 };
const event = JSON.parse(body);
const session = await gobare("GET", `/v1/sessions/${event.data.session_id}`).catch(() => null);
const { slack_channel: channel, slack_thread: thread } = session?.metadata ?? {};
if (!channel || !thread) return { status: 200 };
if (event.type === "session.action_required") {
const question = session.required_actions.find((a: any) => a.type === "question");
if (question) {
const options = (question.arguments?.options ?? []).map((o: any) => `• ${o.label}`).join("\n");
await say(channel, thread, `*${question.name}*\n${options}\nReply in this thread.`);
}
}
if (event.type === "turn.completed") {
const items = await gobare("GET", `/v1/sessions/${session.id}/items?limit=20`);
const reply = items.data.find((i: any) => i.type === "message" && i.role === "assistant");
await say(channel, thread, reply?.content ?? "Done.");
}
if (event.type === "turn.failed") {
const turn = event.data.turn_id ? await gobare("GET", `/v1/sessions/${session.id}/turns/${event.data.turn_id}`) : null;
await say(channel, thread, `That did not work: ${turn.error?.message ?? "the turn failed"}`);
}
return { status: 200 };
}
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve) => {
const chunks: Buffer[] = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
});
}
export function listen(port: number) {
return createServer(async (req, res) => {
const body = await readBody(req);
if (req.url === "/slack/events") {
// Slack wants an answer within 3 seconds and retries otherwise: answer first, work after.
const verdict = verifySlack(body, req.headers);
res.writeHead(verdict.status).end(verdict.body);
if (verdict.payload) await onSlack(verdict.payload, Boolean(req.headers["x-slack-retry-num"])).catch((err) => console.error(err));
return;
}
if (req.url === "/gobare/events") {
const result = await onGobare(body, req.headers).catch((err) => { console.error(err); return { status: 500 }; });
return res.writeHead(result.status).end();
}
res.writeHead(404).end();
}).listen(port);
}
if (import.meta.url === `file://${process.argv[1]}`) listen(Number(process.env.PORT ?? 3000));