Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

45 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

local-slack

License: MIT

A local, throwaway Slack for developing and testing Slack apps/bots — not a Slack replacement. Your bot connects to it exactly as it would to real Slack (Web API + Socket Mode + Events API), and a Slack-like web UI lets a human "act as" a workspace user and watch the users ↔ bot interaction in real time. Workspace, users and channels are defined declaratively in a config file.

It's the inverse of test-interception libraries like slack-testing-library / slack-mock: a real running server with a browser UI, plus a programmatic control API so it can also drive automated tests.

local-slack-demo.mov

What it supports

  • Web APIauth.test, chat.*, conversations.*, users.*, views.*, reactions.*, emoji.list, apps.connections.open, team.info, bots.info
  • Two delivery modes (per-app config switch):
    • Socket Mode — the bot opens a WebSocket (via apps.connections.openws://…)
    • Events API (HTTP) — signed POSTs to the bot's request URL (real x-slack-signature, so Bolt's verification passes)
  • Interactivity — Block Kit rendering, buttons → block_actions, modals via views.open/update/push + view_submission (with response_action errors/update/push/clear)
  • Slash commands and the App Home tab
  • Threads — a resizable docked thread pane (reply summaries on the parent message, conversations.replies, reply_count/latest_reply on the parent via conversations.history)
  • Message permalinkschat.getPermalink hands back a link that actually works: /archives/<channel>/p<ts>, plus ?thread_ts=…&cid=… when the message is a reply. Opening one (pasted in the address bar or clicked in a message) selects the channel, opens the thread if it was a reply, and flashes the message. Links copied out of a real workspace work too — any https://<workspace>.slack.com/… is rewritten to this server's origin when rendered, and URLs in message text are auto-linked the way Slack's client does
  • app_mention<@BOT_ID> in a message delivers app_mention to the mentioned app, in addition to the message event, exactly like real Slack. Only the app actually named gets it (unlike message, which fans out to every app in the channel), and mentions of an app that isn't in the channel are ignored
  • Unread indicators and notifications — channels with unseen messages go bold, threads with unseen replies get a dot on their reply summary (opening a channel doesn't clear its threads, matching Slack), and the 🔔 in the top bar badges the unread count and drops down a list of what you missed — click one to jump straight to that message, opening its thread if it was a reply. The same count shows in the browser tab title, and a chime plays on incoming messages (🔊 toggles it; the choice persists)
  • Human-driven reactions, edit, and delete — react from the UI (delivers reaction_added/reaction_removed), and edit/delete your own messages (delivers message_changed/message_deleted); bot messages can't be edited/deleted this way
  • Multiple apps in one workspace — declare several apps under apps:; each gets its own tokens, delivery mode, Socket Mode connection(s) and Home tab. Channel events fan out to every app that's a member; interactive components (buttons/modals) and slash commands route to the specific app that owns them
  • @mention and #channel autocomplete — type @ or # in the composer to pick a user/bot or a channel; both render inline with the same blue/highlight style they'd have once sent, but insert real <@USER_ID> / <#CHANNEL_ID|name> syntax so the bot receives an actual reference, not literal text. Pick from the menu with Tab/Enter/click, or just type the handle out in full and ignore the menu — it links on space, and again on send, so @bob/#random/@your-bot become real references however you finish typing. Only exact handle matches resolve that way: a half-typed @bo stays plain text rather than silently linking the wrong person, and an address like a@b.com is left alone. Channel references in a rendered message are clickable and jump to that channel's tab
  • Emoji rendering:shortcode: in message text and reaction pills render as the actual emoji character; declare custom emoji in the config to render/react with your own images instead
  • Inspector — a live view of raw traffic to/from the bot (envelopes, HTTP, acks, Web API calls)

Requirements

  • Bun ≥ 1.3 (curl -fsSL https://bun.sh/install | bash)
  • Node is only needed to run the example Bolt bot (Bolt targets Node).

Install

No install, always the latest version, no Bun required (downloads a prebuilt binary for your platform on first run):

npx local-slack --config config.yaml --open

Or via Homebrew:

brew tap parvalabs/tools
brew install local-slack
local-slack --config config.yaml --open

See Single-file binary below for standalone downloads (macOS/Linux/Windows) if you'd rather not use npx or Homebrew.

Quick start

For development (editing local-slack itself):

bun install
bun run dev        # backend on :3000, Vite UI on :5173 (proxies to the backend)

Open the UI at http://localhost:5173 (dev) — or run the server standalone and open http://localhost:3000:

bun run start      # serves the built UI + API on one port (:3000)

Then start the example bot against it (in another terminal):

cd examples/echo-bot && npm install
SLACK_MODE=socket SLACK_API_URL=http://localhost:3000/api/ node index.js

Post a message in the UI as a user → the bot receives it and replies. Type button to get interactive buttons and a modal; type /echo hi for a slash command; open Apps → testbot for the App Home tab.

A Python (slack_bolt) version of the same bot lives in examples/echo-bot-py — the mock isn't Bolt-JS-specific:

cd examples/echo-bot-py && python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
SLACK_API_URL=http://localhost:3000/api/ python index.py

Point your own Bolt app at it

No code changes — just configuration:

// Socket Mode
new App({
  token: "xoxb-test-token",        // must match config.app.botToken
  appToken: "xapp-test-token",     // must match config.app.appToken
  socketMode: true,
  clientOptions: { slackApiUrl: "http://localhost:3000/api/" },
});

// Events API (HTTP)
new App({
  token: "xoxb-test-token",
  signingSecret: "test-signing-secret", // must match config.app.signingSecret
  clientOptions: { slackApiUrl: "http://localhost:3000/api/" },
}).start(4000); // config.app.requestUrl must point here, e.g. http://localhost:4000/slack/events

clientOptions.slackApiUrl is passed through to both the main WebClient and the Socket Mode client, so apps.connections.open hits the mock too.

For Python's slack_bolt, pass a WebClient with base_url set — both to the App and, for Socket Mode, explicitly to SocketModeHandler (its client defaults to app.client, which is authenticated with the bot token, not the app-level token Socket Mode needs):

from slack_sdk import WebClient
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler

API_URL = "http://localhost:3000/api/"
app = App(client=WebClient(token="xoxb-test-token", base_url=API_URL))
handler = SocketModeHandler(app, "xapp-test-token", web_client=WebClient(token="xapp-test-token", base_url=API_URL))
handler.start()

See examples/echo-bot-py for a complete, verified example.

Configuration

See examples/config.yaml. Run with --config <path>.

workspace: { name: Test Workspace, domain: test-workspace, teamId: T01TEST }
app:
  appId: A01APP
  botUserId: U0BOT
  botName: testbot
  botToken: xoxb-test-token
  appToken: xapp-test-token
  signingSecret: test-signing-secret
  mode: socket                     # "socket" | "events"
  requestUrl: http://localhost:4000/slack/events   # used when mode: events
users:
  - { id: U01ALICE, name: alice, real_name: Alice Anderson, email: alice@example.com }
channels:
  - { id: C01GEN, name: general, members: [U01ALICE, U0BOT] }

A user's optional email is returned as profile.email from users.info / users.list, and is what users.lookupByEmail matches on.

Custom emoji

Declare a name -> image mapping under emojis:; paths are relative to the config file:

emojis:
  party_parrot: party_parrot.png
  super_sad: sad.png

The server validates each image exists at startup and serves it at /emoji/<name>. Use :party_parrot: in message text or as a reaction name — both render the actual image, and emoji.list returns each name resolved to a fetchable URL for bots that enumerate a workspace's custom emoji.

Multiple apps

Replace the singular app: with an apps: list to run more than one app against the same workspace at once — e.g. testing bot-to-bot interaction, or exercising two separate apps together. Every app needs a unique appId, botUserId and botToken. See examples/config.multiapp.yaml (paired with examples/echo-bot and examples/shout-bot).

apps:
  - appId: A01APP
    botUserId: U0BOT
    botName: echobot
    botToken: xoxb-echobot-token
    appToken: xapp-echobot-token
    mode: socket
  - appId: A02APP
    botUserId: U0BOT2
    botName: shoutbot
    botToken: xoxb-shoutbot-token
    appToken: xapp-shoutbot-token
    mode: socket
channels:
  - { id: C01GEN, name: general, members: [U01ALICE, U0BOT, U0BOT2] }

Routing rules (matching real Slack as closely as this mock reasonably can):

  • Channel events (messages, reactions, edits, deletes) fan out to every app whose bot is a member of the channel.
  • Interactive components (buttons, modals) route to whichever app posted the message or opened the view — inferred automatically, no configuration needed.
  • Slash commands and opening a Home tab target one specific app, since this mock doesn't model per-command registration. The UI's "As app" selector (shown once ≥2 apps are configured) picks the target for slash commands typed in the composer; /_control/command and /_control/open-home accept an optional appId in the body (defaults to the first configured app).

Tests

bun run test        # unit tests (config/signing/store/interactions/Web API) + integration
                     # tests that spin up the real server and drive it over HTTP/WebSocket

CLI

local-slack --config <path> [--port <n>] [--base-host <host>] [--open]
Flag Description Default
--config Path to the workspace config (YAML/JSON) config.yaml
--port Port for the UI + API + WebSockets 3000
--base-host Hostname clients use to reach this server — baked into the Socket Mode ws:// URL and interactive response_url callbacks. Override this when the bot runs elsewhere (a different pod/container) and can't resolve localhost back to this server localhost
--open Open the web UI in the browser on start
-v, --version Print the version number and exit
-h, --help Show help and exit

Control API (for automated tests)

Drive the workspace and inspect bot traffic without the UI (base http://localhost:3000/_control):

Method Endpoint Body
POST /message { channel, user, text, thread_ts? }
POST /command { channel, user, command, text, appId? } (appId defaults to the first configured app)
POST /interact { channel, messageTs, user, action } (routes to the message's own app automatically)
POST /reaction { channel, ts, user, name, present? } (present defaults true)
POST /edit-message { channel, ts, user, text } (only the message's own author may edit)
POST /delete-message { channel, ts, user } (only the message's own author may delete)
POST /open-home { user, appId? } (appId defaults to the first configured app)
POST /reset — (restore config baseline)
GET /log ordered record of all bot-facing traffic
GET /state workspace / apps / users / channels
GET /messages/:channel messages in a channel
curl -X POST localhost:3000/_control/message \
  -H 'content-type: application/json' \
  -d '{"channel":"C01GEN","user":"U01ALICE","text":"hello"}'
curl localhost:3000/_control/log   # assert on what the bot received / sent

Single-file binary

Pre-built binaries for every release are on the Releases page (or install via Homebrew). This section is about building one yourself:

bun run build:binary   # builds the UI, inlines it, and compiles a standalone executable
./local-slack --config examples/config.yaml

Produces ./local-slack — a standalone binary (no Bun/Node needed on the target) with the web UI embedded.

Cross-platform builds

bun run build:binaries   # builds for every platform below into dist-bin/

Cross-compiles via Bun's --target (see server/scripts/build-binaries.ts), downloading each target toolchain on first use, ad-hoc code-signs the macOS outputs, and packages everything as an archive (tar/zip preserve the executable bit — a raw binary uploaded to e.g. a GitHub release loses it):

Archive Platform
local-slack-darwin-arm64.tar.gz macOS, Apple Silicon
local-slack-darwin-x64.tar.gz macOS, Intel
local-slack-linux-x64.tar.gz Linux, x64
local-slack-linux-arm64.tar.gz Linux, ARM64
local-slack-windows-x64.zip Windows, x64

Each contains a single local-slack/local-slack.exe. To build just one, pass its target to the script directly: bun scripts/build-binaries.ts bun-darwin-arm64 (run from server/).

macOS: Gatekeeper

These binaries aren't notarized (that needs a paid Apple Developer account) — ad-hoc signing avoids an outright "is damaged" refusal for a locally-built or curl-downloaded binary, but a browser download gets an extra com.apple.quarantine tag that Gatekeeper still rejects even when signed. Two ways around it:

# Recommended: curl never sets the quarantine flag, so this just works.
curl -L -o local-slack.tar.gz https://github.com/parvalabs/local-slack/releases/download/v0.1.0/local-slack-darwin-arm64.tar.gz
tar xzf local-slack.tar.gz
./local-slack --config config.yaml

# If you downloaded via the browser instead, clear the quarantine flag it added:
xattr -d com.apple.quarantine local-slack

Publishing (npm, GitHub Releases, Homebrew)

npx local-slack doesn't run TypeScript source through Bun — it runs a prebuilt binary, same as the Homebrew/standalone-binary installs, so it has no Bun dependency at runtime. That's six npm packages under npm/: local-slack-<platform> for each of the 5 build targets (just the compiled binary, gated to the right machine via os/cpu fields in its package.json), plus a thin local-slack wrapper (npm/local-slack/bin.js) that resolves and execs whichever one npm's optionalDependencies resolution actually installed. server/'s own package.json is private and is never itself published — it's the workspace's dev/build tooling, not the distribution artifact.

A release updates all three channels (npm, GitHub Releases, the Homebrew tap) together with real, matching version numbers:

bun run release:prepare 0.2.0   # bumps the version everywhere, rebuilds every binary + npm
                                 # package, prints checksums to review
bun run release:publish 0.2.0   # npm publish x6, git tag + GitHub release, Homebrew tap update

See server/scripts/prepare-release.ts and server/scripts/publish-release.ts for exactly what each step does.

Architecture

  • Runtime: Bun. HTTP: Hono (runtime-agnostic). WebSockets: Bun.serve native.
  • server/ — config loader (zod/yaml), in-memory store + event bus, Web API methods, Socket Mode server, Events API dispatcher (signs deliveries), interactions (trigger_id / response_url / views), UI gateway, control + hooks routers.
  • web/ — React + Vite UI (sidebar, message list, composer, Block Kit renderer, modals, App Home, Inspector).
  • examples/echo-bot/ — a real Bolt (JS) app used for end-to-end verification.
  • examples/echo-bot-py/ — the same bot in Bolt for Python, proving the mock is SDK-agnostic.
  • examples/shout-bot/ — a second Bolt app, paired with examples/config.multiapp.yaml to verify multi-app support.

Not a security boundary: tokens/signatures are validated only enough for realism. Local testing only.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages