Skip to content
Open
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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,22 @@ Slack bot that bridges Slack conversations to Claude Code sessions. Each channel
SLACK_SIGNING_SECRET=...
ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-... # for Whisper audio transcription

# Optional — enables GitHub comment mentions (see below)
GITHUB_TOKEN=github_pat_...
GITHUB_WEBHOOK_SECRET=... # shared secret configured in your GitHub App
GITHUB_BOT_HANDLE=@claudex # mention string Claude watches for (default: @claudex)
GITHUB_WEBHOOK_PORT=8080 # HTTP port for the webhook server (default: 8080)
```

3. **Slack app setup** — import `slack-manifest.json` into your Slack app config. The app needs Socket Mode enabled and the scopes listed in the manifest.

4. **(Optional) GitHub App setup** — to enable `@claudex` mentions in GitHub issues/PRs:
- Create a GitHub App (or use a fine-grained PAT) with `issues: write` permission on the target repos
- Add a webhook pointing at `https://your-host:8080/github/webhook`, subscribed to **Issue comments** events
- Set `GITHUB_TOKEN`, `GITHUB_WEBHOOK_SECRET`, and optionally `GITHUB_BOT_HANDLE` in your `.env`
- The HTTP server starts automatically alongside the Slack Socket Mode connection when `GITHUB_TOKEN` is present

## Running

### With `manage.sh` (recommended)
Expand Down Expand Up @@ -52,6 +64,10 @@ npm run start
- Attached files are downloaded to disk; audio/voice messages are transcribed via Whisper
- Claude has MCP tools for sending messages, uploading files, listing channels, reading history, and searching Slack

### GitHub channel (optional)

When `GITHUB_TOKEN` is set, Claudex also starts a lightweight HTTP server that receives GitHub webhooks. Mentioning `@claudex` (or whatever `GITHUB_BOT_HANDLE` is set to) in a GitHub issue or PR comment triggers the same Claude Code session infrastructure used for Slack. Sessions are keyed by `gh:{owner/repo}#{issue_number}`, so context persists across the full issue/PR thread — the same way it does per Slack thread. Responses are posted back as GitHub comments.

## Project structure

