diff --git a/src/README.md b/src/README.md index 5e9bca2..eacada3 100644 --- a/src/README.md +++ b/src/README.md @@ -10,7 +10,7 @@ project is to provide modules for: 3. Dispatching event payloads to individual functions by callback ID and running them (`src/dispatch-payload.ts`) -This library has two modes of operation: +This library has four modes of operation: 1. Using `mod.ts` as the entrypoint, a directory containing function code files to be loaded at runtime must be provided as an argument. This directory must @@ -22,13 +22,21 @@ This library has two modes of operation: contain a `manifest.json`, `manifest.ts` or `manifest.js` file, which in turn must contain function definitions that include a `source_file` property. This property is used to determine which function to load and run at runtime. +3. Using `self-hosted-socket-mode.ts` as the entrypoint. This establishes a persistent + WebSocket connection to Slack using Socket Mode and maintains a long-running process that listens for events. +4. Using `self-hosted-http-mode.ts` as the entrypoint. This starts an HTTP server that + accepts events via POST requests to `/events` endpoints, similar to Bolt JS's + HTTPReceiver. The only required environment variable is + `SLACK_SIGNING_SECRET`; optional variables include `PORT`, + `SLACK_SIGNATURE_VERIFICATION`, and `SLACK_API_URL` (e.g. + `SLACK_SIGNING_SECRET=xxx PORT=3000 deno run src/self-hosted-http-mode.ts`). Regardless of which mode of operation used, each runtime definition for a function is specified in its own file and must be the default export. ## Usage -By default, your Slack app has a `/slack.json` file that defines a `get-hooks` +By default, your Slack app has a `.slack/hooks.json` file that defines a `get-hooks` hook. The Slack CLI will automatically use the version of the `deno-slack-runtime` that is specified by the version of the `get-hooks` script that you're using. To use this library via the Slack CLI out of the box, use the @@ -42,7 +50,7 @@ You also have the option to You can change the script that runs by specifying a new script for the `start` command. For instance, if you wanted to point to your local instance of this repo, you could accomplish that by adding a `start` command to your -`/slack.json` file and setting it to the following: +`.slack/hooks.json` file and setting it to the following: ```json { @@ -60,6 +68,10 @@ operating this library in: `deno run -q --config=deno.jsonc --allow-read --allow-net https://deno.land/x/deno_slack_runtime@0.1.1/mod.ts ./` 2. Local project with a manifest file: `deno run -q --config=deno.jsonc --allow-read --allow-net https://deno.land/x/deno_slack_runtime@0.1.1/local-run.ts` +3. Self-hosted socket mode: + `deno run -q --config=deno.jsonc --allow-read --allow-net --allow-run --allow-env --allow-sys=osRelease https://deno.land/x/deno_slack_runtime@0.1.1/self-hosted-socket-mode.ts` +4. Self-hosted HTTP Receiver: + `deno run -q --config=deno.jsonc --allow-read --allow-net --allow-env --allow-sys=osRelease https://deno.land/x/deno_slack_runtime@0.1.1/self-hosted-http-mode.ts` ⚠️ Don't forget to update the version specifier in the URL inside the above commands to match the version you want to test! You can also drop the `@` and diff --git a/src/deps.ts b/src/deps.ts index e0c1d99..9544f49 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -5,3 +5,8 @@ export { getManifest } from "https://deno.land/x/deno_slack_hooks@1.4.0/get_mani export { parse } from "https://deno.land/std@0.99.0/flags/mod.ts"; export { getProtocolInterface } from "https://deno.land/x/deno_slack_protocols@0.0.2/mod.ts"; export type { Protocol } from "https://deno.land/x/deno_slack_protocols@0.0.2/types.ts"; + +// Socket Mode dependencies (npm packages via Deno) +export { SocketModeClient } from "npm:@slack/socket-mode@2.0.5"; +export { ConsoleLogger, LogLevel } from "npm:@slack/logger@4.0.0"; +export type { Logger } from "npm:@slack/logger@4.0.0"; diff --git a/src/local-run.ts b/src/local-run.ts index 240163d..68346a5 100644 --- a/src/local-run.ts +++ b/src/local-run.ts @@ -56,6 +56,7 @@ export const getCommandline = function ( "--config=deno.jsonc", "--allow-read", "--allow-env", + "--allow-sys=osRelease", ]; const allowedDomains = manifest.outgoing_domains ?? []; diff --git a/src/self-hosted-http-mode.ts b/src/self-hosted-http-mode.ts new file mode 100644 index 0000000..9e63d19 --- /dev/null +++ b/src/self-hosted-http-mode.ts @@ -0,0 +1,450 @@ +import { getManifest, getProtocolInterface, Protocol } from "./deps.ts"; +import { DispatchPayload } from "./dispatch-payload.ts"; +import type { InvocationPayload } from "./types.ts"; + +/** + * HTTP receiver options aligned with Bolt for JavaScript (bolt-js) HTTPReceiver. + * @see https://github.com/slackapi/bolt-js/blob/main/src/receivers/HTTPReceiver.ts + * + * Slack API calls from the runtime (e.g. in run-function) use BaseSlackAPIClient + * from the deno_slack_api package. + */ +export interface HTTPReceiverOptions { + /** Signing secret from Slack app credentials (required for request verification). */ + signingSecret: string; + /** Port to listen on. Default: 3000. */ + port?: number; + /** Event endpoint path(s). Default: ["/slack/events", "/events"]. */ + endpoints?: string | string[]; + /** Verify request signature with signing secret. Default: true. */ + signatureVerification?: boolean; + /** Override Slack API base URL (e.g. for dev instances). */ + slackApiUrl?: string; +} + +/** + * Prompts the user for the Request URL to set under settings.event_subscriptions + * in the app manifest. Call this before apps.manifest.validate so the user can set + * the URL in the manifest before validation runs. + * @returns The trimmed URL string, or null if the user skipped (Enter with no input). + */ +export function promptRequestUrlForEventSubscriptions(): string | null { + const raw = prompt( + "Request URL to set under settings.event_subscriptions in your app manifest (optional; press Enter to skip):", + ); + const url = raw?.trim() ?? null; + return url === "" ? null : url; +} + +/** + * Constant-time string comparison to mitigate timing attacks (aligned with bolt-js verify-request). + */ +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let result = 0; + for (let i = 0; i < a.length; i++) { + result |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return result === 0; +} + +/** + * Verifies the signature of an incoming request from Slack using HMAC SHA256. + * Behavior aligned with bolt-js HTTPReceiver / verify-request. + * @see https://api.slack.com/authentication/verifying-requests-from-slack + * @see https://github.com/slackapi/bolt-js/blob/main/src/receivers/verify-request.ts + */ +async function verifySlackRequest( + signingSecret: string, + body: string, + headers: Headers, +): Promise { + const signature = headers.get("x-slack-signature"); + const timestamp = headers.get("x-slack-request-timestamp"); + + if (!signature || !timestamp) { + return false; + } + + const requestTimestamp = Number.parseInt(timestamp, 10); + if (Number.isNaN(requestTimestamp)) { + return false; + } + + // Check if request is stale (older than 5 minutes), same as bolt-js + const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 60 * 5; + if (requestTimestamp < fiveMinutesAgo) { + return false; + } + + const [version, signatureHash] = signature.split("="); + if (version !== "v0" || !signatureHash) { + return false; + } + + const sigBaseString = `${version}:${timestamp}:${body}`; + const encoder = new TextEncoder(); + const keyData = encoder.encode(signingSecret); + const messageData = encoder.encode(sigBaseString); + + const cryptoKey = await crypto.subtle.importKey( + "raw", + keyData, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + + const signatureBuffer = await crypto.subtle.sign( + "HMAC", + cryptoKey, + messageData, + ); + const computedHash = Array.from(new Uint8Array(signatureBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + return timingSafeEqual(computedHash, signatureHash); +} + +/** + * Parse event endpoint body like bolt-js HTTPModuleFunctions.parseHTTPRequestBody: + * application/x-www-form-urlencoded with a "payload" field, or raw JSON. + */ +// deno-lint-ignore no-explicit-any +function parseEventRequestBody(bodyText: string, contentType: string | null): any { + if (contentType?.includes("application/x-www-form-urlencoded")) { + const params = new URLSearchParams(bodyText); + const payload = params.get("payload"); + if (typeof payload === "string") { + return JSON.parse(payload); + } + return Object.fromEntries(params); + } + return JSON.parse(bodyText); +} + +/** + * Runs the app as an HTTP server that accepts Slack events via POST to the + * configured endpoints. Request handling (verify → parse → ssl_check → + * url_verification → event dispatch) is aligned with Bolt for JavaScript + * HTTPReceiver. Handler code uses BaseSlackAPIClient from deno_slack_api for + * Slack API calls (e.g. functions.completeSuccess in run-function). + * + * @see https://github.com/slackapi/bolt-js (HTTPReceiver) + */ +export const runWithHTTPReceiver = async function ( + create: typeof getManifest, + dispatch: typeof DispatchPayload, + hookCLI: Protocol, + options: HTTPReceiverOptions, +): Promise { + const { + signingSecret, + port = 3000, + endpoints = ["/slack/events", "/events"], + signatureVerification = true, + slackApiUrl, + } = options; + + // Normalize endpoints to array + const eventEndpoints = Array.isArray(endpoints) ? endpoints : [endpoints]; + + // Load the manifest to get function definitions + const workingDirectory = Deno.cwd(); + const manifest = await create(workingDirectory); + + if (!manifest.functions) { + throw new Error( + `No function definitions were found in the manifest! manifest.functions: ${manifest.functions}`, + ); + } + + hookCLI.log( + `Loaded manifest with functions: ${ + Object.keys(manifest.functions).join(", ") + }`, + ); + + // Store env for passing to handlers + const env = Deno.env.toObject(); + if (slackApiUrl) { + env["SLACK_API_URL"] = slackApiUrl; + } + + // Request handler + const handler = async (request: Request): Promise => { + const url = new URL(request.url); + const pathname = url.pathname; + + // Health check endpoint + if (request.method === "GET" && pathname === "/health") { + // TODO: remove + hookCLI.log(`[info] ${request.method} ${pathname} called`); + return new Response("OK", { status: 200 }); + } + + // POST /functions — raw invocation payload (no signature verification), same as mod.ts + if (request.method === "POST" && pathname === "/functions") { + // TODO: remove + hookCLI.log(`[info] ${request.method} ${pathname} called`); + try { + const body = await request.text(); + // deno-lint-ignore no-explicit-any + const payload: InvocationPayload = JSON.parse(body); + const resp = await dispatch(payload, hookCLI, (functionCallbackId) => { + const functionDefn = manifest.functions[functionCallbackId]; + if (!functionDefn) { + throw new Error( + `No function definition for function callback id ${functionCallbackId} was found in the manifest! manifest.functions: ${ + Object.keys(manifest.functions).join(", ") + }`, + ); + } + const functionFile = + `file://${workingDirectory}/${functionDefn.source_file}`; + return functionFile; + }); + return new Response(JSON.stringify(resp ?? {}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + hookCLI.error("❌ Error processing /functions request:", error); + if (error instanceof Error && error.stack) { + hookCLI.error(error.stack); + } + return new Response( + JSON.stringify({ error: String(error) }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + }, + ); + } + } + + // Event endpoints + if (request.method === "POST" && eventEndpoints.includes(pathname)) { + // TODO: remove + hookCLI.log(`[info] ${request.method} ${pathname} called`); + try { + const body = await request.text(); + + // Verify request signature + if (signatureVerification) { + const isValid = await verifySlackRequest( + signingSecret, + body, + request.headers, + ); + if (!isValid) { + hookCLI.warn("Invalid request signature"); + return new Response("Invalid signature", { status: 401 }); + } + } + + // Parse the body (JSON or form-encoded with payload= like bolt-js) + const parsedBody = parseEventRequestBody( + body, + request.headers.get("content-type"), + ); + + // Log incoming event + const eventType = parsedBody.type || parsedBody.event?.type || + "unknown"; + const eventId = parsedBody.event_id || "no-id"; + hookCLI.log(`📨 Received event: ${eventType} (${eventId})`); + + // Handle URL verification challenge (response format matches bolt-js + // HTTPReceiver buildUrlVerificationResponse) + if (parsedBody.type === "url_verification") { + hookCLI.log("✅ Responding to URL verification challenge"); + const challenge = parsedBody.challenge; + return new Response(JSON.stringify({ challenge }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + // Handle SSL check + if (parsedBody.ssl_check === "1") { + hookCLI.log("🔒 SSL check request"); + return new Response("OK", { status: 200 }); + } + + // Extract retry information + const retryNum = request.headers.get("x-slack-retry-num"); + const retryReason = request.headers.get("x-slack-retry-reason"); + if (retryNum) { + hookCLI.warn( + `🔄 Retry attempt ${retryNum}${ + retryReason ? `: ${retryReason}` : "" + }`, + ); + } + + // Log additional event details + if (parsedBody.event) { + const { type, user, channel, ts } = parsedBody.event; + hookCLI.log( + ` Event details: type=${type}${user ? `, user=${user}` : ""}${ + channel ? `, channel=${channel}` : "" + }${ts ? `, ts=${ts}` : ""}`, + ); + } + + // Log function callback_id if present + const callbackId = parsedBody.event?.function?.callback_id || + parsedBody.function_data?.function?.callback_id; + if (callbackId) { + hookCLI.log(` Function: ${callbackId}`); + } + + // Convert to InvocationPayload format + // deno-lint-ignore no-explicit-any + const payload: InvocationPayload = { + body: parsedBody, + context: { + bot_access_token: parsedBody.event?.bot_access_token || + parsedBody.bot_access_token || "", + team_id: parsedBody.team_id || "", + variables: env, + }, + }; + + // Dispatch the payload to the appropriate function handler + const resp = await dispatch(payload, hookCLI, (functionCallbackId) => { + const functionDefn = manifest.functions[functionCallbackId]; + if (!functionDefn) { + throw new Error( + `No function definition for function callback id ${functionCallbackId} was found in the manifest! manifest.functions: ${ + Object.keys(manifest.functions).join(", ") + }`, + ); + } + + const functionFile = + `file://${workingDirectory}/${functionDefn.source_file}`; + return functionFile; + }); + + // Return response + hookCLI.log(`✓ Event processed successfully`); + + if (resp && Object.keys(resp).length > 0) { + hookCLI.log(` Response: ${JSON.stringify(resp)}`); + return new Response(JSON.stringify(resp), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response("", { status: 200 }); + } catch (error) { + hookCLI.error("❌ Error processing event:", error); + if (error instanceof Error && error.stack) { + hookCLI.error(error.stack); + } + return new Response( + JSON.stringify({ error: String(error) }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + }, + ); + } + } + + // 404 for unknown routes + // TODO: remove + hookCLI.log(`[info] ${request.method} ${pathname} — not found (404)`); + return new Response("Not Found", { status: 404 }); + }; + + // Start the server + hookCLI.log(`🚀 Starting HTTP server on port ${port}`); + hookCLI.log(`📡 Event endpoints: ${eventEndpoints.join(", ")}`); + hookCLI.log(`📮 Invocation endpoint: POST /functions`); + hookCLI.log(`🏥 Health check: /health`); + + const server = Deno.serve({ + port, + onListen: ({ hostname, port }) => { + hookCLI.log(`⚡️ HTTP receiver listening on http://${hostname}:${port}`); + hookCLI.log(`\n💡 To test with Slack:`); + hookCLI.log(` 1. Expose this server with ngrok: ngrok http ${port}`); + hookCLI.log( + ` 2. Set Event Request URL in your Slack app to: https://your-ngrok-url${ + eventEndpoints[0] + }`, + ); + }, + }, handler); + + // Handle graceful shutdown + const handleShutdown = async (signal: string) => { + hookCLI.log(`\nReceived ${signal}, shutting down gracefully...`); + try { + await server.shutdown(); + hookCLI.log("Server stopped"); + Deno.exit(0); + } catch (error) { + hookCLI.error("Error during shutdown:", error); + Deno.exit(1); + } + }; + + Deno.addSignalListener("SIGINT", () => handleShutdown("SIGINT")); + Deno.addSignalListener("SIGTERM", () => handleShutdown("SIGTERM")); + + // Wait for the server to finish + await server.finished; +}; + +if (import.meta.main) { + const signingSecret = Deno.env.get("SLACK_SIGNING_SECRET"); + if (!signingSecret) { + console.error( + "Error: SLACK_SIGNING_SECRET environment variable is required", + ); + console.error( + "Get it from: https://api.slack.com/apps → Your App → Basic Information → App Credentials", + ); + Deno.exit(1); + } + + const port = Deno.env.get("PORT") + ? Number.parseInt(Deno.env.get("PORT")!, 10) + : 3000; + + const signatureVerification = + Deno.env.get("SLACK_SIGNATURE_VERIFICATION") !== "false"; + const slackApiUrl = Deno.env.get("SLACK_API_URL"); + + const hookCLI = getProtocolInterface(Deno.args); + + // Prompt for the Request URL before any manifest validate; caller can set it in manifest then validate + const requestUrl = promptRequestUrlForEventSubscriptions(); + if (requestUrl) { + console.log( + `Set this Request URL in your app manifest under /settings/event_subscriptions: ${requestUrl}`, + ); + } else { + console.log( + "Remember to set the Request URL under settings.event_subscriptions in your app manifest when using Event Subscriptions.", + ); + } + + await runWithHTTPReceiver( + getManifest, + DispatchPayload, + hookCLI, + { + signingSecret, + port, + signatureVerification, + slackApiUrl, + }, + ); +} diff --git a/src/self-hosted-socket-mode.ts b/src/self-hosted-socket-mode.ts new file mode 100644 index 0000000..a1e0ee9 --- /dev/null +++ b/src/self-hosted-socket-mode.ts @@ -0,0 +1,226 @@ +import { + ConsoleLogger, + getManifest, + getProtocolInterface, + type Logger, + LogLevel, + Protocol, + SocketModeClient, +} from "./deps.ts"; +import { getCommandline } from "./local-run.ts"; +import type { InvocationPayload } from "./types.ts"; + +export interface SocketModeRunOptions { + appToken: string; + logger?: Logger; + logLevel?: LogLevel; + slackApiUrl?: string; +} + +/** + * @description Runs a Slack workflow app in Socket Mode by establishing a WebSocket connection to Slack. + */ +export const runWithSocketMode = async function ( + create: typeof getManifest, + hookCLI: Protocol, + options: SocketModeRunOptions, +): Promise { + const { appToken, logLevel = LogLevel.INFO, slackApiUrl } = options; + + // Set up logger + const logger = options.logger ?? (() => { + const defaultLogger = new ConsoleLogger(); + defaultLogger.setLevel(logLevel); + return defaultLogger; + })(); + + // Load the manifest to get function definitions + const workingDirectory = Deno.cwd(); + const manifest = await create(workingDirectory); + + if (!manifest.functions) { + logger.error( + `No function definitions found in the manifest`, + ); + throw new Error( + `No function definitions found in the manifest`, + ); + } + + // Reuse local-run.ts's command line so that the function runs in a subprocess with --allow-net restricted to manifest.outgoing_domains + const devDomain = slackApiUrl ? new URL(slackApiUrl).hostname : ""; + let denoExecutablePath = "deno"; + try { + denoExecutablePath = Deno.execPath(); + } catch (e) { + logger.warn("Could not get Deno executable path, using 'deno'", e); + } + const subprocessCommand = getCommandline( + Deno.mainModule, + manifest, + devDomain, + hookCLI, + ); + + // Create Socket Mode client with optional dev instance support + const clientOptions = slackApiUrl ? { slackApiUrl } : undefined; + const client = new SocketModeClient({ + appToken, + logLevel, + logger, + clientOptions, + }); + + // Listen for incoming events + client.on("slack_event", async (args: { + // deno-lint-ignore no-explicit-any + body: any; + ack: (response?: Record) => Promise; + retry_num?: number; + retry_reason?: string; + }) => { + const { body, ack, retry_num, retry_reason } = args; + + logger.debug("Received slack_event:", JSON.stringify(body, null, 2)); + + try { + // Convert the Socket Mode event into an InvocationPayload format + // that local-run-function.ts expects on stdin + // deno-lint-ignore no-explicit-any + const payload: InvocationPayload = { + body, + context: { + bot_access_token: body.event?.bot_access_token || + body.bot_access_token || "", + team_id: body.team_id || "", + variables: Deno.env.toObject(), + }, + }; + + // Add retry information if present + if (retry_num !== undefined && retry_num > 0) { + logger.warn( + `Retrying event (attempt ${retry_num})${ + retry_reason ? `: ${retry_reason}` : "" + }`, + ); + } + + // Run the function in a subprocess with the same --allow-net restriction as local-run.ts (manifest.outgoing_domains) + const commander = new Deno.Command(denoExecutablePath, { + args: subprocessCommand, + stdin: "piped", + stdout: "piped", + stderr: "piped", + cwd: workingDirectory, + }); + const subprocess = commander.spawn(); + const payloadJson = JSON.stringify(payload); + const writer = subprocess.stdin.getWriter(); + await writer.write(new TextEncoder().encode(payloadJson)); + await writer.close(); + + const output = await subprocess.output(); + const stdout = new TextDecoder().decode(output.stdout).trim(); + const stderr = new TextDecoder().decode(output.stderr); + + if (!output.success) { + logger.error( + `Function subprocess failed (exit code ${output.code}). stderr: ${ + stderr || "(none)" + }`, + ); + await ack(); + return; + } + + if (stdout) { + logger.info(`Function response: ${stdout}`); + } + + await ack({}); + logger.debug("Event processed and acknowledged"); + } catch (error) { + logger.error("Error processing event:", error); + await ack(); + } + }); + + // Handle connection lifecycle events + client.on("connected", () => { + logger.info("✅ Connected to Slack via Socket Mode"); + }); + + client.on("disconnected", () => { + logger.warn("⚠️ Disconnected from Slack"); + }); + + client.on("reconnecting", () => { + logger.info("🔄 Reconnecting to Slack..."); + }); + + client.on("error", (error: Error) => { + logger.error("❌ Socket Mode error:", error); + }); + + // Start the Socket Mode connection + logger.info("🚀 Starting Socket Mode client..."); + try { + await client.start(); + } catch (error) { + logger.error("Failed to start Socket Mode client:", error); + throw error; + } + logger.info("⚡️ Socket Mode runtime is running and listening for events"); + + // Keep the process running + // In Deno, we can use Deno.addSignalListener to handle graceful shutdown + const handleShutdown = async (signal: string) => { + logger.info(`Received ${signal}, shutting down gracefully...`); + try { + await client.disconnect(); + logger.info("Disconnected from Slack"); + Deno.exit(0); + } catch (error) { + logger.error("Error during shutdown:", error); + Deno.exit(1); + } + }; + + Deno.addSignalListener("SIGINT", () => handleShutdown("SIGINT")); + Deno.addSignalListener("SIGTERM", () => handleShutdown("SIGTERM")); +}; + +if (import.meta.main) { + const appToken = Deno.env.get("SLACK_APP_TOKEN"); + if (!appToken) { + console.error( + "Error: SLACK_APP_TOKEN environment variable is required for Socket Mode", + ); + Deno.exit(1); + } + + // Parse log level from environment + const logLevelStr = Deno.env.get("SLACK_LOG_LEVEL") || "INFO"; + const logLevel = LogLevel[logLevelStr as keyof typeof LogLevel] || + LogLevel.INFO; + + // Support for dev Slack instances + const slackApiUrl = Deno.env.get("SLACK_API_URL"); + + const hookCLI = getProtocolInterface(Deno.args); + + try { + await runWithSocketMode( + getManifest, + hookCLI, + { + appToken, + logLevel, + slackApiUrl, + }, + ); + } catch { + Deno.exit(1); + } +}