diff --git a/Dockerfile.dispatcher b/Dockerfile.dispatcher index d4e8a61f..42acb138 100644 --- a/Dockerfile.dispatcher +++ b/Dockerfile.dispatcher @@ -20,6 +20,7 @@ RUN --mount=type=cache,target=/root/.bun/install/cache bun install && \ # Copy source code COPY packages/shared/ ./packages/shared/ COPY packages/dispatcher/ ./packages/dispatcher/ +COPY modules/ ./modules/ # Build shared first WORKDIR /app/packages/shared @@ -36,4 +37,4 @@ EXPOSE 3000 # Runtime user setup is handled by node:20-alpine # Use Node.js for runtime (better WebSocket compatibility) -CMD ["node", "dist/index.js"] +CMD ["node", "dist/packages/dispatcher/src/index.js"] diff --git a/Dockerfile.orchestrator b/Dockerfile.orchestrator index 612ce8a1..aab414d0 100644 --- a/Dockerfile.orchestrator +++ b/Dockerfile.orchestrator @@ -46,6 +46,9 @@ COPY packages/shared/ ./packages/shared/ COPY packages/orchestrator/src ./packages/orchestrator/src COPY packages/orchestrator/tsconfig.json ./packages/orchestrator/ COPY packages/orchestrator/db/ ./packages/orchestrator/db/ +# Copy only the module registry files, not the GitHub module (to avoid unnecessary dependencies) +COPY modules/index.ts ./modules/ +COPY modules/types.ts ./modules/ # Build shared package first WORKDIR /app/packages/shared @@ -72,4 +75,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ # Start the orchestrator with bun WORKDIR /app/packages/orchestrator -CMD ["bun", "dist/index.js"] +CMD ["bun", "dist/packages/orchestrator/src/index.js"] diff --git a/Dockerfile.worker b/Dockerfile.worker index 5873cde5..5e5a0b62 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -81,6 +81,7 @@ RUN --mount=type=cache,target=/root/.bun/install/cache bun install # Copy source code (needed for both dev and prod) COPY packages/ ./packages/ COPY scripts/ ./scripts/ +COPY modules/ ./modules/ # For production mode, build during image creation # For dev mode, we'll build at startup to allow for live code changes diff --git a/docker-compose.yml b/docker-compose.yml index 58f30f7f..01d6c025 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,6 +51,7 @@ services: NODE_ENV: production DATABASE_URL: postgresql://postgres:${POSTGRESQL_PASSWORD:-password}@postgres:5432/peerbot?sslmode=disable GITHUB_TOKEN: ${GITHUB_TOKEN} + ENCRYPTION_KEY: ${ENCRYPTION_KEY} DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-docker} DOCKER_HOST: unix:///var/run/docker.sock CLAUDE_ALLOWED_TOOLS: ${CLAUDE_ALLOWED_TOOLS} diff --git a/integration-tests/src/mocks/claude-server.ts b/integration-tests/src/mocks/claude-server.ts index a1b2f93e..f25bdd1c 100644 --- a/integration-tests/src/mocks/claude-server.ts +++ b/integration-tests/src/mocks/claude-server.ts @@ -75,7 +75,7 @@ export class MockClaudeServer { }); // Health check - this.app.get("/health", (req, res) => { + this.app.get("/health", (_req, res) => { res.json({ status: "ok", mock: true }); }); } diff --git a/integration-tests/src/mocks/slack-server.ts b/integration-tests/src/mocks/slack-server.ts index 48b16e3c..54b009c7 100644 --- a/integration-tests/src/mocks/slack-server.ts +++ b/integration-tests/src/mocks/slack-server.ts @@ -1,5 +1,5 @@ import express from "express"; -import { EventEmitter } from "events"; +import { EventEmitter } from "node:events"; interface SlackMessage { ts: string; @@ -97,7 +97,7 @@ export class MockSlackServer extends EventEmitter { }); // Mock auth.test - this.app.post("/api/auth.test", (req, res) => { + this.app.post("/api/auth.test", (_req, res) => { res.json({ ok: true, url: "https://test-workspace.slack.com/", diff --git a/packages/dispatcher/src/converters/github-actions.ts b/modules/github/actions.ts similarity index 80% rename from packages/dispatcher/src/converters/github-actions.ts rename to modules/github/actions.ts index 0e9ddd77..f30d732a 100644 --- a/packages/dispatcher/src/converters/github-actions.ts +++ b/modules/github/actions.ts @@ -1,12 +1,24 @@ #!/usr/bin/env bun -import type { GitHubRepositoryManager } from "../github/repository-manager"; -import { generateGitHubAuthUrl } from "../utils/github-utils"; -import { getUserGitHubInfo } from "../slack/handlers/github-handler"; -import { generateDeterministicActionId } from "./blockkit-processor"; +import type { GitHubRepositoryManager } from "./repository-manager"; +import { generateGitHubAuthUrl } from "./utils"; +import { getUserGitHubInfo } from "./handlers"; +import { createHash } from "node:crypto"; import { createLogger } from "@peerbot/shared"; -const logger = createLogger("dispatcher"); +const logger = createLogger("github-module"); + +// Inline action ID generation to avoid cross-package dependencies +function generateDeterministicActionId( + content: string, + prefix: string = "action" +): string { + const hash = createHash("sha256") + .update(content) + .digest("hex") + .substring(0, 8); + return `${prefix}_${hash}`; +} /** * Generate GitHub action buttons for the session branch @@ -16,7 +28,6 @@ export async function generateGitHubActionButtons( gitBranch: string | undefined, hasGitChanges: boolean | undefined, pullRequestUrl: string | undefined, - userMappings: Map, repoManager: GitHubRepositoryManager, slackClient?: any ): Promise { @@ -45,11 +56,9 @@ export async function generateGitHubActionButtons( return undefined; } - // Get GitHub username from Slack user ID - let githubUsername = userMappings.get(userId); - if (!githubUsername && slackClient) { - // Create user mapping on-demand if not found - logger.debug(`Creating on-demand user mapping for user ${userId}`); + // Generate GitHub username from Slack user ID + let githubUsername: string; + if (slackClient) { try { const userInfo = await slackClient.users.info({ user: userId }); const user = userInfo.user; @@ -66,22 +75,17 @@ export async function generateGitHubActionButtons( .replace(/[^a-z0-9-]/g, "-") .replace(/^-|-$/g, ""); - username = `user-${username}`; - userMappings.set(userId, username); - githubUsername = username; - - logger.info(`Created user mapping: ${userId} -> ${username}`); + githubUsername = `user-${username}`; + logger.debug( + `Generated GitHub username: ${userId} -> ${githubUsername}` + ); } catch (error) { - logger.error(`Failed to create user mapping for ${userId}:`, error); - const fallbackUsername = `user-${userId.substring(0, 8)}`; - userMappings.set(userId, fallbackUsername); - githubUsername = fallbackUsername; + logger.error(`Failed to get user info for ${userId}:`, error); + githubUsername = `user-${userId.substring(0, 8)}`; } - } - - if (!githubUsername) { - logger.debug(`No GitHub username mapping found for user ${userId}`); - return undefined; + } else { + // Fallback if no Slack client available + githubUsername = `user-${userId.substring(0, 8)}`; } // Get repository information, create if needed diff --git a/modules/github/errors.ts b/modules/github/errors.ts new file mode 100644 index 00000000..17feb2a6 --- /dev/null +++ b/modules/github/errors.ts @@ -0,0 +1,25 @@ +import { BaseError } from "@peerbot/shared"; + +/** + * Error class for GitHub repository operations + */ +export class GitHubRepositoryError extends BaseError { + readonly name = "GitHubRepositoryError"; + + constructor( + public operation: string, + public username: string, + message: string, + cause?: Error + ) { + super(message, cause); + } + + toJSON(): Record { + return { + ...super.toJSON(), + operation: this.operation, + username: this.username, + }; + } +} diff --git a/packages/dispatcher/src/oauth/github-oauth-handler.ts b/modules/github/handlers.ts similarity index 51% rename from packages/dispatcher/src/oauth/github-oauth-handler.ts rename to modules/github/handlers.ts index 8414f9ec..d9078087 100644 --- a/packages/dispatcher/src/oauth/github-oauth-handler.ts +++ b/modules/github/handlers.ts @@ -1,12 +1,11 @@ -#!/usr/bin/env bun - -import axios from "axios"; -import type { Request, Response } from "express"; -import { getDbPool } from "@peerbot/shared"; import { createLogger } from "@peerbot/shared"; +import { getDbPool } from "@peerbot/shared"; import { encrypt, decrypt } from "@peerbot/shared"; +import axios from "axios"; +import type { Request, Response } from "express"; -const logger = createLogger("dispatcher"); +const logger = createLogger("github-module"); +import { generateGitHubAuthUrl } from "./utils"; export class GitHubOAuthHandler { private dbPool: any; @@ -164,9 +163,6 @@ export class GitHubOAuthHandler { `Triggering home tab refresh for user ${userId} after GitHub OAuth` ); await this.homeTabCallback(userId); - - // Also send a DM with repository selection prompt - // This requires access to Slack client, which we'll pass through the callback } } catch (error) { logger.error("Failed to trigger home tab refresh:", error); @@ -277,7 +273,7 @@ export class GitHubOAuthHandler { } /** - * Handle logout + * Handle logout/revoke */ async handleLogout(req: Request, res: Response): Promise { try { @@ -302,6 +298,13 @@ export class GitHubOAuthHandler { } } + /** + * Handle OAuth revoke (alias for logout) + */ + async handleRevoke(req: Request, res: Response): Promise { + return this.handleLogout(req, res); + } + /** * Cleanup resources */ @@ -309,3 +312,360 @@ export class GitHubOAuthHandler { /* no-op for shared pool */ } } + +/** + * Handle GitHub login modal action + */ +export async function handleGitHubLoginModal( + userId: string, + body: any, + client: any +): Promise { + try { + const authUrl = generateGitHubAuthUrl(userId); + + await client.views.open({ + trigger_id: body.trigger_id, + view: { + type: "modal", + callback_id: "github_login_modal", + title: { + type: "plain_text", + text: "Connect GitHub", + }, + blocks: [ + { + type: "header", + text: { + type: "plain_text", + text: "🔗 Connect Your GitHub Account", + emoji: true, + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: + "Connect your GitHub account to:\n\n" + + "• Access your repositories\n" + + "• Create new projects\n" + + "• Manage code with AI assistance\n\n" + + "*Your connection is secure and encrypted.*", + }, + }, + { + type: "divider", + }, + { + type: "section", + text: { + type: "mrkdwn", + text: "Click the button below to authenticate with GitHub:", + }, + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "🚀 Connect with GitHub", + emoji: true, + }, + url: authUrl, + style: "primary", + }, + ], + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: "💡 *Note:* After connecting, you can select which repositories to work with.", + }, + ], + }, + ], + close: { + type: "plain_text", + text: "Cancel", + }, + }, + }); + + logger.info(`GitHub login modal opened for user ${userId}`); + } catch (error) { + logger.error("Failed to open GitHub login modal:", error); + } +} + +/** + * Handle GitHub connect action - initiates OAuth flow + */ +export async function handleGitHubConnect( + userId: string, + channelId: string, + client: any +): Promise { + try { + // Generate OAuth URL with user ID + const authUrl = generateGitHubAuthUrl(userId); + + await client.chat.postMessage({ + channel: channelId, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: "🔗 *Connect your GitHub account*\n\nClick the link below to authorize Peerbot to access your GitHub repositories:", + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `<${authUrl}|Connect with GitHub>`, + }, + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: "🔒 We'll only access repositories you explicitly grant permission to", + }, + ], + }, + ], + }); + + logger.info(`GitHub connect initiated for user ${userId}`); + } catch (error) { + logger.error("Failed to initiate GitHub connect:", error); + await client.chat.postMessage({ + channel: channelId, + text: "Failed to generate GitHub login link. Please try again.", + }); + } +} + +/** + * Handle GitHub logout + */ +export async function handleGitHubLogout( + userId: string, + client: any +): Promise { + try { + const dbPool = getDbPool(process.env.DATABASE_URL!); + + // Remove GitHub token and username from database + await dbPool.query( + `DELETE FROM user_environ + WHERE user_id = (SELECT id FROM users WHERE platform = 'slack' AND platform_user_id = $1) + AND name IN ('GITHUB_TOKEN', 'GITHUB_USER')`, + [userId.toUpperCase()] + ); + + logger.info(`GitHub logout completed for user ${userId}`); + + // Send confirmation + const im = await client.conversations.open({ users: userId }); + if (im.channel?.id) { + await client.chat.postMessage({ + channel: im.channel.id, + text: "✅ Successfully logged out from GitHub", + }); + } + } catch (error) { + logger.error(`Failed to logout user ${userId}:`, error); + } +} + +/** + * Search user's accessible repositories + */ +export async function searchUserRepos( + query: string, + token: string +): Promise { + try { + let url: string; + + if (query) { + // Search user's repos with query + url = `https://api.github.com/user/repos?per_page=100&sort=updated`; + } else { + // Get recent repos if no query + url = `https://api.github.com/user/repos?per_page=20&sort=updated`; + } + + const response = await fetch(url, { + headers: { + Authorization: `token ${token}`, + Accept: "application/vnd.github.v3+json", + }, + }); + + if (!response.ok) { + logger.warn( + `GitHub API error for user repos: ${response.status} ${response.statusText}` + ); + return []; + } + + const repos = (await response.json()) as any; + + // Filter by query if provided + if (query) { + const lowerQuery = query.toLowerCase(); + return repos.filter( + (repo: any) => + repo.name.toLowerCase().includes(lowerQuery) || + repo.full_name.toLowerCase().includes(lowerQuery) + ); + } + + return repos; + } catch { + return []; + } +} + +/** + * Search organization repositories + */ +export async function searchOrgRepos( + query: string, + token: string +): Promise { + const org = process.env.GITHUB_ORGANIZATION; + + if (!org) return []; + + try { + // Get organization repos + const response = await fetch( + `https://api.github.com/orgs/${org}/repos?per_page=100&sort=updated`, + { + headers: { + Authorization: `token ${token}`, + Accept: "application/vnd.github.v3+json", + }, + } + ); + + if (!response.ok) { + logger.warn( + `GitHub API error for org repos: ${response.status} ${response.statusText}` + ); + return []; + } + + const repos = (await response.json()) as any; + + // Filter by query if provided + if (query) { + const lowerQuery = query.toLowerCase(); + return repos.filter( + (repo: any) => + repo.name.toLowerCase().includes(lowerQuery) || + repo.full_name.toLowerCase().includes(lowerQuery) + ); + } + + // Return top 20 if no query + return repos.slice(0, 20); + } catch { + return []; + } +} + +/** + * Handle repository search - provides Slack option format + */ +export async function handleRepositorySearch( + query: string, + userId: string +): Promise { + try { + const { token } = await getUserGitHubInfo(userId); + + if (!token) { + return []; + } + + // Search both user repos and org repos in parallel + const [userRepos, orgRepos] = await Promise.all([ + searchUserRepos(query, token), + searchOrgRepos(query, token), + ]); + + // Combine and deduplicate + const allRepos = [...userRepos, ...orgRepos]; + const uniqueRepos = Array.from( + new Map(allRepos.map((repo) => [repo.html_url, repo])).values() + ); + + // Format for Slack (limit to 100) + return uniqueRepos.slice(0, 100).map((repo) => ({ + text: { + type: "plain_text" as const, + text: repo.full_name, // Shows "owner/repo" + }, + value: repo.html_url, + })); + } catch (error) { + logger.error("Error in repository search:", error); + return []; + } +} + +/** + * Get user's GitHub info from database + */ +export async function getUserGitHubInfo(userId: string): Promise<{ + token: string | null; + username: string | null; +}> { + try { + const dbPool = getDbPool(process.env.DATABASE_URL!); + + const result = await dbPool.query( + `SELECT name, value + FROM user_environ + WHERE user_id = (SELECT id FROM users WHERE platform = 'slack' AND platform_user_id = $1) + AND name IN ('GITHUB_TOKEN', 'GITHUB_USER')`, + [userId.toUpperCase()] + ); + + let token = null; + let username = null; + + for (const row of result.rows) { + if (row.name === "GITHUB_TOKEN") { + try { + // Token is encrypted, decrypt it + token = decrypt(row.value); + } catch (error) { + logger.error( + `Failed to decrypt GitHub token for user ${userId}:`, + error + ); + token = null; + } + } else if (row.name === "GITHUB_USER") { + username = row.value; + } + } + + return { token, username }; + } catch (error) { + logger.error(`Failed to get GitHub info for user ${userId}:`, error); + return { token: null, username: null }; + } +} diff --git a/modules/github/index.ts b/modules/github/index.ts new file mode 100644 index 00000000..9c19cac7 --- /dev/null +++ b/modules/github/index.ts @@ -0,0 +1,439 @@ +import { z } from "zod"; +import { createLogger } from "@peerbot/shared"; +import type { + ModuleInterface, + HomeTabModule, + WorkerModule, + OrchestratorModule, + DispatcherModule, + SessionContext, + ActionButton, + ThreadContext, +} from "../types"; +import { GitHubRepositoryManager } from "./repository-manager"; +import { getUserGitHubInfo, GitHubOAuthHandler } from "./handlers"; +import { generateGitHubAuthUrl } from "./utils"; + +const logger = createLogger("github-module"); + +// GitHub-specific module interface (defined in GitHub module) +export interface GitHubModuleInterface extends ModuleInterface { + /** Add GitHub authentication to repository URL */ + addGitHubAuth(repositoryUrl: string, token: string): string; + + /** Generate OAuth URL for user authentication */ + generateOAuthUrl(userId: string): string; + + /** Check if GitHub CLI is authenticated */ + isGitHubCLIAuthenticated(workingDir: string): Promise; + + /** Get repository manager instance */ + getRepositoryManager(): any; + + /** Get user GitHub info */ + getUserInfo( + userId: string + ): Promise<{ token: string | null; username: string | null }>; +} + +// GitHub configuration schema (module-specific) +export const GitHubConfigSchema = z.object({ + appId: z.string().optional(), + privateKey: z.string().optional(), + clientId: z.string().optional(), + clientSecret: z.string().optional(), + installationId: z.string().optional(), + token: z.string().optional(), + organization: z.string().optional(), + repository: z.string().optional(), + ingressUrl: z.string().optional(), +}); + +export type GitHubConfig = z.infer; + +/** + * Loads GitHub configuration from environment variables + */ +export function loadGitHubConfig(): GitHubConfig { + return GitHubConfigSchema.parse({ + appId: process.env.GITHUB_APP_ID, + privateKey: process.env.GITHUB_PRIVATE_KEY, + clientId: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + installationId: process.env.GITHUB_INSTALLATION_ID, + token: process.env.GITHUB_TOKEN, + organization: process.env.GITHUB_ORGANIZATION, + repository: process.env.GITHUB_REPOSITORY, + ingressUrl: process.env.INGRESS_URL, + }); +} + +export class GitHubModule + implements + HomeTabModule, + WorkerModule, + OrchestratorModule, + DispatcherModule, + GitHubModuleInterface +{ + name = "github"; + private repoManager?: GitHubRepositoryManager; + private oauthHandler?: GitHubOAuthHandler; + + isEnabled(): boolean { + return !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET); + } + + async init(): Promise { + if (!this.isEnabled()) return; + + const config = loadGitHubConfig(); + this.repoManager = new GitHubRepositoryManager( + config, + process.env.DATABASE_URL + ); + this.oauthHandler = new GitHubOAuthHandler(process.env.DATABASE_URL!); + } + + registerEndpoints(app: any): void { + if (!this.isEnabled() || !this.oauthHandler) { + logger.warn( + "GitHub module not enabled or OAuth handler not initialized - skipping endpoint registration" + ); + return; + } + + if (!app) { + throw new Error("Express app is required for endpoint registration"); + } + + try { + // Register OAuth endpoints + app.get("/api/github/oauth/authorize", (req: any, res: any) => { + this.oauthHandler!.handleAuthorize(req, res); + }); + + app.get("/api/github/oauth/callback", (req: any, res: any) => { + this.oauthHandler!.handleCallback(req, res); + }); + + app.post("/api/github/oauth/revoke", (req: any, res: any) => { + this.oauthHandler!.handleRevoke(req, res); + }); + + logger.info("✅ GitHub OAuth endpoints registered successfully"); + } catch (error) { + logger.error("Failed to register GitHub OAuth endpoints:", error); + throw error; + } + } + + async renderHomeTab(userId: string): Promise { + if (!this.repoManager) return []; + + const { token, username } = await getUserGitHubInfo(userId); + const isGitHubConnected = !!token; + + if (!isGitHubConnected) { + const authUrl = generateGitHubAuthUrl(userId); + return [ + { + type: "section", + text: { + type: "mrkdwn", + text: "*🔗 GitHub Integration*\nConnect your GitHub account to work with repositories", + }, + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "🔗 Login with GitHub", + emoji: true, + }, + url: authUrl, + style: "primary", + }, + ], + }, + ]; + } + + const userRepo = await this.repoManager.getUserRepository( + username!, + userId + ); + + if (userRepo) { + const repoUrl = userRepo.repositoryUrl.replace(/\.git$/, ""); + const repoDisplayName = repoUrl.replace( + /^https?:\/\/(www\.)?github\.com\//, + "" + ); + + return [ + { + type: "section", + text: { + type: "mrkdwn", + text: `*Active Repository:*\n<${repoUrl}|${repoDisplayName}>`, + }, + accessory: { + type: "button", + text: { type: "plain_text", text: "🔄 Change Repository" }, + action_id: "open_repository_modal", + }, + }, + ]; + } + + return [ + { + type: "section", + text: { + type: "mrkdwn", + text: `*🔗 GitHub Integration*\nConnected as @${username}`, + }, + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "Select Repository", + emoji: true, + }, + action_id: "open_repository_modal", + }, + { + type: "button", + text: { + type: "plain_text", + text: "Disconnect", + emoji: true, + }, + action_id: "github_logout", + }, + ], + }, + ]; + } + + async initWorkspace(config: { + repositoryUrl?: string; + workspaceDir?: string; + }): Promise { + if (!config.repositoryUrl || !config.workspaceDir) return; + + // Clone repository if not already present + const repoName = this.extractRepoName(config.repositoryUrl); + const targetDir = `${config.workspaceDir}/${repoName}`; + + // Check if repo already exists + try { + const fs = await import("node:fs"); + if (!fs.existsSync(targetDir)) { + const { execSync } = await import("node:child_process"); + execSync(`git clone ${config.repositoryUrl} ${targetDir}`, { + stdio: "inherit", + cwd: config.workspaceDir, + }); + } + } catch (error) { + logger.warn(`Failed to clone repository: ${error}`); + } + } + + async onSessionStart(context: SessionContext): Promise { + if (context.repositoryUrl) { + const repoName = this.extractRepoName(context.repositoryUrl); + context.systemPrompt += `\n\nYou are working with the GitHub repository: ${repoName}`; + } + return context; + } + + async onSessionEnd(context: SessionContext): Promise { + if (!context.repositoryUrl) return []; + + return [ + { + text: "Create Pull Request", + action_id: "create_pull_request", + style: "primary", + }, + { + text: "Commit Changes", + action_id: "commit_changes", + }, + ]; + } + + async buildEnvVars( + userId: string, + baseEnv: Record + ): Promise> { + const { token, username } = await getUserGitHubInfo(userId); + + if (token && username) { + return { + ...baseEnv, + GITHUB_TOKEN: token, + GITHUB_USER: username, + }; + } + + return baseEnv; + } + + /** + * Add GitHub authentication to repository URL + */ + addGitHubAuth(repositoryUrl: string, token: string): string { + try { + const url = new URL(repositoryUrl); + if (url.hostname === "github.com") { + // Convert to authenticated HTTPS URL + url.username = "x-access-token"; + url.password = token; + return url.toString(); + } + return repositoryUrl; + } catch (error) { + logger.warn(`Failed to parse repository URL: ${repositoryUrl}`, error); + return repositoryUrl; + } + } + + /** + * Generate GitHub OAuth URL for authentication + */ + generateOAuthUrl(userId: string): string { + const baseUrl = process.env.INGRESS_URL || "http://localhost:8080"; + return `${baseUrl}/api/github/oauth/authorize?userId=${userId}`; + } + + /** + * Check if GitHub CLI is authenticated + */ + async isGitHubCLIAuthenticated(workingDir: string): Promise { + try { + const { execSync } = await import("node:child_process"); + execSync("gh auth status", { + cwd: workingDir, + stdio: "pipe", + timeout: 3000, + }); + return true; + } catch (error) { + // If GH_TOKEN is set, authentication is available even if gh auth status fails + return !!(process.env.GH_TOKEN || process.env.GITHUB_TOKEN); + } + } + + private extractRepoName(url: string): string { + const match = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/); + return match ? `${match[1]}/${match[2]}` : url; + } + + async generateActionButtons(context: ThreadContext): Promise { + if (!this.repoManager) { + return []; + } + + // Extract GitHub-specific fields from moduleFields + const githubFields = context.moduleFields?.github || {}; + + const { generateGitHubActionButtons } = await import("./actions"); + const buttons = await generateGitHubActionButtons( + context.userId, + githubFields.gitBranch, + githubFields.hasGitChanges, + githubFields.pullRequestUrl, + this.repoManager, + context.slackClient + ); + + return ( + buttons?.map((button) => ({ + text: button.text?.text || "", + action_id: button.action_id, + style: button.style, + value: button.value, + })) || [] + ); + } + + async handleAction( + actionId: string, + userId: string, + context: any + ): Promise { + // Handle GitHub-specific actions + switch (actionId) { + case "open_github_login_modal": { + const { handleGitHubLoginModal } = await import("./handlers"); + await handleGitHubLoginModal(userId, context.body, context.client); + return true; + } + + case "github_login": + case "github_connect": { + const { handleGitHubConnect } = await import("./handlers"); + await handleGitHubConnect(userId, context.channelId, context.client); + return true; + } + + case "github_logout": { + const { handleGitHubLogout } = await import("./handlers"); + await handleGitHubLogout(userId, context.client); + // Update home tab after logout - delegate back to action handler + if (context.updateAppHome) { + await context.updateAppHome(userId, context.client); + } + return true; + } + + case "open_repository_modal": + // This is handled by repository-modal-utils which should also be moved to module + return false; // Let dispatcher handle for now + + default: + // Check if it's a GitHub-specific action (prefixed with github_ or contains repo operations) + if ( + actionId.startsWith("github_") || + actionId.includes("pr_") || + actionId.includes("view_pr_") + ) { + // This is a GitHub action but not one we handle directly + return false; + } + return false; + } + } + + /** + * Handle repository search options - called from slack external select + */ + async handleRepositorySearch(query: string, userId: string): Promise { + const { handleRepositorySearch } = await import("./handlers"); + return handleRepositorySearch(query, userId); + } + + getRepositoryManager(): GitHubRepositoryManager | undefined { + return this.repoManager; + } + + async getUserInfo(userId: string) { + return getUserGitHubInfo(userId); + } +} + +export * from "./repository-manager"; +export * from "./handlers"; +export * from "./utils"; +export * from "./errors"; diff --git a/packages/dispatcher/src/github/repository-manager.ts b/modules/github/repository-manager.ts similarity index 94% rename from packages/dispatcher/src/github/repository-manager.ts rename to modules/github/repository-manager.ts index 9f8737db..ad0b61f9 100644 --- a/packages/dispatcher/src/github/repository-manager.ts +++ b/modules/github/repository-manager.ts @@ -2,12 +2,26 @@ import { Octokit } from "@octokit/rest"; import { createLogger } from "@peerbot/shared"; -import type { GitHubConfig, UserRepository } from "../types"; -const logger = createLogger("dispatcher"); +const logger = createLogger("github-module"); -// Import from shared package -import { GitHubRepositoryError, getDbPool } from "@peerbot/shared"; +// Import from shared package and local module +import { getDbPool } from "@peerbot/shared"; +import { GitHubRepositoryError } from "./errors"; +import type { GitHubConfig } from "./index"; + +export interface GitHubModuleConfig extends GitHubConfig { + // All config is already in the base GitHubConfig type +} + +export interface UserRepository { + username: string; + repositoryName: string; + repositoryUrl: string; + cloneUrl: string; + createdAt: number; + lastUsed: number; +} export class GitHubRepositoryManager { private octokit: Octokit; diff --git a/packages/dispatcher/src/utils/github-utils.ts b/modules/github/utils.ts similarity index 100% rename from packages/dispatcher/src/utils/github-utils.ts rename to modules/github/utils.ts diff --git a/modules/index.ts b/modules/index.ts new file mode 100644 index 00000000..d5a7e0a9 --- /dev/null +++ b/modules/index.ts @@ -0,0 +1,89 @@ +import type { + ModuleInterface, + HomeTabModule, + WorkerModule, + OrchestratorModule, + DispatcherModule, +} from "./types"; + +export class ModuleRegistry { + private modules: Map = new Map(); + + register(module: ModuleInterface): void { + if (module.isEnabled()) { + this.modules.set(module.name, module); + } + } + + async initAll(): Promise { + // Auto-register available modules if not already registered + await this.autoRegisterModules(); + + for (const module of this.modules.values()) { + if (module.init) { + await module.init(); + } + } + } + + registerEndpoints(app: any): void { + for (const module of this.modules.values()) { + if (module.registerEndpoints) { + try { + module.registerEndpoints(app); + } catch (error) { + console.error( + `Failed to register endpoints for module ${module.name}:`, + error + ); + } + } + } + } + + private async autoRegisterModules(): Promise { + // Lazy-load GitHub module to avoid importing dispatcher-specific dependencies + try { + const { GitHubModule } = await import("./github"); + const gitHubModule = new GitHubModule(); + if (!this.modules.has(gitHubModule.name)) { + this.register(gitHubModule); + } + } catch (error) { + console.debug("GitHub module not available:", error); + } + } + + getHomeTabModules(): HomeTabModule[] { + return Array.from(this.modules.values()).filter( + (m): m is HomeTabModule => "renderHomeTab" in m + ); + } + + getWorkerModules(): WorkerModule[] { + return Array.from(this.modules.values()).filter( + (m): m is WorkerModule => "onSessionStart" in m || "onSessionEnd" in m + ); + } + + getOrchestratorModules(): OrchestratorModule[] { + return Array.from(this.modules.values()).filter( + (m): m is OrchestratorModule => "buildEnvVars" in m + ); + } + + getDispatcherModules(): DispatcherModule[] { + return Array.from(this.modules.values()).filter( + (m): m is DispatcherModule => "generateActionButtons" in m + ); + } + + getModule(name: string): T | undefined { + return this.modules.get(name) as T; + } +} + +// Global registry instance +export const moduleRegistry = new ModuleRegistry(); + +export * from "./types"; diff --git a/modules/types.ts b/modules/types.ts new file mode 100644 index 00000000..94b85db5 --- /dev/null +++ b/modules/types.ts @@ -0,0 +1,82 @@ +export interface ModuleInterface { + /** Module identifier */ + name: string; + + /** Check if module should be enabled based on environment */ + isEnabled(): boolean; + + /** Initialize module - called once at startup */ + init?(): Promise; + + /** Register HTTP endpoints with Express app */ + registerEndpoints?(app: any): void; +} + +export interface HomeTabModule extends ModuleInterface { + /** Render home tab elements */ + renderHomeTab?(userId: string): Promise; + + /** Handle home tab interactions */ + handleHomeTabAction?( + actionId: string, + userId: string, + value?: any + ): Promise; +} + +export interface WorkerModule extends ModuleInterface { + /** Initialize workspace - called when worker starts session */ + initWorkspace?(config: any): Promise; + + /** Called at session start - can modify system prompt */ + onSessionStart?(context: SessionContext): Promise; + + /** Called at session end - can add action buttons */ + onSessionEnd?(context: SessionContext): Promise; +} + +export interface OrchestratorModule extends ModuleInterface { + /** Build environment variables for worker container */ + buildEnvVars?( + userId: string, + baseEnv: Record + ): Promise>; + + /** Get container address for module-specific services */ + getContainerAddress?(): string; +} + +export interface DispatcherModule extends ModuleInterface { + /** Generate action buttons for thread responses */ + generateActionButtons?(context: ThreadContext): Promise; + + /** Handle action button clicks */ + handleAction?( + actionId: string, + userId: string, + context: any + ): Promise; +} + +export interface SessionContext { + userId: string; + threadId: string; + repositoryUrl?: string; + systemPrompt: string; + workspace?: any; +} + +export interface ActionButton { + text: string; + action_id: string; + style?: "primary" | "danger"; + value?: string; +} + +export interface ThreadContext { + userId: string; + channelId: string; + threadTs: string; + slackClient?: any; + moduleFields?: Record; // Generic fields for modules to use +} diff --git a/packages/dispatcher/agent.md b/packages/dispatcher/agent.md index c9a02f4e..bf633979 100644 --- a/packages/dispatcher/agent.md +++ b/packages/dispatcher/agent.md @@ -27,6 +27,7 @@ Slack event router and communication hub. Entry point for all Slack interactions - **One thread = One worker**: All messages in a Slack thread go to same worker deployment - Use `targetThreadId` for consistent worker naming: `peerbot-worker-{userId}-{threadId}` - Never use message timestamps for worker identification +- **NEVER use console.log/warn/error** - ALWAYS use logger from `@peerbot/shared` ## Environment Variables - `SLACK_BOT_TOKEN`: Slack API access diff --git a/packages/dispatcher/src/index.ts b/packages/dispatcher/src/index.ts index 6b9404a4..5d4a698b 100644 --- a/packages/dispatcher/src/index.ts +++ b/packages/dispatcher/src/index.ts @@ -8,7 +8,6 @@ initSentry(); import { join } from "node:path"; import { App, ExpressReceiver, LogLevel } from "@slack/bolt"; import { config as dotenvConfig } from "dotenv"; -import { GitHubRepositoryManager } from "./github/repository-manager"; import { createLogger } from "@peerbot/shared"; const logger = createLogger("dispatcher"); @@ -21,13 +20,12 @@ import { QueueProducer } from "./queue/task-queue-producer"; import { setupHealthEndpoints } from "./simple-http"; import { SlackEventHandlers } from "./slack/slack-event-handlers"; import type { DispatcherConfig } from "./types"; +import { moduleRegistry } from "../../../modules"; export class SlackDispatcher { private app: App; private queueProducer: QueueProducer; private threadResponseConsumer?: ThreadResponseConsumer; - private repoManager: GitHubRepositoryManager; - private eventHandlers?: SlackEventHandlers; private anthropicProxy?: AnthropicProxy; private config: DispatcherConfig; @@ -95,10 +93,6 @@ export class SlackDispatcher { // Initialize queue producer - use DATABASE_URL for consistency logger.info("Initializing queue mode"); this.queueProducer = new QueueProducer(config.queues.connectionString); - this.repoManager = new GitHubRepositoryManager( - config.github, - config.queues.connectionString - ); // ThreadResponseConsumer will be created after event handlers are initialized this.setupErrorHandling(); @@ -131,6 +125,10 @@ export class SlackDispatcher { // Setup health endpoints will be called after event handlers are created + // Initialize modules + await moduleRegistry.initAll(); + logger.info("✅ Modules initialized"); + // Start queue producer await this.queueProducer.start(); logger.info("✅ Queue producer started"); @@ -145,11 +143,9 @@ export class SlackDispatcher { } // We'll test auth after starting the server - logger.info("Starting Slack app with token:", { - firstChars: this.config.slack.token?.substring(0, 10), - length: this.config.slack.token?.length, - signingSecretLength: this.config.slack.signingSecret?.length, - }); + logger.debug( + `Starting Slack app in ${this.config.slack.socketMode ? "Socket Mode" : "HTTP Mode"}` + ); if (this.config.slack.socketMode === false) { // In HTTP mode, start with the port @@ -298,7 +294,6 @@ export class SlackDispatcher { // Log configuration logger.info("Configuration:"); - logger.info(`- GitHub Organization: ${this.config.github.organization}`); logger.info( `- Session Timeout: ${this.config.sessionTimeoutMinutes} minutes` ); @@ -387,79 +382,16 @@ export class SlackDispatcher { // Initialize queue-based event handlers logger.info("Initializing queue-based event handlers"); - this.eventHandlers = new SlackEventHandlers( - this.app, - this.queueProducer, - this.repoManager, - config - ); + new SlackEventHandlers(this.app, this.queueProducer, config); - // Now create ThreadResponseConsumer with access to user mappings + // Now create ThreadResponseConsumer this.threadResponseConsumer = new ThreadResponseConsumer( config.queues.connectionString, - config.slack.token, - this.repoManager, - this.eventHandlers.getUserMappings() + config.slack.token ); - // Setup health endpoints with home tab update callback - setupHealthEndpoints( - this.anthropicProxy, - config.queues.connectionString, - async (userId: string) => { - if (this.eventHandlers) { - const client = this.app.client; - await (this.eventHandlers as any).updateAppHome(userId, client); - - // Send repository selection message after GitHub login - try { - const im = await client.conversations.open({ users: userId }); - await client.chat.postMessage({ - channel: im.channel?.id || userId, - text: "GitHub connected successfully!", - blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: "✅ *GitHub connected successfully!*\n\nNow you can select a repository to work with:", - }, - }, - { - type: "actions", - elements: [ - { - type: "button", - text: { - type: "plain_text", - text: "Select Repository", - emoji: true, - }, - action_id: "select_repository", - style: "primary", - }, - ], - }, - { - type: "context", - elements: [ - { - type: "mrkdwn", - text: "You can also visit the Home tab to manage your repositories", - }, - ], - }, - ], - }); - } catch (error) { - logger.error( - "Failed to send repository selection message:", - error - ); - } - } - } - ); + // Setup health endpoints + setupHealthEndpoints(this.anthropicProxy); } catch (error) { logger.error("Failed to get bot info:", error); throw new Error("Failed to initialize bot - could not get bot user ID"); @@ -576,14 +508,6 @@ async function main() { allowedUsers: process.env.SLACK_ALLOWED_USERS?.split(","), allowedChannels: process.env.SLACK_ALLOWED_CHANNELS?.split(","), }, - github: { - token: process.env.GITHUB_TOKEN || "", // Optional - users can use OAuth instead - organization: process.env.GITHUB_ORGANIZATION || "", // Empty string means use authenticated user - repository: process.env.GITHUB_REPOSITORY, // Optional override repository URL - clientId: process.env.GITHUB_CLIENT_ID, // GitHub OAuth App Client ID - clientSecret: process.env.GITHUB_CLIENT_SECRET, // GitHub OAuth App Client Secret - ingressUrl: process.env.INGRESS_URL, // Public URL for OAuth callbacks - }, claude: { allowedTools: process.env.ALLOWED_TOOLS?.split(","), model: process.env.AGENT_DEFAULT_MODEL, @@ -626,12 +550,6 @@ async function main() { if (!config.slack.token) { throw new Error("SLACK_BOT_TOKEN is required"); } - // GITHUB_TOKEN is optional - users can login with OAuth instead - if (!config.github.token) { - logger.warn( - "GITHUB_TOKEN not provided - users must login with GitHub OAuth to access repositories" - ); - } if (!config.queues.connectionString) { throw new Error("DATABASE_URL is required"); } diff --git a/packages/dispatcher/src/queue/slack-thread-processor.ts b/packages/dispatcher/src/queue/slack-thread-processor.ts index 9eb2b50c..0801390d 100644 --- a/packages/dispatcher/src/queue/slack-thread-processor.ts +++ b/packages/dispatcher/src/queue/slack-thread-processor.ts @@ -2,9 +2,9 @@ import { WebClient } from "@slack/web-api"; import PgBoss from "pg-boss"; -import type { GitHubRepositoryManager } from "../github/repository-manager"; +import { moduleRegistry } from "../../../../modules"; import { processMarkdownAndBlockkit } from "../converters/blockkit-processor"; -import { generateGitHubActionButtons } from "../converters/github-actions"; +// GitHub action buttons now handled through module system import { convertMarkdownToSlack } from "../converters/markdown-to-slack"; import { createLogger } from "@peerbot/shared"; @@ -36,20 +36,11 @@ export class ThreadResponseConsumer { private pgBoss: PgBoss; private slackClient: WebClient; private isRunning = false; - private repoManager: GitHubRepositoryManager; - private userMappings: Map; // slackUserId -> githubUsername private sessionBotMessages: Map = new Map(); // sessionKey -> botMessageTs - constructor( - connectionString: string, - slackToken: string, - repoManager: GitHubRepositoryManager, - userMappings: Map - ) { + constructor(connectionString: string, slackToken: string) { this.pgBoss = new PgBoss(connectionString); this.slackClient = new WebClient(slackToken); - this.repoManager = repoManager; - this.userMappings = userMappings; } /** @@ -392,19 +383,50 @@ export class ThreadResponseConsumer { ); } - // Get GitHub action buttons for this session - const githubActionButtons = await generateGitHubActionButtons( - userId, - data.gitBranch, - data.hasGitChanges, - data.pullRequestUrl, - this.userMappings, - this.repoManager, - this.slackClient - ); + // Get action buttons from modules + const actionButtons: any[] = []; + const dispatcherModules = moduleRegistry.getDispatcherModules(); + for (const module of dispatcherModules) { + if (module.generateActionButtons) { + const moduleButtons = await module.generateActionButtons({ + userId, + channelId: data.channelId, + threadTs: data.threadTs, + slackClient: this.slackClient, + moduleFields: { + github: { + gitBranch: data.gitBranch, + hasGitChanges: data.hasGitChanges, + pullRequestUrl: data.pullRequestUrl, + }, + }, + }); + actionButtons.push( + ...moduleButtons + .filter((btn) => { + // Validate required button properties + if (!btn.text || !btn.action_id) { + logger.warn( + `Invalid button from module ${module.name}: missing text or action_id`, + btn + ); + return false; + } + return true; + }) + .map((btn) => ({ + type: "button", + text: { type: "plain_text", text: btn.text }, + action_id: btn.action_id, + style: btn.style, + value: btn.value, + })) + ); + } + } - // Add GitHub action buttons as a separate actions block - if (githubActionButtons && githubActionButtons.length > 0) { + // Add action buttons as a separate actions block + if (actionButtons && actionButtons.length > 0) { // Add a divider before the GitHub actions if there are other blocks if (result.blocks.length > 0) { result.blocks.push({ type: "divider" }); @@ -413,7 +435,7 @@ export class ThreadResponseConsumer { // Add the GitHub action buttons as an actions block result.blocks.push({ type: "actions", - elements: githubActionButtons, + elements: actionButtons, }); } @@ -612,19 +634,50 @@ export class ThreadResponseConsumer { ], }; - // Get GitHub action buttons for this session - const githubActionButtons = await generateGitHubActionButtons( - userId, - data.gitBranch, - data.hasGitChanges, - data.pullRequestUrl, - this.userMappings, - this.repoManager, - this.slackClient - ); + // Get action buttons from modules + const actionButtons: any[] = []; + const dispatcherModules = moduleRegistry.getDispatcherModules(); + for (const module of dispatcherModules) { + if (module.generateActionButtons) { + const moduleButtons = await module.generateActionButtons({ + userId, + channelId: data.channelId, + threadTs: data.threadTs, + slackClient: this.slackClient, + moduleFields: { + github: { + gitBranch: data.gitBranch, + hasGitChanges: data.hasGitChanges, + pullRequestUrl: data.pullRequestUrl, + }, + }, + }); + actionButtons.push( + ...moduleButtons + .filter((btn) => { + // Validate required button properties + if (!btn.text || !btn.action_id) { + logger.warn( + `Invalid button from module ${module.name}: missing text or action_id`, + btn + ); + return false; + } + return true; + }) + .map((btn) => ({ + type: "button", + text: { type: "plain_text", text: btn.text }, + action_id: btn.action_id, + style: btn.style, + value: btn.value, + })) + ); + } + } - // Add GitHub action buttons if available - if (githubActionButtons && githubActionButtons.length > 0) { + // Add action buttons if available + if (actionButtons && actionButtons.length > 0) { // Add a divider before the GitHub actions errorResult.blocks.push({ type: "divider", @@ -633,7 +686,7 @@ export class ThreadResponseConsumer { // Add the GitHub action buttons as an actions block errorResult.blocks.push({ type: "actions", - elements: githubActionButtons, + elements: actionButtons, } as any); } diff --git a/packages/dispatcher/src/simple-http.ts b/packages/dispatcher/src/simple-http.ts index b92c2997..db228a36 100644 --- a/packages/dispatcher/src/simple-http.ts +++ b/packages/dispatcher/src/simple-http.ts @@ -1,20 +1,15 @@ import http from "node:http"; import express from "express"; import { createLogger } from "@peerbot/shared"; +import { moduleRegistry } from "../../../modules"; const logger = createLogger("http"); -import { GitHubOAuthHandler } from "./oauth/github-oauth-handler"; import type { AnthropicProxy } from "./proxy/anthropic-proxy"; let healthServer: http.Server | null = null; let proxyApp: express.Application | null = null; -let oauthHandler: GitHubOAuthHandler | null = null; -export function setupHealthEndpoints( - anthropicProxy?: AnthropicProxy, - databaseUrl?: string, - homeTabUpdateCallback?: (userId: string) => Promise -) { +export function setupHealthEndpoints(anthropicProxy?: AnthropicProxy) { if (healthServer) return; // Create Express app for proxy and health endpoints @@ -43,26 +38,9 @@ export function setupHealthEndpoints( logger.info("✅ Anthropic proxy enabled at :8080/api/anthropic"); } - // Add GitHub OAuth endpoints if database URL is provided - if (databaseUrl && process.env.GITHUB_CLIENT_ID) { - oauthHandler = new GitHubOAuthHandler(databaseUrl, homeTabUpdateCallback); - - proxyApp.get("/api/github/oauth/authorize", (req, res) => - oauthHandler!.handleAuthorize(req, res) - ); - - proxyApp.get("/api/github/oauth/callback", (req, res) => - oauthHandler!.handleCallback(req, res) - ); - - proxyApp.post("/api/github/logout", (req, res) => - oauthHandler!.handleLogout(req, res) - ); - - logger.info( - "✅ GitHub OAuth endpoints enabled at :8080/api/github/oauth/*" - ); - } + // Register module endpoints + moduleRegistry.registerEndpoints(proxyApp); + logger.info("✅ Module endpoints registered"); // Create HTTP server with Express app healthServer = http.createServer(proxyApp); diff --git a/packages/dispatcher/src/slack/handlers/action-handler.ts b/packages/dispatcher/src/slack/handlers/action-handler.ts index cd96c958..22b0fcc3 100644 --- a/packages/dispatcher/src/slack/handlers/action-handler.ts +++ b/packages/dispatcher/src/slack/handlers/action-handler.ts @@ -2,16 +2,10 @@ import { createLogger } from "@peerbot/shared"; // import { getDbPool } from "@peerbot/shared"; // Currently unused const logger = createLogger("dispatcher"); -import type { GitHubRepositoryManager } from "../../github/repository-manager"; import type { QueueProducer } from "../../queue/task-queue-producer"; -import type { DispatcherConfig, SlackContext } from "../../types"; -import { generateGitHubAuthUrl } from "../../utils/github-utils"; +import type { SlackContext } from "../../types"; import type { MessageHandler } from "./message-handler"; -import { - handleGitHubConnect, - handleGitHubLogout, - getUserGitHubInfo, -} from "./github-handler"; +// Dynamic module imports to avoid hardcoded dependencies import { handleTryDemo } from "./demo-handler"; import { openRepositoryModal } from "./repository-modal-utils"; import { @@ -22,13 +16,9 @@ import { export class ActionHandler { constructor( - private repoManager: GitHubRepositoryManager, - _queueProducer: QueueProducer, // Not used directly in ActionHandler - private config: DispatcherConfig, + _queueProducer: QueueProducer, private messageHandler: MessageHandler - ) { - // queueProducer passed for consistency but not used directly - } + ) {} /** * Handle block action events @@ -43,191 +33,136 @@ export class ActionHandler { ): Promise { logger.info(`Handling block action: ${actionId}`); - switch (actionId) { - case "github_login": - await this.handleGitHubLogin(userId, client); - break; - - case "github_logout": - await handleGitHubLogout(userId, client); - // Update home tab after logout - await this.updateAppHome(userId, client); - break; - - case "open_repository_modal": - await openRepositoryModal({ - userId, - body, + // Try to handle action through modules first + let handled = false; + let dispatcherModules: any[] = []; + try { + const { moduleRegistry } = await import("../../../../../modules"); + dispatcherModules = moduleRegistry.getDispatcherModules(); + } catch (error) { + logger.warn("Module registry not available, skipping module actions"); + } + for (const module of dispatcherModules) { + if (module.handleAction) { + const moduleHandled = await module.handleAction(actionId, userId, { + channelId, client, - checkAdminStatus: false, - getGitHubUserInfo: getUserGitHubInfo, - }); - break; - - case "open_github_login_modal": { - // Open modal with GitHub OAuth link - const authUrl = generateGitHubAuthUrl(userId); - await client.views.open({ - trigger_id: body.trigger_id, - view: { - type: "modal", - callback_id: "github_login_modal", - title: { - type: "plain_text", - text: "Connect GitHub", - }, - blocks: [ - { - type: "header", - text: { - type: "plain_text", - text: "🔗 Connect Your GitHub Account", - emoji: true, - }, - }, - { - type: "section", - text: { - type: "mrkdwn", - text: - "Connect your GitHub account to:\n\n" + - "• Access your repositories\n" + - "• Create new projects\n" + - "• Manage code with AI assistance\n\n" + - "*Your connection is secure and encrypted.*", - }, - }, - { - type: "divider", - }, - { - type: "section", - text: { - type: "mrkdwn", - text: "Click the button below to authenticate with GitHub:", - }, - }, - { - type: "actions", - elements: [ - { - type: "button", - text: { - type: "plain_text", - text: "🚀 Connect with GitHub", - emoji: true, - }, - url: authUrl, - style: "primary", - }, - ], - }, - { - type: "context", - elements: [ - { - type: "mrkdwn", - text: "💡 *Note:* After connecting, you can select which repositories to work with.", - }, - ], - }, - ], - close: { - type: "plain_text", - text: "Cancel", - }, - }, + body, + updateAppHome: this.updateAppHome.bind(this), }); - break; + if (moduleHandled) { + handled = true; + break; + } } + } - case "github_connect": - await handleGitHubConnect(userId, channelId, client); - break; + if (!handled) { + switch (actionId) { + case "open_repository_modal": { + // Get GitHub functions from module + try { + const { moduleRegistry } = await import("../../../../../modules"); + const gitHubModule = moduleRegistry.getModule("github"); + if (gitHubModule) { + const { getUserGitHubInfo } = await import( + "../../../../../modules/github/handlers" + ); + await openRepositoryModal({ + userId, + body, + client, + checkAdminStatus: false, + getGitHubUserInfo: getUserGitHubInfo, + }); + } + } catch (error) { + logger.warn("GitHub module not available for repository modal"); + } + break; + } - case "try_demo": { - // Check if this is from the home tab (view type will be 'home') - const fromHomeTab = body.view?.type === "home"; + case "try_demo": { + // Check if this is from the home tab (view type will be 'home') + const fromHomeTab = body.view?.type === "home"; - // Get the message timestamp to keep demo response in same thread (if not from home) - const demoMessageTs = body.message?.ts; + // Get the message timestamp to keep demo response in same thread (if not from home) + const demoMessageTs = body.message?.ts; - // Pass the fromHomeTab flag to ensure DM is sent when clicked from home - await handleTryDemo( - userId, - channelId, - client, - demoMessageTs, - fromHomeTab - ); - - // Clear cache and update home tab after demo setup - const username = await this.messageHandler.getOrCreateUserMapping( - userId, - client - ); - this.messageHandler.clearCacheForUser(username); - await this.updateAppHome(userId, client); - break; - } - - default: - // Handle blockkit form button clicks - if (actionId.startsWith("blockkit_form_")) { - await handleBlockkitForm( - actionId, + // Pass the fromHomeTab flag to ensure DM is sent when clicked from home + await handleTryDemo( userId, channelId, - messageTs, - body, - client - ); - } - // Handle executable code block buttons - else if ( - actionId.match(/^(bash|python|javascript|js|typescript|ts|sql|sh)_/) - ) { - await handleExecutableCodeBlock( - actionId, - userId, - channelId, - messageTs, - body, client, - (context: SlackContext, userRequest: string, client: any) => - this.messageHandler.handleUserRequest( - context, - userRequest, - client - ) - ); - } - // Handle stop worker button clicks - else if (actionId.startsWith("stop_worker_")) { - const deploymentName = actionId.replace("stop_worker_", ""); - await handleStopWorker( - deploymentName, - userId, - channelId, - messageTs, - client - ); - } - // Handle GitHub Pull Request button clicks - else if (actionId.startsWith("github_pr_")) { - await this.handleGitHubPullRequestAction( - actionId, - userId, - channelId, - messageTs, - body, - client - ); - } else { - logger.info( - `Unsupported action: ${actionId} from user ${userId} in channel ${channelId}` + demoMessageTs, + fromHomeTab ); + + // Update home tab after demo setup + await this.updateAppHome(userId, client); + break; } + + default: + // Handle blockkit form button clicks + if (actionId.startsWith("blockkit_form_")) { + await handleBlockkitForm( + actionId, + userId, + channelId, + messageTs, + body, + client + ); + } + // Handle executable code block buttons + else if ( + actionId.match(/^(bash|python|javascript|js|typescript|ts|sql|sh)_/) + ) { + await handleExecutableCodeBlock( + actionId, + userId, + channelId, + messageTs, + body, + client, + (context: SlackContext, userRequest: string, client: any) => + this.messageHandler.handleUserRequest( + context, + userRequest, + client + ) + ); + } + // Handle stop worker button clicks + else if (actionId.startsWith("stop_worker_")) { + const deploymentName = actionId.replace("stop_worker_", ""); + await handleStopWorker( + deploymentName, + userId, + channelId, + messageTs, + client + ); + } + // Handle GitHub Pull Request button clicks + else if (actionId.startsWith("github_pr_")) { + await this.handleGitHubPullRequestAction( + actionId, + userId, + channelId, + messageTs, + body, + client + ); + } else { + logger.info( + `Unsupported action: ${actionId} from user ${userId} in channel ${channelId}` + ); + } + + break; + } } } @@ -334,68 +269,18 @@ export class ActionHandler { ); try { - const username = await this.messageHandler.getOrCreateUserMapping( - userId, - client - ); - - // Check if user has GitHub token - const githubUser = await getUserGitHubInfo(userId); - const isGitHubConnected = !!githubUser.token; - - let repository; - let readmeSection: string | null = null; - - // Check for environment overrides - const userEnv = await this.messageHandler.getUserEnvironment(userId); - const overrideRepo = userEnv.GITHUB_REPOSITORY as string | undefined; - - // Try to get or create repository + // Get GitHub connection status for demo purposes + let githubUser = { token: null, username: null }; try { - if (overrideRepo) { - const repoUrl = overrideRepo.replace(/\/$/, "").replace(/\.git$/, ""); - const repoName = repoUrl.split("/").pop() || "unknown"; - - repository = { - username, - repositoryName: repoName, - repositoryUrl: repoUrl, - cloneUrl: repoUrl.endsWith(".git") ? repoUrl : `${repoUrl}.git`, - createdAt: Date.now(), - lastUsed: Date.now(), - }; - - logger.info( - `Using environment override repository for user ${userId}: ${repoUrl}` - ); - } else { - // Try to get existing repository - repository = await this.repoManager.getUserRepository( - username, - userId - ); - - // If no cached repository and we have a token, create one - if (!repository && (this.config.github.token || isGitHubConnected)) { - repository = await this.repoManager.ensureUserRepository(username); - } - } - - // Fetch README.md content if we have a repository - if (repository) { - const readmeContent = await this.fetchRepositoryReadme( - repository.repositoryUrl - ); - if (readmeContent) { - readmeSection = `*📖 README :*\n\`\`\`\n${readmeContent.slice(0, 500)}${readmeContent.length > 500 ? "..." : ""}\n\`\`\``; - } + const { moduleRegistry } = await import("../../../../../modules"); + const gitHubModule = moduleRegistry.getModule("github"); + if (gitHubModule && "getUserInfo" in gitHubModule) { + githubUser = await (gitHubModule as any).getUserInfo(userId); } } catch (error) { - logger.warn( - `Could not get/ensure repository for user ${username}:`, - error - ); + logger.warn("GitHub module not available for home tab"); } + const isGitHubConnected = !!githubUser.token; const blocks: any[] = [ { @@ -407,85 +292,36 @@ export class ActionHandler { }, ]; - // Show repository info or login prompt - if (repository && isGitHubConnected) { - const repoUrl = repository.repositoryUrl.replace(/\.git$/, ""); - const repoDisplayName = repoUrl.replace( - /^https?:\/\/(www\.)?github\.com\//, - "" - ); - - blocks.push({ - type: "section", - text: { - type: "mrkdwn", - text: `*Active Repository:*\n<${repoUrl}|${repoDisplayName}>`, - }, - accessory: { - type: "button", - text: { type: "plain_text", text: "🔄 Change Repository" }, - action_id: "open_repository_modal", - }, - }); - - // Add README section if available - if (readmeSection) { - blocks.push({ - type: "section", - text: { type: "mrkdwn", text: readmeSection }, - }); - } - - blocks.push({ type: "divider" }); - } else if (repository && !isGitHubConnected) { - // Repository exists but user not authenticated - show login prompt with repo info - const repoUrl = repository.repositoryUrl.replace(/\.git$/, ""); - const repoDisplayName = repoUrl.replace( - /^https?:\/\/(www\.)?github\.com\//, - "" - ); - - blocks.push({ - type: "section", - text: { - type: "mrkdwn", - text: `*Demo Repository:*\n<${repoUrl}|${repoDisplayName}>\n\n_Connect your GitHub account to work with your own repositories._`, - }, - }); - - const authUrl = generateGitHubAuthUrl(userId); - - const demoElements = [ - { - type: "button", - text: { type: "plain_text", text: "🔗 Login with GitHub" }, - url: authUrl, - style: "primary", - } as any, - ]; - - // Only show Try Demo button if DEMO_REPOSITORY is configured - if (process.env.DEMO_REPOSITORY) { - demoElements.push({ - type: "button", - text: { type: "plain_text", text: "🎮 Try Demo" }, - action_id: "try_demo", - }); + // Add module-rendered home tab sections + let homeTabModules: any[] = []; + try { + const { moduleRegistry } = await import("../../../../../modules"); + homeTabModules = moduleRegistry.getHomeTabModules(); + } catch (error) { + logger.warn("Module registry not available for home tab rendering"); + } + for (const module of homeTabModules) { + try { + const moduleBlocks = await module.renderHomeTab!(userId); + blocks.push(...moduleBlocks); + if (moduleBlocks.length > 0) { + blocks.push({ type: "divider" }); + } + } catch (error) { + logger.error( + `Failed to render home tab for module ${module.name}:`, + error + ); } + } - blocks.push({ - type: "actions", - elements: demoElements, - }); - - blocks.push({ type: "divider" }); - } else if (isGitHubConnected) { - // GitHub connected but no repository selected + // Demo functionality (non-module specific) + if (process.env.DEMO_REPOSITORY && !isGitHubConnected) { blocks.push({ type: "section", text: { type: "mrkdwn", - text: `*GitHub Connected:* ${githubUser.username || "✓"}\n\nSelect a repository to start working:`, + text: "*🎮 Demo Mode*\nTry Peerbot with a demo repository", }, }); @@ -494,49 +330,13 @@ export class ActionHandler { elements: [ { type: "button", - text: { type: "plain_text", text: "📂 Select Repository" }, - action_id: "open_repository_modal", + text: { type: "plain_text", text: "🎮 Try Demo" }, + action_id: "try_demo", style: "primary", }, ], }); - blocks.push({ type: "divider" }); - } else { - // Not connected to GitHub - blocks.push({ - type: "section", - text: { - type: "mrkdwn", - text: "*Get Started:*\nConnect your GitHub account to start working with your repositories.", - }, - }); - - const authUrl = generateGitHubAuthUrl(userId); - - const loginElements = [ - { - type: "button", - text: { type: "plain_text", text: "🔗 Login with GitHub" }, - url: authUrl, - style: "primary", - } as any, - ]; - - // Only show Try Demo button if DEMO_REPOSITORY is configured - if (process.env.DEMO_REPOSITORY) { - loginElements.push({ - type: "button", - text: { type: "plain_text", text: "🎮 Try Demo" }, - action_id: "try_demo", - }); - } - - blocks.push({ - type: "actions", - elements: loginElements, - }); - blocks.push({ type: "divider" }); } @@ -553,33 +353,6 @@ export class ActionHandler { }, }); - // Add logout button if GitHub is connected - if (isGitHubConnected) { - blocks.push( - { type: "divider" }, - { - type: "actions", - elements: [ - { - type: "button", - text: { type: "plain_text", text: "🚪 Logout from GitHub" }, - action_id: "github_logout", - style: "danger", - confirm: { - title: { type: "plain_text", text: "Logout from GitHub?" }, - text: { - type: "mrkdwn", - text: "This will disconnect your GitHub account. You'll need to login again to access your repositories.", - }, - confirm: { type: "plain_text", text: "Logout" }, - deny: { type: "plain_text", text: "Cancel" }, - }, - }, - ], - } - ); - } - // Update the app home view await client.views.publish({ user_id: userId, @@ -594,89 +367,4 @@ export class ActionHandler { logger.error(`Failed to update app home for user ${userId}:`, error); } } - - /** - * Handle GitHub login - */ - private async handleGitHubLogin(userId: string, client: any): Promise { - const authUrl = generateGitHubAuthUrl(userId); - - try { - const im = await client.conversations.open({ users: userId }); - if (im.channel?.id) { - await client.chat.postMessage({ - channel: im.channel.id, - blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: "*🔗 Connect to GitHub*\n\nClick the link below to connect your GitHub account:", - }, - }, - { - type: "section", - text: { - type: "mrkdwn", - text: `<${authUrl}|Connect with GitHub>`, - }, - }, - { - type: "context", - elements: [ - { - type: "mrkdwn", - text: "🔒 We'll only access repositories you explicitly grant permission to", - }, - ], - }, - ], - }); - } - } catch (error) { - logger.error(`Failed to send GitHub login message to ${userId}:`, error); - } - } - - /** - * Fetch repository README content - */ - private async fetchRepositoryReadme( - repositoryUrl: string - ): Promise { - try { - const urlParts = repositoryUrl - .replace(/^https?:\/\//, "") - .replace(/\.git$/, "") - .split("/"); - - if (urlParts.length < 3) { - return null; - } - - const owner = urlParts[1]; - const repo = urlParts[2]; - const branch = "main"; - - const readmeUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/README.md`; - - const response = await fetch(readmeUrl); - if (response.ok) { - const content = await response.text(); - return content.substring(0, 1000); - } - - // Try master branch - const masterUrl = `https://raw.githubusercontent.com/${owner}/${repo}/master/README.md`; - const masterResponse = await fetch(masterUrl); - if (masterResponse.ok) { - const content = await masterResponse.text(); - return content.substring(0, 1000); - } - } catch (error) { - logger.error(`Failed to fetch README for ${repositoryUrl}:`, error); - } - - return null; - } } diff --git a/packages/dispatcher/src/slack/handlers/github-handler.ts b/packages/dispatcher/src/slack/handlers/github-handler.ts deleted file mode 100644 index 304c1061..00000000 --- a/packages/dispatcher/src/slack/handlers/github-handler.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { createLogger } from "@peerbot/shared"; -import { getDbPool } from "@peerbot/shared"; - -const logger = createLogger("dispatcher"); -import { ErrorHandler } from "../../utils/error-handler"; -import { decrypt } from "@peerbot/shared"; -import { generateGitHubAuthUrl } from "../../utils/github-utils"; - -/** - * Handle GitHub connect action - initiates OAuth flow - */ -export async function handleGitHubConnect( - userId: string, - channelId: string, - client: any -): Promise { - try { - // Generate OAuth URL with user ID - const authUrl = generateGitHubAuthUrl(userId); - - // Check if this is a DM or channel - // const isDM = channelId.startsWith('D'); // Currently unused - - await client.chat.postMessage({ - channel: channelId, - blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: "🔗 *Connect your GitHub account*\n\nClick the link below to authorize Peerbot to access your GitHub repositories:", - }, - }, - { - type: "section", - text: { - type: "mrkdwn", - text: `<${authUrl}|Connect with GitHub>`, - }, - }, - { - type: "context", - elements: [ - { - type: "mrkdwn", - text: "🔒 We'll only access repositories you explicitly grant permission to", - }, - ], - }, - ], - }); - - logger.info(`GitHub connect initiated for user ${userId}`); - } catch (error) { - ErrorHandler.logAndHandle("initiate GitHub connect", error, { userId }); - await client.chat.postMessage({ - channel: channelId, - text: ErrorHandler.formatSlackError( - error, - "Failed to generate GitHub login link" - ), - }); - } -} - -/** - * Handle GitHub logout - */ -export async function handleGitHubLogout( - userId: string, - client: any -): Promise { - try { - const dbPool = getDbPool(process.env.DATABASE_URL!); - - // Remove GitHub token and username from database - await dbPool.query( - `DELETE FROM user_environ - WHERE user_id = (SELECT id FROM users WHERE platform = 'slack' AND platform_user_id = $1) - AND name IN ('GITHUB_TOKEN', 'GITHUB_USER')`, - [userId.toUpperCase()] - ); - - logger.info(`GitHub logout completed for user ${userId}`); - - // Send confirmation - const im = await client.conversations.open({ users: userId }); - if (im.channel?.id) { - await client.chat.postMessage({ - channel: im.channel.id, - text: "✅ Successfully logged out from GitHub", - }); - } - } catch (error) { - logger.error(`Failed to logout user ${userId}:`, error); - } -} - -/** - * Get user's GitHub info from database - */ -export async function getUserGitHubInfo(userId: string): Promise<{ - token: string | null; - username: string | null; -}> { - try { - const dbPool = getDbPool(process.env.DATABASE_URL!); - - const result = await dbPool.query( - `SELECT name, value - FROM user_environ - WHERE user_id = (SELECT id FROM users WHERE platform = 'slack' AND platform_user_id = $1) - AND name IN ('GITHUB_TOKEN', 'GITHUB_USER')`, - [userId.toUpperCase()] - ); - - let token = null; - let username = null; - - for (const row of result.rows) { - if (row.name === "GITHUB_TOKEN") { - try { - // Token is encrypted, decrypt it - token = decrypt(row.value); - } catch (error) { - logger.error( - `Failed to decrypt GitHub token for user ${userId}:`, - error - ); - token = null; - } - } else if (row.name === "GITHUB_USER") { - try { - username = decrypt(row.value); - } catch (error) { - // If decryption fails, assume it's plain text (for backwards compatibility) - username = row.value; - } - } - } - - return { token, username }; - } catch (error) { - logger.error(`Failed to get GitHub info for user ${userId}:`, error); - return { token: null, username: null }; - } -} diff --git a/packages/dispatcher/src/slack/handlers/message-handler.ts b/packages/dispatcher/src/slack/handlers/message-handler.ts index fa71a97c..c0420e74 100644 --- a/packages/dispatcher/src/slack/handlers/message-handler.ts +++ b/packages/dispatcher/src/slack/handlers/message-handler.ts @@ -13,24 +13,16 @@ import type { SlackContext, ThreadSession, } from "../../types"; -import type { GitHubRepositoryManager } from "../../github/repository-manager"; +// GitHubRepositoryManager imported dynamically when needed import { getDbPool } from "@peerbot/shared"; export class MessageHandler { private activeSessions = new Map(); - private userMappings = new Map(); // slackUserId -> githubUsername - private repositoryCache = new Map< - string, - { repository: any; timestamp: number } - >(); // username -> {repository, timestamp} - private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes cache TTL private readonly SESSION_TTL = 24 * 60 * 60 * 1000; // 24 hours session TTL - // private readonly USER_MAPPING_TTL = 60 * 60 * 1000; // 1 hour user mapping TTL - Currently unused private lastCleanupTime = Date.now(); constructor( private queueProducer: QueueProducer, - private repoManager: GitHubRepositoryManager, private config: DispatcherConfig ) { this.startCachePrewarming(); @@ -206,20 +198,11 @@ export class MessageHandler { ); try { - // Get user's GitHub username mapping - const username = await this.getOrCreateUserMapping( - context.userId, - client - ); - - // Check if this is a new session - const isNewSession = !context.threadTs; - - // First get a preliminary check for repository without context + // Get repository from environment variables (stored via GitHub OAuth flow) const preliminaryEnv = await this.getUserEnvironment( context.userId, context.channelId, - undefined // Don't pass repository yet as we need to determine it first + undefined ); const overrideRepo = preliminaryEnv.GITHUB_REPOSITORY as | string @@ -227,7 +210,7 @@ export class MessageHandler { let repository; if (overrideRepo) { - // User has overridden the repository URL + // User has configured a repository URL via environment variable const repoUrl = overrideRepo; const parts = repoUrl.split("/"); const repoName = parts[parts.length - 1]; @@ -240,20 +223,12 @@ export class MessageHandler { lastUsed: Date.now(), }; - logger.info(`Using overridden repository for ${username}: ${repoUrl}`); + logger.info(`Using repository for user ${context.userId}: ${repoUrl}`); } else { - // Normal flow - check cache then fetch - const cachedRepo = this.repositoryCache.get(username); - if (cachedRepo && Date.now() - cachedRepo.timestamp < this.CACHE_TTL) { - repository = cachedRepo.repository; - logger.info(`Using cached repository for ${username}`); - } else { - repository = await this.repoManager.ensureUserRepository(username); - this.repositoryCache.set(username, { - repository, - timestamp: Date.now(), - }); - } + logger.info( + `No repository configured for user ${context.userId} in channel ${context.channelId}` + ); + repository = null; } const threadTs = normalizedThreadTs; @@ -265,7 +240,6 @@ export class MessageHandler { channelId: context.channelId, userId: context.userId, threadCreator: context.userId, // Store the thread creator - username, repositoryUrl: repository?.repositoryUrl || "", lastActivity: Date.now(), status: "pending", @@ -294,7 +268,7 @@ export class MessageHandler { } // Determine if this is a new conversation - const isNewConversation = !context.threadTs || isNewSession; + const isNewConversation = !context.threadTs || !existingSession; if (isNewConversation) { const deploymentPayload: WorkerDeploymentPayload = { @@ -451,42 +425,6 @@ export class MessageHandler { return userList.includes(userId); } - /** - * Get or create mapping between Slack user ID and GitHub username - */ - async getOrCreateUserMapping( - slackUserId: string, - client: any - ): Promise { - // Check cache first (with TTL) - const cached = this.userMappings.get(slackUserId); - if (cached) { - return cached; - } - - try { - const userInfo = await client.users.info({ user: slackUserId }); - const userProfile = userInfo?.user?.profile; - - let username = - userProfile?.display_name || userProfile?.real_name || slackUserId; - username = username.toLowerCase().replace(/[^a-z0-9-]/g, "-"); - - if (!username.match(/^[a-z0-9]/)) { - username = `user-${username}`; - } - - this.userMappings.set(slackUserId, username); - logger.info(`Created user mapping: ${slackUserId} -> ${username}`); - return username; - } catch (error) { - logger.error(`Failed to get user info for ${slackUserId}:`, error); - const fallback = `user-${slackUserId.toLowerCase()}`; - this.userMappings.set(slackUserId, fallback); - return fallback; - } - } - /** * Start cache prewarming */ @@ -517,18 +455,8 @@ export class MessageHandler { } } - // Cleanup expired user mappings - this.userMappings.clear(); - - // Cleanup expired repository cache - for (const [key, cached] of this.repositoryCache.entries()) { - if (now - cached.timestamp > this.CACHE_TTL) { - this.repositoryCache.delete(key); - } - } - logger.info( - `Cleanup completed - Active sessions: ${this.activeSessions.size}, User mappings: ${this.userMappings.size}, Repo cache: ${this.repositoryCache.size}` + `Cleanup completed - Active sessions: ${this.activeSessions.size}` ); } @@ -537,18 +465,6 @@ export class MessageHandler { return this.activeSessions; } - getUserMappings(): Map { - return this.userMappings; - } - - getRepositoryCache(): Map { - return this.repositoryCache; - } - - clearCacheForUser(username: string): void { - this.repositoryCache.delete(username); - } - setShortcutCommandHandler(_handler: any): void { // Reference to ShortcutCommandHandler - currently not used } diff --git a/packages/dispatcher/src/slack/handlers/repository-modal-utils.ts b/packages/dispatcher/src/slack/handlers/repository-modal-utils.ts index 16377c96..6e6f1959 100644 --- a/packages/dispatcher/src/slack/handlers/repository-modal-utils.ts +++ b/packages/dispatcher/src/slack/handlers/repository-modal-utils.ts @@ -1,5 +1,5 @@ import { createLogger } from "@peerbot/shared"; -import { generateGitHubAuthUrl } from "../../utils/github-utils"; +// GitHub utility imports are loaded dynamically when needed const logger = createLogger("dispatcher"); @@ -74,6 +74,9 @@ export async function openRepositoryModal({ }, }); + const { generateGitHubAuthUrl } = await import( + "../../../../../modules/github/utils" + ); const authUrl = generateGitHubAuthUrl(userId); blocks.push({ type: "section", diff --git a/packages/dispatcher/src/slack/handlers/shortcut-command-handler.ts b/packages/dispatcher/src/slack/handlers/shortcut-command-handler.ts index 4ff0a037..a10af40e 100644 --- a/packages/dispatcher/src/slack/handlers/shortcut-command-handler.ts +++ b/packages/dispatcher/src/slack/handlers/shortcut-command-handler.ts @@ -8,7 +8,7 @@ import { encrypt } from "@peerbot/shared"; import type { MessageHandler } from "./message-handler"; import type { ActionHandler } from "./action-handler"; import { openRepositoryModal } from "./repository-modal-utils"; -import { getUserGitHubInfo } from "./github-handler"; +// GitHub imports are loaded dynamically when needed export class ShortcutCommandHandler { constructor( @@ -59,6 +59,9 @@ export class ShortcutCommandHandler { const userId = body.user.id; logger.info(`Create project shortcut triggered by ${userId}`); + const { getUserGitHubInfo } = await import( + "../../../../../modules/github/handlers" + ); await openRepositoryModal({ userId, body, @@ -144,6 +147,9 @@ export class ShortcutCommandHandler { threadTs?: string ): Promise { // Check if user has GitHub connected + const { getUserGitHubInfo } = await import( + "../../../../../modules/github/handlers" + ); const githubUser = await getUserGitHubInfo(userId); const isGitHubConnected = !!githubUser.token; @@ -435,6 +441,9 @@ export class ShortcutCommandHandler { // Create new repository // Get GitHub user info // const username = await this.messageHandler.getOrCreateUserMapping(userId, client); // Currently unused + const { getUserGitHubInfo } = await import( + "../../../../../modules/github/handlers" + ); const githubUser = await getUserGitHubInfo(userId); if (!githubUser.token) { @@ -468,13 +477,6 @@ export class ShortcutCommandHandler { // Save the selected repository await this.saveSelectedRepository(userId, repositoryUrl, channelId); - // Clear cache for the user - const username = await this.messageHandler.getOrCreateUserMapping( - userId, - client - ); - this.messageHandler.clearCacheForUser(username); - // Send confirmation const repoName = repositoryUrl.split("/").pop()?.replace(".git", "") || "repository"; diff --git a/packages/dispatcher/src/slack/slack-event-handlers.ts b/packages/dispatcher/src/slack/slack-event-handlers.ts index a50342ac..9c0e8eb8 100644 --- a/packages/dispatcher/src/slack/slack-event-handlers.ts +++ b/packages/dispatcher/src/slack/slack-event-handlers.ts @@ -4,7 +4,6 @@ import type { App } from "@slack/bolt"; import { createLogger } from "@peerbot/shared"; const logger = createLogger("slack-events"); -import type { GitHubRepositoryManager } from "../github/repository-manager"; import type { QueueProducer } from "../queue/task-queue-producer"; import type { DispatcherConfig } from "../types"; import { @@ -17,7 +16,7 @@ import { setupTeamJoinHandler } from "./handlers/welcome-handler"; import { MessageHandler } from "./handlers/message-handler"; import { ActionHandler } from "./handlers/action-handler"; import { ShortcutCommandHandler } from "./handlers/shortcut-command-handler"; -import { getUserGitHubInfo } from "./handlers/github-handler"; +// Dynamic module imports to avoid hardcoded dependencies /** * Queue-based Slack event handlers that route messages to appropriate queues @@ -31,21 +30,11 @@ export class SlackEventHandlers { constructor( private app: App, queueProducer: QueueProducer, - repoManager: GitHubRepositoryManager, private config: DispatcherConfig ) { // Initialize specialized handlers - this.messageHandler = new MessageHandler( - queueProducer, - repoManager, - config - ); - this.actionHandler = new ActionHandler( - repoManager, - queueProducer, - config, - this.messageHandler - ); + this.messageHandler = new MessageHandler(queueProducer, config); + this.actionHandler = new ActionHandler(queueProducer, this.messageHandler); this.shortcutCommandHandler = new ShortcutCommandHandler( app, config, @@ -68,57 +57,45 @@ export class SlackEventHandlers { private setupOptionsHandlers(): void { logger.info("Setting up options handlers for external selects"); - // Handle repository search + // Handle repository search via GitHub module this.app.options("existing_repo_select", async ({ options, ack, body }) => { // Handle both initial load and search const query = options?.value || ""; const userId = body.user?.id; + if (!userId) { + await ack({ options: [] }); + return; + } + logger.info( `Repository search triggered - query: "${query}", user: ${userId}` ); try { - // Get user's GitHub token - logger.info(`Fetching GitHub info for user ${userId}`); - const githubUser = await getUserGitHubInfo(userId); - logger.info( - `GitHub user info retrieved: token=${!!githubUser.token}, username=${githubUser.username}` - ); + // Delegate to GitHub module + let gitHubModule: any = null; + try { + const { moduleRegistry } = await import("../../../../modules"); + gitHubModule = moduleRegistry.getModule("github"); + } catch (error) { + logger.warn("Module registry not available"); + } - if (!githubUser.token) { - // No token = no suggestions - logger.info(`No GitHub token found for user ${userId}`); + if (!gitHubModule || !("handleRepositorySearch" in gitHubModule)) { + logger.warn("GitHub module not available - returning empty options"); await ack({ options: [] }); return; } - // Search both user repos and org repos in parallel - const [userRepos, orgRepos] = await Promise.all([ - this.searchUserRepos(query, githubUser.token), - this.searchOrgRepos(query, githubUser.token), - ]); - - logger.info( - `Found ${userRepos.length} user repos, ${orgRepos.length} org repos` + const options = await gitHubModule.handleRepositorySearch( + query, + userId ); - // Combine and deduplicate - const allRepos = [...userRepos, ...orgRepos]; - const uniqueRepos = Array.from( - new Map(allRepos.map((repo) => [repo.html_url, repo])).values() + logger.info( + `Returning ${options.length} repository options from GitHub module` ); - - // Format for Slack (limit to 100) - const options = uniqueRepos.slice(0, 100).map((repo) => ({ - text: { - type: "plain_text" as const, - text: repo.full_name, // Shows "owner/repo" - }, - value: repo.html_url, - })); - - logger.info(`Returning ${options.length} repository options`); await ack({ options }); } catch (error) { // Log error but still return empty options @@ -128,99 +105,6 @@ export class SlackEventHandlers { }); } - /** - * Search user's accessible repositories - */ - private async searchUserRepos(query: string, token: string): Promise { - try { - let url: string; - - if (query) { - // Search user's repos with query - url = `https://api.github.com/user/repos?per_page=100&sort=updated`; - } else { - // Get recent repos if no query - url = `https://api.github.com/user/repos?per_page=20&sort=updated`; - } - - const response = await fetch(url, { - headers: { - Authorization: `token ${token}`, - Accept: "application/vnd.github.v3+json", - }, - }); - - if (!response.ok) { - logger.warn( - `GitHub API error for user repos: ${response.status} ${response.statusText}` - ); - return []; - } - - const repos = (await response.json()) as any; - - // Filter by query if provided - if (query) { - const lowerQuery = query.toLowerCase(); - return repos.filter( - (repo: any) => - repo.name.toLowerCase().includes(lowerQuery) || - repo.full_name.toLowerCase().includes(lowerQuery) - ); - } - - return repos; - } catch { - return []; - } - } - - /** - * Search organization repositories - */ - private async searchOrgRepos(query: string, token: string): Promise { - const org = process.env.GITHUB_ORGANIZATION; - - if (!org) return []; - - try { - // Get organization repos - const response = await fetch( - `https://api.github.com/orgs/${org}/repos?per_page=100&sort=updated`, - { - headers: { - Authorization: `token ${token}`, - Accept: "application/vnd.github.v3+json", - }, - } - ); - - if (!response.ok) { - logger.warn( - `GitHub API error for org repos: ${response.status} ${response.statusText}` - ); - return []; - } - - const repos = (await response.json()) as any; - - // Filter by query if provided - if (query) { - const lowerQuery = query.toLowerCase(); - return repos.filter( - (repo: any) => - repo.name.toLowerCase().includes(lowerQuery) || - repo.full_name.toLowerCase().includes(lowerQuery) - ); - } - - // Return top 20 if no query - return repos.slice(0, 20); - } catch { - return []; - } - } - /** * Get bot ID from configuration */ @@ -545,21 +429,4 @@ export class SlackEventHandlers { logger.info("Cleaning up Slack event handlers"); this.messageHandler.cleanupExpiredData(); } - - /** - * Get user mappings (required by ThreadResponseConsumer) - */ - getUserMappings(): Map { - return this.messageHandler.getUserMappings(); - } - - /** - * Get or create user mapping (required by external components) - */ - async getOrCreateUserMapping( - slackUserId: string, - client: any - ): Promise { - return this.messageHandler.getOrCreateUserMapping(slackUserId, client); - } } diff --git a/packages/dispatcher/src/types.ts b/packages/dispatcher/src/types.ts index ea1b06b1..9bfce54a 100644 --- a/packages/dispatcher/src/types.ts +++ b/packages/dispatcher/src/types.ts @@ -19,16 +19,6 @@ export interface SlackConfig { allowPrivateChannels?: boolean; } -export interface GitHubConfig { - token: string; - organization: string; - repoTemplate?: string; - repository?: string; // Override repository URL instead of creating user-specific ones - clientId?: string; // GitHub OAuth App Client ID - clientSecret?: string; // GitHub OAuth App Client Secret - ingressUrl?: string; // Public URL for OAuth callbacks -} - export interface QueueConfig { directMessage: string; messageQueue: string; @@ -47,7 +37,6 @@ export interface AnthropicProxyConfig { export interface DispatcherConfig { slack: SlackConfig; - github: GitHubConfig; claude: Partial; sessionTimeoutMinutes: number; logLevel?: LogLevel; @@ -69,7 +58,6 @@ export interface SlackContext { export interface WorkerJobRequest { sessionKey: string; userId: string; - username: string; channelId: string; threadTs?: string; userPrompt: string; @@ -87,7 +75,6 @@ export interface ThreadSession { channelId: string; userId: string; threadCreator?: string; // Track the original thread creator - username: string; jobName?: string; repositoryUrl: string; lastActivity: number; diff --git a/packages/dispatcher/tsconfig.json b/packages/dispatcher/tsconfig.json index 941f7085..e8ffaa17 100644 --- a/packages/dispatcher/tsconfig.json +++ b/packages/dispatcher/tsconfig.json @@ -2,7 +2,6 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src", "declaration": true, "declarationMap": true, "sourceMap": true, @@ -18,6 +17,6 @@ "noUnusedLocals": false, "noUnusedParameters": false }, - "include": ["src/**/*"], + "include": ["src/**/*", "../../modules/**/*"], "exclude": ["dist", "node_modules", "**/*.test.ts", "**/__tests__/**"] } diff --git a/packages/orchestrator/agent.md b/packages/orchestrator/agent.md index 669809f0..0584a51a 100644 --- a/packages/orchestrator/agent.md +++ b/packages/orchestrator/agent.md @@ -28,6 +28,7 @@ Worker deployment and lifecycle management. Handles Docker/Kubernetes orchestrat - **One thread = One worker**: Creates `peerbot-worker-{userId}-{threadId}` deployments - Workers get persistent volumes at `/workspace` for session continuity - Auto-cleanup idle workers to prevent resource leaks +- **NEVER use console.log/warn/error** - ALWAYS use logger from `@peerbot/shared` ## Environment Variables - `DATABASE_URL`: PostgreSQL connection diff --git a/packages/orchestrator/src/base/BaseDeploymentManager.ts b/packages/orchestrator/src/base/BaseDeploymentManager.ts index 3d048c50..c9f16e87 100644 --- a/packages/orchestrator/src/base/BaseDeploymentManager.ts +++ b/packages/orchestrator/src/base/BaseDeploymentManager.ts @@ -7,6 +7,7 @@ import { } from "../types"; import type { BaseSecretManager } from "./BaseSecretManager"; import { decrypt, createLogger } from "@peerbot/shared"; +import { buildModuleEnvVars } from "../module-integration"; const logger = createLogger("orchestrator"); @@ -212,20 +213,20 @@ export abstract class BaseDeploymentManager { /** * Generate environment variables common to all deployment types */ - protected generateEnvironmentVariables( + protected async generateEnvironmentVariables( username: string, userId: string, deploymentName: string, messageData?: any, includeSecrets: boolean = true, userEnvVars: Record = {} - ): { [key: string]: string } { + ): Promise<{ [key: string]: string }> { // Parse database connection string to extract host and port const dbUrl = new URL(this.config.database.connectionString); const dbHost = dbUrl.hostname; const dbPort = dbUrl.port || "5432"; // Default PostgreSQL port - const envVars: { [key: string]: string } = { + let envVars: { [key: string]: string } = { USER_ID: userId, USERNAME: username, DEPLOYMENT_NAME: deploymentName, @@ -253,10 +254,12 @@ export abstract class BaseDeploymentManager { // Include secrets from process.env for Docker deployments if (includeSecrets) { - if (process.env.GITHUB_TOKEN) { - envVars.GITHUB_TOKEN = process.env.GITHUB_TOKEN; + // Add module-specific environment variables + try { + envVars = await buildModuleEnvVars(userId, envVars); + } catch (error) { + logger.warn("Failed to build module environment variables:", error); } - // OAuth token is now always handled by the proxy in dispatcher } if (process.env.CLAUDE_ALLOWED_TOOLS) { diff --git a/packages/orchestrator/src/docker/DockerDeploymentManager.ts b/packages/orchestrator/src/docker/DockerDeploymentManager.ts index ad6050c5..3b251fc4 100644 --- a/packages/orchestrator/src/docker/DockerDeploymentManager.ts +++ b/packages/orchestrator/src/docker/DockerDeploymentManager.ts @@ -126,7 +126,7 @@ export class DockerDeploymentManager extends BaseDeploymentManager { const password = await this.getPasswordForUser(username); // Get common environment variables from base class - const commonEnvVars = this.generateEnvironmentVariables( + const commonEnvVars = await this.generateEnvironmentVariables( username, userId, deploymentName, diff --git a/packages/orchestrator/src/index.ts b/packages/orchestrator/src/index.ts index 7d0007cf..7a49ede8 100644 --- a/packages/orchestrator/src/index.ts +++ b/packages/orchestrator/src/index.ts @@ -5,6 +5,8 @@ import { initSentry } from "@peerbot/shared"; // Initialize Sentry monitoring initSentry(); +import { moduleRegistry } from "../../../modules"; + import { join } from "node:path"; import { config as dotenvConfig } from "dotenv"; import type { BaseDeploymentManager } from "./base/BaseDeploymentManager"; @@ -26,6 +28,7 @@ class PeerbotOrchestrator { constructor(config: OrchestratorConfig) { this.config = config; + this.dbPool = new DatabasePool(config.database); this.deploymentManager = this.createDeploymentManager(config); this.queueConsumer = new QueueConsumer(config, this.deploymentManager); @@ -161,6 +164,10 @@ class PeerbotOrchestrator { async start(): Promise { try { + // Initialize modules + await moduleRegistry.initAll(); + logger.info("✅ Modules initialized"); + // Run database migrations using dbmate (this will create database and run migrations) await this.runDbmateMigrations(); @@ -461,11 +468,6 @@ async function main() { // Create and start orchestrator const orchestrator = new PeerbotOrchestrator(config); await orchestrator.start(); - - // Keep the process alive - process.on("SIGUSR1", () => { - // const _status = orchestrator.getStatus(); - }); } catch (error) { logger.error("💥 Failed to start Peerbot Orchestrator:", error); process.exit(1); diff --git a/packages/orchestrator/src/k8s/K8sDeploymentManager.ts b/packages/orchestrator/src/k8s/K8sDeploymentManager.ts index 3779ae4a..018fcc43 100644 --- a/packages/orchestrator/src/k8s/K8sDeploymentManager.ts +++ b/packages/orchestrator/src/k8s/K8sDeploymentManager.ts @@ -234,7 +234,7 @@ export class K8sDeploymentManager extends BaseDeploymentManager { } // Get environment variables before creating the deployment spec - const envVars = this.generateEnvironmentVariables( + const envVars = await this.generateEnvironmentVariables( username, userId, deploymentName, @@ -341,17 +341,7 @@ export class K8sDeploymentManager extends BaseDeploymentManager { name: "NODE_ENV", value: process.env.NODE_ENV || "production", }, - // K8s-specific secrets that can't be handled in base class - { - name: "GITHUB_TOKEN", - valueFrom: { - secretKeyRef: { - name: "peerbot-secrets", - key: "github-token", - optional: true, - } as any, - }, - }, + // Module-specific environment variables are added through base class ], resources: { requests: this.config.worker.resources.requests, diff --git a/packages/orchestrator/src/module-integration.ts b/packages/orchestrator/src/module-integration.ts new file mode 100644 index 00000000..ebd0a382 --- /dev/null +++ b/packages/orchestrator/src/module-integration.ts @@ -0,0 +1,27 @@ +import { moduleRegistry } from "../../../modules"; +import { createLogger } from "@peerbot/shared"; + +const logger = createLogger("orchestrator"); + +export async function buildModuleEnvVars( + userId: string, + baseEnv: Record +): Promise> { + let envVars = { ...baseEnv }; + + const orchestratorModules = moduleRegistry.getOrchestratorModules(); + for (const module of orchestratorModules) { + if (module.buildEnvVars) { + try { + envVars = await module.buildEnvVars(userId, envVars); + } catch (error) { + logger.error( + `Failed to build env vars for module ${module.name}:`, + error + ); + } + } + } + + return envVars; +} diff --git a/packages/orchestrator/tsconfig.json b/packages/orchestrator/tsconfig.json index 813b8c9c..6f195f3d 100644 --- a/packages/orchestrator/tsconfig.json +++ b/packages/orchestrator/tsconfig.json @@ -4,7 +4,6 @@ "module": "commonjs", "lib": ["ES2022"], "outDir": "./dist", - "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, @@ -22,6 +21,10 @@ "@/*": ["src/*"] } }, - "include": ["src/**/*"], + "include": [ + "src/**/*", + "../../../modules/index.ts", + "../../../modules/types.ts" + ], "exclude": ["node_modules", "dist", "**/*.test.ts"] } diff --git a/packages/shared/agent.md b/packages/shared/agent.md index 69986213..9a7f43db 100644 --- a/packages/shared/agent.md +++ b/packages/shared/agent.md @@ -35,6 +35,7 @@ Common utilities and infrastructure for all packages. Foundation for logging, da - All packages use shared error types and logging configuration - Database access through shared connection pooling - Encryption utilities for storing sensitive data (GitHub tokens, API keys) +- **NEVER use console.log/warn/error** - ALWAYS use the logger created via `createLogger()` ## Environment Variables - `DATABASE_URL`: PostgreSQL connection string diff --git a/packages/shared/src/config/index.ts b/packages/shared/src/config/index.ts index b7498e51..bc972a92 100644 --- a/packages/shared/src/config/index.ts +++ b/packages/shared/src/config/index.ts @@ -24,15 +24,6 @@ export const SlackConfigSchema = z.object({ .default("INFO"), }); -// GitHub configuration schema -export const GitHubConfigSchema = z.object({ - appId: z.string().optional(), - privateKey: z.string().optional(), - clientId: z.string().optional(), - clientSecret: z.string().optional(), - installationId: z.string().optional(), -}); - // Claude configuration schema export const ClaudeConfigSchema = z.object({ apiKey: z.string().optional(), @@ -77,7 +68,6 @@ export const KubernetesConfigSchema = z.object({ export const AppConfigSchema = z.object({ database: DatabaseConfigSchema, slack: SlackConfigSchema, - github: GitHubConfigSchema.optional(), claude: ClaudeConfigSchema.optional(), queue: QueueConfigSchema, kubernetes: KubernetesConfigSchema.optional(), @@ -85,7 +75,6 @@ export const AppConfigSchema = z.object({ export type DatabaseConfig = z.infer; export type SlackConfig = z.infer; -export type GitHubConfig = z.infer; export type ClaudeConfig = z.infer; export type QueueConfig = z.infer; export type KubernetesConfig = z.infer; @@ -122,19 +111,6 @@ export function loadSlackConfig(): SlackConfig { return SlackConfigSchema.parse(config); } -/** - * Loads GitHub configuration from environment variables - */ -export function loadGitHubConfig(): GitHubConfig { - return GitHubConfigSchema.parse({ - appId: process.env.GITHUB_APP_ID, - privateKey: process.env.GITHUB_PRIVATE_KEY, - clientId: process.env.GITHUB_CLIENT_ID, - clientSecret: process.env.GITHUB_CLIENT_SECRET, - installationId: process.env.GITHUB_INSTALLATION_ID, - }); -} - /** * Loads Claude configuration from environment variables */ @@ -194,7 +170,6 @@ export function loadConfig(): AppConfig { return AppConfigSchema.parse({ database: loadDatabaseConfig(), slack: loadSlackConfig(), - github: loadGitHubConfig(), claude: loadClaudeConfig(), queue: loadQueueConfig(), kubernetes: loadKubernetesConfig(), diff --git a/packages/shared/src/errors/dispatcher-errors.ts b/packages/shared/src/errors/dispatcher-errors.ts index cd66c5d9..7c986159 100644 --- a/packages/shared/src/errors/dispatcher-errors.ts +++ b/packages/shared/src/errors/dispatcher-errors.ts @@ -1,25 +1,2 @@ -import { BaseError } from "./base-error"; - -/** - * Error class for GitHub repository operations - */ -export class GitHubRepositoryError extends BaseError { - readonly name = "GitHubRepositoryError"; - - constructor( - public operation: string, - public username: string, - message: string, - cause?: Error - ) { - super(message, cause); - } - - toJSON(): Record { - return { - ...super.toJSON(), - operation: this.operation, - username: this.username, - }; - } -} +// GitHub-specific errors moved to modules/github/errors.ts +// This file can be removed diff --git a/packages/shared/src/errors/index.ts b/packages/shared/src/errors/index.ts index cddde263..02e0c0c3 100644 --- a/packages/shared/src/errors/index.ts +++ b/packages/shared/src/errors/index.ts @@ -13,5 +13,4 @@ export { CoreWorkerError, } from "./worker-errors"; -// Export dispatcher errors -export { GitHubRepositoryError } from "./dispatcher-errors"; +// Dispatcher errors - GitHub-specific errors moved to GitHub module diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index caf75a93..2a774e3d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -17,7 +17,5 @@ export * from "./database"; // Export encryption utilities export * from "./utils/encryption"; -// Export testing utilities -export * from "./testing"; // Export error classes export * from "./errors"; diff --git a/packages/shared/src/utils/encryption.ts b/packages/shared/src/utils/encryption.ts index 36fb2ec7..7de29ee1 100644 --- a/packages/shared/src/utils/encryption.ts +++ b/packages/shared/src/utils/encryption.ts @@ -3,16 +3,48 @@ import * as crypto from "node:crypto"; const IV_LENGTH = 12; // 96-bit nonce for AES-GCM /** - * Get encryption key from environment, properly padded + * Get encryption key from environment with validation + * + * IMPORTANT: The ENCRYPTION_KEY must be exactly 32 bytes (256 bits) for AES-256. + * Generate a secure key using: `openssl rand -base64 32` or `openssl rand -hex 32` */ -function getEncryptionKey(): string { +function getEncryptionKey(): Buffer { const key = process.env.ENCRYPTION_KEY || ""; if (!key) { throw new Error( "ENCRYPTION_KEY environment variable is required for secure operation" ); } - return key.padEnd(32).slice(0, 32); + + // Try to decode as base64 first (most common format) + let keyBuffer: Buffer; + try { + keyBuffer = Buffer.from(key, "base64"); + if (keyBuffer.length === 32) { + return keyBuffer; + } + } catch { + // Not valid base64, try other formats + } + + // Try as hex + if (/^[0-9a-fA-F]{64}$/.test(key)) { + keyBuffer = Buffer.from(key, "hex"); + if (keyBuffer.length === 32) { + return keyBuffer; + } + } + + // Try as UTF-8 (for backward compatibility with existing keys) + keyBuffer = Buffer.from(key, "utf8"); + if (keyBuffer.length === 32) { + return keyBuffer; + } + + throw new Error( + `ENCRYPTION_KEY must be exactly 32 bytes (256 bits) when decoded, got ${keyBuffer.length} bytes. ` + + `Generate a valid key with: openssl rand -base64 32` + ); } /** @@ -21,11 +53,7 @@ function getEncryptionKey(): string { export function encrypt(text: string): string { const encryptionKey = getEncryptionKey(); const iv = crypto.randomBytes(IV_LENGTH); - const cipher = crypto.createCipheriv( - "aes-256-gcm", - Buffer.from(encryptionKey, "utf8"), - iv - ); + const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, iv); const encrypted = Buffer.concat([ cipher.update(text, "utf8"), cipher.final(), @@ -44,11 +72,7 @@ export function decrypt(text: string): string { const iv = Buffer.from(parts[0]!, "hex"); const tag = Buffer.from(parts[1]!, "hex"); const encryptedText = Buffer.from(parts[2]!, "hex"); - const decipher = crypto.createDecipheriv( - "aes-256-gcm", - Buffer.from(encryptionKey, "utf8"), - iv - ); + const decipher = crypto.createDecipheriv("aes-256-gcm", encryptionKey, iv); decipher.setAuthTag(tag); const decrypted = Buffer.concat([ decipher.update(encryptedText), diff --git a/packages/worker/agent.md b/packages/worker/agent.md index dd2fe706..f0031c98 100644 --- a/packages/worker/agent.md +++ b/packages/worker/agent.md @@ -29,6 +29,7 @@ Claude Code execution environment. Processes user requests in isolated container - **Workspace persistence**: Uses `/workspace` volume for session continuity - **Auto-resume**: Claude CLI `--resume` flag maintains conversation context - **Thread isolation**: Workers only process messages for their assigned thread +- **NEVER use console.log/warn/error** - ALWAYS use logger from `@peerbot/shared` ## Environment Variables - `USER_ID`: Slack user ID for session association diff --git a/packages/worker/scripts/worker-entrypoint.sh b/packages/worker/scripts/worker-entrypoint.sh index c634f9d2..4a43016b 100644 --- a/packages/worker/scripts/worker-entrypoint.sh +++ b/packages/worker/scripts/worker-entrypoint.sh @@ -192,4 +192,11 @@ echo "🚀 Executing Claude Worker..." if [ "$(pwd)" != "/app/packages/worker" ]; then cd /app/packages/worker || { echo "❌ Failed to cd to /app/packages/worker"; exit 1; } fi -exec bun run dist/src/index.js \ No newline at end of file + +# In development mode, run from source to avoid path resolution issues with modules +if [ "${NODE_ENV}" = "development" ]; then + echo "📝 Running in development mode from source..." + exec bun run src/index.ts +else + exec bun run dist/src/index.js +fi \ No newline at end of file diff --git a/packages/worker/src/core/prompt-generation.ts b/packages/worker/src/core/prompt-generation.ts index 63607b6d..47c77a3c 100644 --- a/packages/worker/src/core/prompt-generation.ts +++ b/packages/worker/src/core/prompt-generation.ts @@ -82,7 +82,7 @@ function generateEnvironmentSection(context: SessionContext): string { sections.push("## Working Environment"); if (context.repositoryUrl) { - sections.push("You are working in a user-specific GitHub repository:"); + sections.push("You are working in a user-specific repository:"); sections.push(`- Repository: ${context.repositoryUrl}`); sections.push( `- Working Directory: ${context.workingDirectory || "/workspace"}` @@ -101,7 +101,7 @@ function generateEnvironmentSection(context: SessionContext): string { sections.push("Container Information:"); sections.push("- This is an ephemeral Kubernetes job container"); sections.push("- Maximum execution time: 5 minutes"); - sections.push("- Changes will be persisted to GitHub "); + sections.push("- Changes will be persisted to the repository"); sections.push("- Progress updates are streamed to Slack in real-time"); return `${sections.join("\n")}\n\n`; @@ -118,7 +118,7 @@ You are responding to a user in Slack through a Kubernetes-based Claude Code sys 1. **Progress Updates**: Your progress is automatically streamed to Slack 2. **Thread Context**: This conversation may be part of an ongoing thread 3. **File Changes**: After making any code changes, you MUST commit and push them using git commands (git add, git commit, git push) -4. **Links**: Users will receive GitHub.dev links and PR creation links +4. **Links**: Users will receive repository links and PR creation links when working with repositories 5. **Timeout**: You have a 5-minute timeout - work efficiently Keep responses concise but helpful. Focus on solving the user's specific request. diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 89aa6344..165d1af6 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -2,11 +2,11 @@ import { initSentry, createLogger } from "@peerbot/shared"; -// Force rebuild to deploy MCP config fix - timestamp: 1756399400 - // Initialize Sentry monitoring initSentry(); +import { moduleRegistry } from "../../../modules"; + const logger = createLogger("worker"); import { QueuePersistentClaudeWorker } from "./persistent-task-worker"; @@ -23,6 +23,10 @@ export { ClaudeWorker } from "./claude-worker"; * Main entry point - now supports both queue-based and legacy workers */ async function main() { + // Initialize available modules + await moduleRegistry.initAll(); + logger.info("✅ Modules initialized"); + logger.info( "🔄 Starting in queue mode (dynamic deployment-based persistent worker)" ); @@ -135,5 +139,3 @@ async function appendTerminationMessage(signal: string): Promise { export type { WorkerConfig } from "./types"; main(); - -// Cache bust Sat Aug 30 18:38:05 BST 2025 diff --git a/packages/worker/src/module-integration.ts b/packages/worker/src/module-integration.ts new file mode 100644 index 00000000..c8d250b6 --- /dev/null +++ b/packages/worker/src/module-integration.ts @@ -0,0 +1,69 @@ +import { + moduleRegistry, + type SessionContext, + type ActionButton, +} from "../../../modules"; +import { createLogger } from "@peerbot/shared"; + +const logger = createLogger("worker"); + +export async function onSessionStart( + context: SessionContext +): Promise { + let updatedContext = context; + + const workerModules = moduleRegistry.getWorkerModules(); + for (const module of workerModules) { + if (module.onSessionStart) { + try { + updatedContext = await module.onSessionStart(updatedContext); + } catch (error) { + logger.error( + `Failed to execute onSessionStart for module ${module.name}:`, + error + ); + } + } + } + + return updatedContext; +} + +export async function onSessionEnd( + context: SessionContext +): Promise { + const allButtons: ActionButton[] = []; + + const workerModules = moduleRegistry.getWorkerModules(); + for (const module of workerModules) { + if (module.onSessionEnd) { + try { + const buttons = await module.onSessionEnd(context); + allButtons.push(...buttons); + } catch (error) { + logger.error( + `Failed to execute onSessionEnd for module ${module.name}:`, + error + ); + } + } + } + + return allButtons; +} + +export async function initModuleWorkspace(config: any): Promise { + const workerModules = moduleRegistry.getWorkerModules(); + for (const module of workerModules) { + if (module.initWorkspace) { + try { + await module.initWorkspace(config); + } catch (error) { + logger.error( + `Failed to initialize workspace for module ${module.name}:`, + error + ); + } + } + } +} diff --git a/packages/worker/src/persistent-task-worker.ts b/packages/worker/src/persistent-task-worker.ts index 0548066a..f0b88003 100644 --- a/packages/worker/src/persistent-task-worker.ts +++ b/packages/worker/src/persistent-task-worker.ts @@ -26,9 +26,6 @@ export class QueuePersistentClaudeWorker { this.userId = userId; this.targetThreadId = targetThreadId; - // Load initial configuration from environment - // this.config = this.loadConfigFromEnv(); - // Get deployment name from environment const deploymentName = process.env.DEPLOYMENT_NAME; if (!deploymentName) { diff --git a/packages/worker/src/queue/queue-consumer.ts b/packages/worker/src/queue/queue-consumer.ts index 0dbc1e9e..f3e61236 100644 --- a/packages/worker/src/queue/queue-consumer.ts +++ b/packages/worker/src/queue/queue-consumer.ts @@ -389,8 +389,7 @@ export class WorkerQueueConsumer { botResponseTs: platformMetadata.botResponseTs, // Pass through bot response timestamp claudeOptions: JSON.stringify(claudeOptions), workspace: { - baseDirectory: process.env.WORKSPACE_DIR || "/workspace", - githubToken: process.env.GITHUB_TOKEN!, + baseDirectory: "/workspace", }, }; } diff --git a/packages/worker/src/task-queue-integration.ts b/packages/worker/src/task-queue-integration.ts index afd35cd1..1425ff31 100644 --- a/packages/worker/src/task-queue-integration.ts +++ b/packages/worker/src/task-queue-integration.ts @@ -2,6 +2,7 @@ import PgBoss from "pg-boss"; import { createLogger } from "@peerbot/shared"; +// Dynamic module imports - no hardcoded GitHub types const logger = createLogger("worker"); @@ -277,31 +278,44 @@ export class QueueIntegration { `About to check for PR in directory: ${workingDir}, branch: ${branch}` ); - // First check if gh CLI is authenticated + // Check if GitHub CLI is authenticated through module + let isAuthenticated = false; try { - logger.info("Checking GitHub CLI authentication..."); - execSync("gh auth status", { - cwd: workingDir, - stdio: "pipe", - timeout: 3000, // 3 second timeout - }); - logger.info("GitHub CLI is authenticated"); - } catch (authError: any) { - // If GH_TOKEN is set, authentication is available even if gh auth status fails - if (process.env.GH_TOKEN || process.env.GITHUB_TOKEN) { + const { moduleRegistry } = await import("../../../modules"); + const githubModule = moduleRegistry.getModule("github"); + if (githubModule && "isGitHubCLIAuthenticated" in githubModule) { + isAuthenticated = await ( + githubModule as any + ).isGitHubCLIAuthenticated(workingDir); logger.info( - "Using GH_TOKEN/GITHUB_TOKEN environment variable for authentication" + `GitHub CLI authentication status: ${isAuthenticated}` ); } else { - logger.warn( - "GitHub CLI not authenticated and no token found, skipping PR detection" - ); - return { - branch, - hasGitChanges: status.hasChanges, - pullRequestUrl: undefined, - }; + // Fallback to direct check + logger.info("Checking GitHub CLI authentication (fallback)..."); + execSync("gh auth status", { + cwd: workingDir, + stdio: "pipe", + timeout: 3000, + }); + isAuthenticated = true; } + } catch (authError: any) { + logger.warn("GitHub CLI not authenticated, skipping PR detection"); + return { + branch, + hasGitChanges: status.hasChanges, + pullRequestUrl: undefined, + }; + } + + if (!isAuthenticated) { + logger.warn("GitHub CLI not authenticated, skipping PR detection"); + return { + branch, + hasGitChanges: status.hasChanges, + pullRequestUrl: undefined, + }; } // Try to get PR information @@ -586,8 +600,25 @@ export class QueueIntegration { } try { - // Generate GitHub OAuth URL for authentication - const authUrl = `${process.env.INGRESS_URL || "http://localhost:8080"}/api/github/oauth/authorize?userId=${process.env.USER_ID}`; + // Generate authentication URL through module system + let authUrl = `${process.env.INGRESS_URL || "http://localhost:8080"}/login`; + let loginButtonText = "🔗 Login"; + + try { + const { moduleRegistry } = await import("../../../modules"); + const githubModule = moduleRegistry.getModule("github"); + if (githubModule && "generateOAuthUrl" in githubModule) { + authUrl = (githubModule as any).generateOAuthUrl( + process.env.USER_ID || "" + ); + loginButtonText = "🔗 Login with GitHub"; + } + } catch (moduleError) { + logger.warn( + "Failed to get OAuth URL from module, using fallback:", + moduleError + ); + } // Create a rich message with buttons const blocks = [ @@ -605,7 +636,7 @@ export class QueueIntegration { type: "button", text: { type: "plain_text", - text: "🔐 Connect GitHub", + text: loginButtonText, }, url: authUrl, action_id: "github_login", diff --git a/packages/worker/src/types.ts b/packages/worker/src/types.ts index f6c007a4..ca4759c9 100644 --- a/packages/worker/src/types.ts +++ b/packages/worker/src/types.ts @@ -15,13 +15,11 @@ export interface WorkerConfig { resumeSessionId?: string; // Claude session ID to resume from workspace: { baseDirectory: string; - githubToken: string; }; } export interface WorkspaceSetupConfig { baseDirectory: string; - githubToken: string; } export interface GitRepository { diff --git a/packages/worker/src/workspace-manager.ts b/packages/worker/src/workspace-manager.ts index 60a153a4..c6581972 100644 --- a/packages/worker/src/workspace-manager.ts +++ b/packages/worker/src/workspace-manager.ts @@ -116,23 +116,7 @@ export class WorkspaceManager { // Setup git configuration await this.setupGitConfig(userDirectory, username); - // Setup GitHub CLI authentication if token is available - if (process.env.GITHUB_TOKEN) { - try { - logger.info("Setting up GitHub CLI authentication..."); - await execAsync( - `echo "${process.env.GITHUB_TOKEN}" | gh auth login --with-token`, - { - cwd: userDirectory, - env: { ...process.env, GH_TOKEN: process.env.GITHUB_TOKEN }, - } - ); - logger.info("GitHub CLI authentication configured successfully"); - } catch (error) { - logger.warn("Failed to setup GitHub CLI authentication:", error); - // Non-fatal - continue without gh CLI - } - } + // Note: GitHub authentication is handled by GitHub module during workspace initialization hook // Get repository info const repository = await this.getRepositoryInfo( @@ -187,8 +171,8 @@ export class WorkspaceManager { `Cloning repository ${repositoryUrl} to ${targetDirectory}...` ); - // Use GitHub token for authentication - const authenticatedUrl = this.addGitHubAuth(repositoryUrl); + // Note: GitHub authentication is handled by the GitHub module through workspace hooks + const authenticatedUrl = repositoryUrl; const { stderr } = await execAsync( `git clone "${authenticatedUrl}" "${targetDirectory}"`, @@ -395,27 +379,6 @@ export class WorkspaceManager { } } - /** - * Add GitHub authentication to URL - */ - private addGitHubAuth(repositoryUrl: string): string { - try { - const url = new URL(repositoryUrl); - - if (url.hostname === "github.com") { - // Convert to authenticated HTTPS URL - url.username = "x-access-token"; - url.password = this.config.githubToken; - return url.toString(); - } - - return repositoryUrl; - } catch (error) { - logger.warn("Failed to parse repository URL, using as-is:", error); - return repositoryUrl; - } - } - /** * Check if directory exists */ diff --git a/packages/worker/tsconfig.json b/packages/worker/tsconfig.json index 0212a837..e05ac48d 100644 --- a/packages/worker/tsconfig.json +++ b/packages/worker/tsconfig.json @@ -2,7 +2,6 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "dist", - "rootDir": ".", "declaration": true, "declarationMap": true, "sourceMap": true, @@ -13,6 +12,11 @@ "noEmit": false, "allowImportingTsExtensions": false }, - "include": ["src/**/*", "mcp/**/*"], + "include": [ + "src/**/*", + "mcp/**/*", + "../../modules/**/*", + "../shared/src/**/*" + ], "exclude": ["dist", "node_modules", "**/*.test.ts", "**/__tests__/**"] } diff --git a/tsconfig.json b/tsconfig.json index 090fe17e..20f7d697 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,9 +23,23 @@ // Some stricter flags "noUnusedLocals": true, "noUnusedParameters": true, - "noPropertyAccessFromIndexSignature": false + "noPropertyAccessFromIndexSignature": false, + + // Workspace module resolution + "baseUrl": ".", + "paths": { + "@peerbot/shared": ["packages/shared/src/index.ts"], + "@peerbot/shared/*": ["packages/shared/src/*"], + "@peerbot/dispatcher": ["packages/dispatcher/src/index.ts"], + "@peerbot/dispatcher/*": ["packages/dispatcher/src/*"], + "@peerbot/orchestrator": ["packages/orchestrator/src/index.ts"], + "@peerbot/orchestrator/*": ["packages/orchestrator/src/*"], + "@peerbot/worker": ["packages/worker/src/index.ts"], + "@peerbot/worker/*": ["packages/worker/src/*"] + } }, "include": [ + "modules/**/*", "packages/*/src/**/*", "packages/*/__tests__/**/*", "src/**/*",