```
Expand All @@ -64,6 +80,9 @@ src/
messages.ts # post messages, format mrkdwn
mcp-server.ts # per-session MCP server with Slack tools
tools.ts # Slack MCP tool definitions
github/
handler.ts # webhook HTTP server, signature verification, session routing
post.ts # post comments back to GitHub via REST API
claude/
session.ts # create/resume Claude Code sessions
response.ts # consume streaming response
Expand Down
149 changes: 149 additions & 0 deletions src/github/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
import { getSession, saveSession } from "../store/sessions.js";
import { createSession, resumeSession } from "../claude/session.js";
import { resolveCwd } from "../util/paths.js";
import { postGitHubComment } from "./post.js";

const BOT_HANDLE = process.env.GITHUB_BOT_HANDLE ?? "@claudex";

/** Concurrency guard: set of thread keys currently being processed */
const activeThreads = new Set<string>();

/** Verify the X-Hub-Signature-256 header from GitHub */
async function verifySignature(req: IncomingMessage, body: string): Promise<boolean> {
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (!secret) {
console.warn("[github] GITHUB_WEBHOOK_SECRET not set — skipping signature verification");
return true;
}
const sig = req.headers["x-hub-signature-256"] as string | undefined;
if (!sig) return false;
const expected = "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
try {
return timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
} catch {
return false;
}
}

/** Read the full request body */
async function readBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
return Buffer.concat(chunks).toString("utf-8");
}

/** Handle a single webhook HTTP request */
export async function handleWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<void> {
if (req.method !== "POST" || req.url !== "/github/webhook") {
res.writeHead(404).end();
return;
}

const body = await readBody(req);

if (!(await verifySignature(req, body))) {
console.warn("[github] Webhook signature verification failed");
res.writeHead(401).end("Unauthorized");
return;
}

// Respond immediately so GitHub doesn't time out
res.writeHead(200).end("ok");

const event = req.headers["x-github-event"] as string | undefined;

// Only handle new comments on issues and PRs for now.
// pull_request_review_comment (inline diff comments) can be added later.
if (event !== "issue_comment") return;

const payload = JSON.parse(body);
if (payload.action !== "created") return; // ignore edits / deletions

const commentBody: string = payload.comment?.body ?? "";
if (!commentBody.includes(BOT_HANDLE)) return;

const repo: string = payload.repository.full_name; // "owner/repo"
const issueNumber: string = String(payload.issue.number);
const commentsUrl: string = payload.issue.comments_url;
const user: string = payload.comment.user.login;

// Session key: channelId = "gh:owner/repo", threadTs = issue/PR number.
// This slots naturally into the existing session store without any schema changes.
const channelId = `gh:${repo}`;
const threadTs = issueNumber;
const threadKey = `${channelId}:${threadTs}`;

if (activeThreads.has(threadKey)) {
console.log(`[github] ${threadKey} already processing, skipping`);
return;
}

activeThreads.add(threadKey);
try {
const existing = getSession(channelId, threadTs);

// Working directory mirrors the Slack pattern: ~/github/{owner}-{repo}/
const cwd = resolveCwd("github", repo.replace("/", "-"));

// Strip the bot mention from the comment text
const text = commentBody.replace(new RegExp(BOT_HANDLE, "g"), "").trim();
const prompt = `[GitHub ${repo}#${issueNumber}]\n\n${user}: ${text}`;

console.log(`[github] ${threadKey} sending to Claude: ${prompt.slice(0, 120)}...`);

const response = existing
? await resumeSession(prompt, cwd, existing.sessionId)
: await createSession(prompt, cwd);

await postGitHubComment(commentsUrl, response.text);

saveSession({
channelId,
threadTs,
sessionId: response.sessionId,
cwd,
// lastResponseTs isn't used for GitHub sessions (no message aggregation needed),
// but the field is required — store the wall-clock time for debugging.
lastResponseTs: new Date().toISOString(),
createdAt: existing?.createdAt ?? new Date().toISOString(),
updatedAt: new Date().toISOString(),
});

if (response.costUsd > 0) {
console.log(`[github] ${threadKey} cost: $${response.costUsd.toFixed(4)}`);
}
} catch (err) {
console.error(`[github] Error handling ${threadKey}:`, err);
try {
await postGitHubComment(
commentsUrl,
`> ❌ Claudex error: ${err instanceof Error ? err.message : "Unknown error"}`,
);
} catch {
// Best effort — don't let a reply failure mask the original error
}
} finally {
activeThreads.delete(threadKey);
}
}

/** Start the GitHub webhook HTTP server on the given port */
export function startGitHubWebhookServer(port: number): void {
const server = createServer(async (req, res) => {
try {
await handleWebhookRequest(req, res);
} catch (err) {
console.error("[github] Unhandled webhook error:", err);
if (!res.headersSent) res.writeHead(500).end("Internal server error");
}
});

server.listen(port, () => {
console.log(`⚡ GitHub webhook server listening on port ${port}`);
});
}
25 changes: 25 additions & 0 deletions src/github/post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Post a comment back to a GitHub issue or PR via the REST API.
*/
export async function postGitHubComment(
commentsUrl: string,
body: string,
): Promise<void> {
const token = process.env.GITHUB_TOKEN;
if (!token) throw new Error("GITHUB_TOKEN env var not set");

const res = await fetch(commentsUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
"X-GitHub-Api-Version": "2022-11-28",
},
body: JSON.stringify({ body }),
});

if (!res.ok) {
throw new Error(`GitHub API error: ${res.status} ${await res.text()}`);
}
}
11 changes: 10 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createApp } from "./slack/app.js";
import { loadSessions } from "./store/sessions.js";
import { startGitHubWebhookServer } from "./github/handler.js";

// Validate required env vars
const required = ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "ANTHROPIC_API_KEY"];
Expand All @@ -13,8 +14,16 @@ for (const key of required) {
// Load persisted sessions
loadSessions();

// Start the app
// Start the Slack Socket Mode app
const app = createApp();
await app.start();

// Optionally start the GitHub webhook HTTP server (same process, second I/O channel)
if (process.env.GITHUB_TOKEN) {
const port = parseInt(process.env.GITHUB_WEBHOOK_PORT ?? "8080", 10);
startGitHubWebhookServer(port);
} else {
console.log("GITHUB_TOKEN not set — GitHub webhook server disabled");
}

console.log("⚡ Claudex is running");