SlackBot (slack_bot_ws v0.1.0-rc.2)

View Source

Production-ready Slack bot framework for Socket Mode.

SlackBot provides a supervised WebSocket connection, tier-aware rate limiting, declarative slash-command parsing, and full observability for building Slack bots in Elixir.

Quick Start

The recommended approach is the otp_app pattern:

1. Define your bot module:

defmodule MyApp.SlackBot do
  use SlackBot, otp_app: :my_app

  handle_event "message", event, _ctx do
    MyApp.SlackBot.push({"chat.postMessage", %{
      "channel" => event["channel"],
      "text" => "Hello from MyApp!"
    }})
  end

  slash "/ping" do
    handle _payload, _ctx do
      {:ok, %{"text" => "Pong!"}}
    end
  end
end

2. Configure tokens:

# config/config.exs
config :my_app, MyApp.SlackBot,
  app_token: System.fetch_env!("SLACK_APP_TOKEN"),
  bot_token: System.fetch_env!("SLACK_BOT_TOKEN")

3. Supervise:

defmodule MyApp.Application do
  use Application

  def start(_type, _args) do
    children = [
      MyApp.SlackBot
    ]

    Supervisor.start_link(children, strategy: :one_for_one)
  end
end

Alternative: Inline Supervision

You can also supervise SlackBot directly without the otp_app pattern:

children = [
  {SlackBot,
   name: MyBot,
   app_token: System.fetch_env!("SLACK_APP_TOKEN"),
   bot_token: System.fetch_env!("SLACK_BOT_TOKEN"),
   module: MyBot}
]

Supervisor.start_link(children, strategy: :one_for_one)

This approach is useful for dynamic bot instances or when you prefer explicit configuration over application environment.

Public API

Web API Calls

  • MyApp.SlackBot.push/1 - Synchronous API call (waits for response) for bot modules started via the otp_app pattern.
  • SlackBot.push/2 - Explicit variant when supervising bots under custom names or passing %SlackBot.Config{} directly.
  • MyApp.SlackBot.push_async/1 / SlackBot.push_async/2 - Fire-and-forget async calls routed through the runtime task supervisor.

All variants route through rate limiters and Telemetry automatically.

Cache Queries

Event Injection

  • MyApp.SlackBot.emit/1 / SlackBot.emit/2 - Inject synthetic events into the handler pipeline

Configuration

  • MyApp.SlackBot.config/0 / SlackBot.config/1 - Read the immutable %SlackBot.Config{} struct

Multiple Bot Instances

The ergonomic approach is one module per bot using the otp_app pattern:

defmodule MyApp.BotOne do
  use SlackBot, otp_app: :my_app
end

defmodule MyApp.BotTwo do
  use SlackBot, otp_app: :my_app
end

children = [
  MyApp.BotOne,
  MyApp.BotTwo
]

Each module gets its own push/1, push_async/1, emit/1, and config/0 helpers so your handlers can call MyApp.BotOne.push/1 without guessing which instance is running.

Need multiple runtime copies of the same router module (for multi-tenant bots, for example)? Start SlackBot directly with a unique :name per instance:

children = [
  {SlackBot, name: :team_alpha_bot, module: MyApp.DynamicRouter, ...},
  {SlackBot, name: :team_beta_bot, module: MyApp.DynamicRouter, ...}
]

In that setup, call the explicit APIs (SlackBot.push(:team_alpha_bot, request), SlackBot.emit(:team_beta_bot, event), etc.) and avoid the module helpers so there is no ambiguity about which instance is targeted.

See Also

Summary

Types

Criteria for matching cached Slack channels.

Criteria for matching cached Slack users.

Functions

Returns a child specification so SlackBot can be supervised directly.

Reads the immutable %SlackBot.Config{} from the config server registered under server.

Injects a synthetic event into your handler pipeline.

Finds a cached Slack channel, fetching from Slack API if not cached.

Finds a cached Slack user, fetching from Slack API if not cached.

Sends a Web API request to Slack using the bot token.

Sends a Web API request asynchronously without blocking the caller.

Starts a SlackBot supervision tree.

Types

