diff --git a/src/README.md b/src/README.md index 5e9bca2..a1b13bf 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 multiple 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,14 +22,19 @@ 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 `function_executed` events. It expects + a similar setup in the current working directory as the `local-run.ts` + entrypoint. 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` -hook. The Slack CLI will automatically use the version of the +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 `slack run` command in your terminal. This will automatically run the `start` @@ -42,7 +47,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 +65,8 @@ 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` ⚠️ 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..1390099 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -5,3 +5,10 @@ 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"; + +// Dependencies for self-hosted-socket-mode.ts +export { SocketModeClient } from "npm:@slack/socket-mode@2.0.5"; + +// Logging dependencies for self-hosted entrypoints +export type { Logger } from "npm:@slack/logger@4.0.0"; +export { ConsoleLogger, LogLevel } 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-socket-mode.ts b/src/self-hosted-socket-mode.ts new file mode 100644 index 0000000..ff6e4f6 --- /dev/null +++ b/src/self-hosted-socket-mode.ts @@ -0,0 +1,211 @@ +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`, + ); + } + + 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); + } + // Run the function in a subprocess with restricted permissions + const subprocessCommand = getCommandline( + Deno.mainModule, + manifest, + devDomain, + hookCLI, + ); + + const clientOptions = slackApiUrl ? { slackApiUrl } : undefined; + const client = new SocketModeClient({ + appToken, + logLevel, + logger, + clientOptions, + }); + + // Listen for incoming events + client.on("slack_event", async ({ body, ack, retry_num, retry_reason }) => { + logger.debug("Received event:", JSON.stringify(body, null, 2)); + try { + // deno-lint-ignore no-explicit-any + const payload: InvocationPayload = { + body, + context: { + bot_access_token: body.event.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); + } + + const logLevelStr = Deno.env.get("SLACK_LOG_LEVEL") || "INFO"; + const logLevel = LogLevel[logLevelStr as keyof typeof LogLevel] || + LogLevel.INFO; + + 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); + } +}