A pluggable Slack bot core for personal use, hosting multiple apps (crawlers, blog publish, etc.) behind a single Slack App.
slack-bot is an HTTP-only Request URL receiver for Slack: it does not use Socket Mode. Every incoming request is verified with HMAC-SHA256 against SLACK_SIGNING_SECRET before any plugin sees it. Plugins register declaratively with the slash commands they own and the handlers for each interaction type, and the core dispatches to the right plugin by command name or by the <plugin-name>: prefix on action_id / callback_id. The bot runs as a single replica behind Cloudflare Tunnel and Cloudflare Access, with the Slack endpoints exposed via Service Auth Bypass.
| Method | Path | Purpose |
|---|---|---|
| POST | /api/slack/commands |
Slash Commands Request URL. |
| POST | /api/slack/interactivity |
Interactivity & Shortcuts Request URL (block actions, view submissions, shortcuts, etc.). |
| POST | /api/slack/events |
Events API Request URL. Also handles the url_verification challenge automatically. |
| GET | /health/live |
Liveness probe. |
| GET | /health/ready |
Readiness probe. Returns 503 until plugins finish registering and the server is ready. |
| Variable | Required | Default | Description |
|---|---|---|---|
SLACK_SIGNING_SECRET |
Yes | - | Slack App signing secret. Used for HMAC-SHA256 verification of every Slack request. |
SLACK_BOT_TOKEN |
Yes | - | Bot User OAuth Token (xoxb-...). Used by the Slack Web API client. |
PORT |
No | 8080 |
TCP port the HTTP server listens on. |
MAX_CONCURRENT_TASKS |
No | 32 |
Maximum number of concurrent in-memory scheduler tasks. Registration beyond this limit fails. |
MAX_WEB_API_RETRIES |
No | 3 |
Maximum retry count for Slack Web API calls that hit HTTP 429. |
LOG_LEVEL |
No | info |
One of debug, info, warn, error. |
CF_ACCESS_<PLUGIN_NAME_UPPER>_CLIENT_ID |
No | - | Cloudflare Access Service Token client ID for the named plugin. Hyphens in the plugin name become underscores. |
CF_ACCESS_<PLUGIN_NAME_UPPER>_CLIENT_SECRET |
No | - | Cloudflare Access Service Token client secret for the named plugin. Same naming rule as above. |
Secrets must not be committed. In production they are injected via a Kubernetes Secret. Locally, place them in .env, which is gitignored.
Perform these steps once per Slack workspace.
- Create a new Slack App at https://api.slack.com/apps using "From scratch".
- Open Basic Information, copy the Signing Secret, and set it as
SLACK_SIGNING_SECRET. - Open OAuth & Permissions and add the bot scopes you need. The minimum recommended set is
chat:write,commands, andchat:write.public. Plugins may require additional scopes (for example,views:writefor modal-based flows). Install the app to the workspace, copy the Bot User OAuth Token (xoxb-...), and set it asSLACK_BOT_TOKEN. - Open Slash Commands and register each command exposed by the deployed plugins (for example,
/crawl-list). Follow the hyphenated naming convention described below. Set the Request URL tohttps://<your-host>/api/slack/commands. - Open Interactivity & Shortcuts, enable interactivity, and set the Request URL to
https://<your-host>/api/slack/interactivity. - Open Event Subscriptions (only required when a plugin uses it), enable events, and set the Request URL to
https://<your-host>/api/slack/events. Slack sends aurl_verificationchallenge on save; the bot answers it automatically.
The slash command list can also be generated as a Slack App manifest fragment via PluginRegistry.buildAppManifestCommands() instead of being entered by hand.
- Install runtimes and tooling:
mise install(Node.js 24, lefthook, etc.). - Enable corepack for pnpm:
corepack enable. - Install dependencies:
pnpm install. - Create a
.envfile based on the table above. Do not commit it. - Start the bot:
pnpm start(runstsx src/main.ts). - Expose the local server to Slack with ngrok:
ngrok http 8080. Use thehttps://...ngrok-free.appURL as the Request URL prefix in the Slack App settings while developing. - Run checks:
pnpm test(typecheck plus unit) andpnpm lint.
A plugin is an object that declares a name, the commands it owns, and one or more handler methods. Register plugins by passing them to bootstrap.
import { bootstrap, type Plugin } from 'slack-bot'
const pingPlugin: Plugin = {
name: 'ping',
commands: [
{
command: '/ping',
description: 'Reply with pong.',
},
],
async onCommand(ctx, body) {
// Acknowledge synchronously within the 3-second Slack deadline.
ctx.ack({ text: 'thinking...' })
// Long-running work happens after ack().
const reply = await computeReply(body.text)
// Edit the original ack message.
await ctx.originalUpdater().patch({ text: reply })
// Or send a new follow-up message via response_url.
await ctx.followUp({ text: 'done' })
},
}
bootstrap({ plugins: [pingPlugin] })When a plugin needs core services (the in-memory scheduler, the Cloudflare Access fetch helper, the Slack Web API client, the logger, or the resolved config), pass a factory function instead. bootstrap invokes the factory with a PluginDeps object at startup.
import { bootstrap, type PluginFactory } from 'slack-bot'
const crawlPlugin: PluginFactory = ({ scheduler, cfAccess, logger }) => {
const http = cfAccess.forPlugin('crawl')
return {
name: 'crawl',
commands: [{ command: '/crawl-run', description: 'Start a crawl.' }],
async onCommand(ctx, body) {
ctx.ack({ text: 'starting...' })
scheduler.schedule({
name: `crawl:${body.text}`,
intervalMs: 5000,
maxDurationMs: 30 * 60 * 1000,
async tick() {
const res = await http.request(
`https://crawlers.fohte.net/api/runs/${body.text}`,
)
const status = (await res.json()) as { done: boolean }
await ctx
.originalUpdater()
.patch({ text: `status: ${String(status.done)}` })
return { done: status.done }
},
async onError(err) {
logger.error({ event: 'crawl_tick_error', err: String(err) })
},
})
},
}
}
bootstrap({ plugins: [crawlPlugin] })Rules:
- Plugin
namemust match/^[a-z][a-z0-9-]{0,31}$/. - Plugin
nameand slash command names must be globally unique within the bot. Boot fails fast on conflict. ack()must be called within 3 seconds (Slack requirement). Long work runs afterack()and usesfollowUp()ororiginalUpdater().patch().
Recommended pattern: /<plugin-name>-<action> (for example, /crawl-list, /crawl-run, /blog-post). Slack does not namespace slash commands across apps installed in a workspace, so prefixing with the plugin name reduces the chance of collision with unrelated apps. The bot does not enforce this; it is a convention for plugin authors.
Required pattern: <plugin-name>:<action>[:<payload>]. The router dispatches interactivity payloads by splitting on the first : and looking up the registered plugin by that prefix. A payload whose prefix does not match any registered plugin produces an ephemeral error reply.
Examples: crawl:start:42, blog:retry, crawl:modal.
The bot emits structured JSON logs to stdout via pino. A redact filter scrubs known secret keys (bot token, signing secret, authorization, *_token, *_secret) so plugins do not have to think about it when logging context. Log level is configured via LOG_LEVEL.