channel_matcher()

@type channel_matcher() :: {:id, String.t()} | {:name, String.t()}

Criteria for matching cached Slack channels.

user_matcher()

@type user_matcher() :: {:id, String.t()} | {:email, String.t()} | {:name, String.t()}

Criteria for matching cached Slack users.

Functions

child_spec(opts)

@spec child_spec(keyword()) :: Supervisor.child_spec()

Returns a child specification so SlackBot can be supervised directly.

config(server)

@spec config(GenServer.server()) :: SlackBot.Config.t()

Reads the immutable %SlackBot.Config{} from the config server registered under server.

emit(config, arg)

@spec emit(
  SlackBot.Config.t() | GenServer.server(),
  {String.t(), map()}
) :: :ok

Injects a synthetic event into your handler pipeline.

Prefer the injected module helper (MyApp.SlackBot.emit/1) unless you need to target a dynamically named instance. This lets you programmatically trigger handlers as if Slack sent the event, useful for testing, scheduled tasks, or cross-bot communication.

Use Cases

  • Testing: Replay production events in development
  • Scheduled jobs: Trigger handlers from a cron-like scheduler
  • Internal events: Coordinate between different parts of your bot
  • Simulations: Test handler behavior without hitting Slack

Arguments

  • server_or_config - Bot instance name (atom/pid/via) or %SlackBot.Config{}.
  • type - Event type matching your handle_event declarations
  • payload - Event payload map (structure depends on event type)

Examples

Trigger a message handler

MyApp.SlackBot.emit({"message", %{
  "type" => "message",
  "channel" => "C123456",
  "user" => "U123456",
  "text" => "simulated message",
  "ts" => "1234567890.123456"
}})

Scheduled reminder

def send_daily_reminder do
  MyApp.SlackBot.emit({"daily_reminder", %{
    "time" => DateTime.utc_now(),
    "channels" => ["C123", "C456"]
  }})
end

# In your router:
handle_event "daily_reminder", payload, _ctx do
  Enum.each(payload["channels"], fn channel ->
    MyApp.SlackBot.push({"chat.postMessage", %{
      "channel" => channel,
      "text" => "Daily standup in 10 minutes!"
    }})
  end)
end

Test a handler

# In your test
test "processes mentions correctly" do
  MyBot.emit({"app_mention", %{
    "channel" => "C123",
    "user" => "U123",
    "text" => "<@BOTID> help"
  }})

  # Assert side effects
  assert_receive {:message_sent, "C123", text}
end

Diagnostics

Emitted events are recorded in the diagnostics buffer (when enabled) with direction: :outbound and meta: %{origin: :emit}.

See Also

find_channel(server \\ __MODULE__, matcher)

@spec find_channel(GenServer.server(), channel_matcher()) :: map() | nil

Finds a cached Slack channel, fetching from Slack API if not cached.

Returns the full channel object including name, topic, member count, and metadata. Checks cache first, then hits Slack's API if needed.

Matchers

  • {:id, "C123"} - Exact channel ID (fastest)
  • {:name, "#general"} - Channel name with or without # (case-insensitive)
  • {:name, "general"} - Same as above

Returns

  • Channel map with full Slack channel data
  • nil if no matching channel found or bot not a member

Examples

Find by ID

iex> SlackBot.find_channel(MyApp.SlackBot, {:id, "C123456"})
%{
  "id" => "C123456",
  "name" => "general",
  "is_channel" => true,
  "is_private" => false,
  "is_archived" => false,
  "topic" => %{
    "value" => "Company announcements",
    "creator" => "U123",
    "last_set" => 1234567890
  },
  "purpose" => %{
    "value" => "This channel is for team-wide communication"
  },
  "num_members" => 150,
  "created" => 1234567890
}

Find by name

# With hash
iex> SlackBot.find_channel(MyApp.SlackBot, {:name, "#engineering"})
%{"id" => "C789", "name" => "engineering", ...}

# Without hash (same result)
iex> SlackBot.find_channel(MyApp.SlackBot, {:name, "engineering"})
%{"id" => "C789", "name" => "engineering", ...}

# Not found
iex> SlackBot.find_channel(MyApp.SlackBot, {:name, "#nonexistent"})
nil

Practical Usage

def post_to_channel(channel_name, message) do
  case SlackBot.find_channel(MyBot, {:name, channel_name}) do
    %{"id" => channel_id} ->
      MyBot.push({"chat.postMessage", %{
        "channel" => channel_id,
        "text" => message
      }})

    nil ->
      {:error, :channel_not_found}
  end
end

Important: Bot Membership

The bot must be a member of a channel to find it. Private channels won't appear unless the bot has been invited.

Caching Behavior

  • Channel cache syncs automatically (see cache_sync config)
  • Default sync runs hourly via conversations.list
  • Sync only includes channels the bot has joined

Performance Tips

  • Prefer {:id, ...} when you have the ID
  • Name lookups scan the cache (no extra API call once synced)

See Also

  • find_user/2 - Similar function for users
  • Config option cache_sync - Controls channel cache refresh

find_user(server \\ __MODULE__, matcher)

@spec find_user(GenServer.server(), user_matcher()) :: map() | nil

Finds a cached Slack user, fetching from Slack API if not cached.

This is your go-to function for resolving users by ID, email, or name. SlackBot checks the cache first, then hits Slack's API if needed, then caches the result.

Matchers

  • {:id, "U123"} - Exact Slack user ID (fastest)
  • {:email, "alice@example.com"} - Profile email (case-insensitive)
  • {:name, "alice"} - Username or display name (case-insensitive)

Returns

  • User map with full Slack profile data
  • nil if no matching user found

Examples

Find by ID (fastest)

iex> SlackBot.find_user(MyApp.SlackBot, {:id, "U123456"})
%{
  "id" => "U123456",
  "name" => "alice",
  "real_name" => "Alice Smith",
  "profile" => %{
    "email" => "alice@example.com",
    "display_name" => "Alice",
    "title" => "Software Engineer",
    "image_72" => "https://...",
    "status_text" => "In a meeting",
    "status_emoji" => ":calendar:"
  },
  "is_bot" => false,
  "is_admin" => false,
  "tz" => "America/Los_Angeles"
}

Find by email

iex> SlackBot.find_user(MyApp.SlackBot, {:email, "bob@example.com"})
%{"id" => "U789", "name" => "bob", ...}

iex> SlackBot.find_user(MyApp.SlackBot, {:email, "nobody@example.com"})
nil

Find by name

# Matches username
iex> SlackBot.find_user(MyApp.SlackBot, {:name, "alice"})
%{"id" => "U123", "name" => "alice", ...}

# Also matches display name
iex> SlackBot.find_user(MyApp.SlackBot, {:name, "Alice Smith"})
%{"id" => "U123", "profile" => %{"display_name" => "Alice Smith"}, ...}

Practical Usage

def send_dm(email, message) do
  case SlackBot.find_user(MyBot, {:email, email}) do
    %{"id" => user_id} ->
      MyBot.push({"chat.postMessage", %{
        "channel" => user_id,
        "text" => message
      }})

    nil ->
      {:error, :user_not_found}
  end
end

Caching Behavior

  • First lookup fetches from Slack and caches (respects user_cache.ttl_ms)
  • Subsequent lookups are instant from cache
  • Cache auto-refreshes on expiry

Performance Tips

  • Prefer {:id, ...} when you have the ID—it's a direct cache lookup
  • Email and name matchers scan the cache first before hitting the API

See Also

push(server, arg)

@spec push(
  SlackBot.Config.t() | GenServer.server(),
  {String.t(), map()}
) :: {:ok, map()} | {:error, term()}

Sends a Web API request to Slack using the bot token.

This is your primary way to call Slack's Web API. SlackBot automatically:

  • Routes through per-channel and tier-level rate limiters
  • Emits Telemetry events for observability
  • Uses the configured HTTP pool (Finch by default)

Arguments

  • server_or_config - Bot instance name (atom, pid, or {:via, ...}) or a %SlackBot.Config{} reference. Most applications should call the injected module helper (MyApp.SlackBot.push/1) instead of invoking this function directly.
  • {method, body} - Tuple of API method name and request parameters

Returns

  • {:ok, response} - Successful API call with Slack's JSON response
  • {:error, reason} - Failed call (see Error Handling below)

Examples

Post a message

iex> MyApp.SlackBot.push({"chat.postMessage", %{
...>   "channel" => "C123456",
...>   "text" => "Hello from SlackBot!",
...>   "blocks" => [...]
...> }})
{:ok, %{
  "ok" => true,
  "channel" => "C123456",
  "ts" => "1234567890.123456",
  "message" => %{"text" => "Hello from SlackBot!", ...}
}}

Upload a file

iex> MyApp.SlackBot.push({"files.upload", %{
...>   "channels" => "C123456",
...>   "content" => "log data here",
...>   "filename" => "debug.log",
...>   "title" => "Debug Logs"
...> }})
{:ok, %{"ok" => true, "file" => %{"id" => "F123", ...}}}

Update a message

iex> MyApp.SlackBot.push({"chat.update", %{
...>   "channel" => "C123456",
...>   "ts" => "1234567890.123456",
...>   "text" => "Updated text"
...> }})
{:ok, %{"ok" => true, ...}}

Error Handling

case MyApp.SlackBot.push({"chat.postMessage", body}) do
  {:ok, %{"ok" => true} = response} ->
    Logger.info("Message posted: #{response["ts"]}")

  {:ok, %{"ok" => false, "error" => error}} ->
    Logger.error("Slack API error: #{error}")

  {:error, reason} ->
    Logger.error("HTTP error: #{inspect(reason)}")
end

Common Errors

  • "channel_not_found" - Invalid channel ID or bot not invited
  • "not_in_channel" - Bot needs to join the channel first
  • "invalid_auth" - Check your bot token
  • "rate_limited" - You've exceeded Slack's quotas (SlackBot mitigates this)
  • "msg_too_long" - Message exceeds 40,000 characters

Rate Limiting

SlackBot automatically queues requests when approaching rate limits. Your call may block briefly if the limiter needs to wait. Use the module helper MyApp.SlackBot.push_async/1 (or SlackBot.push_async/2 when targeting a dynamic instance) for fire-and-forget calls that shouldn't block your handler.

See Also

push_async(server, request)

@spec push_async(
  GenServer.server(),
  {String.t(), map()}
) :: Task.t()

Sends a Web API request asynchronously without blocking the caller.

Prefer the injected module helper (MyApp.SlackBot.push_async/1) unless you need to target a dynamically named instance. Use this when you want fire-and-forget behavior—your handler continues immediately while the API call happens in a supervised task.

When to Use push_async

  • Posting multiple messages in a loop
  • Sending notifications that don't need confirmation
  • Updating UI elements in the background
  • Any call where you don't need the response immediately

Returns

A Task.t() you can await later if needed, or ignore for true fire-and-forget.

Examples

Send multiple notifications

def notify_team(user_ids, message) do
  Enum.each(user_ids, fn user_id ->
    MyApp.SlackBot.push_async({"chat.postMessage", %{
      "channel" => user_id,
      "text" => message
    }})
  end)

  :ok  # Returns immediately, messages send in background
end

Fire and forget

# Don't care about the result
MyApp.SlackBot.push_async({"reactions.add", %{
  "channel" => channel,
  "timestamp" => ts,
  "name" => "white_check_mark"
}})

Await if needed

task = MyApp.SlackBot.push_async({"chat.postMessage", body})
# ... do other work ...
result = Task.await(task, 5_000)

Task Supervision

All async tasks run under SlackBot's runtime task supervisor, so crashes won't take down your bot. Failed tasks are logged automatically.

See Also

  • MyApp.SlackBot.push/1 - Synchronous variant that waits for response
  • push/2 - Explicit synchronous variant when you need to target a dynamic instance
  • Task.await/2 - If you need to wait for the result later

start_link(opts \\ [])

@spec start_link(keyword()) :: Supervisor.on_start()

Starts a SlackBot supervision tree.

Accepts the same options as child_spec/1, returning Supervisor.on_start().