Slack bot - "Dobby"

Three ways to talk to Dobby:

# 1. @-mention in any channel the bot is in
@dobby book lon-par tomorrow
@dobby book lon par next monday return friday 2a 1c premier
@dobby book par-bxl tomorrow unallocated

# 2. DM the bot directly (no @dobby prefix needed)
book lon-par tomorrow
book wheelchair to amster next saturday

# 3. Slash command - works EVERYWHERE without inviting the bot to a channel
/dobby book lon-par tomorrow
/dobby book lon par next monday return friday 2a 1c premier
/dobby wheelchair to amsterdam next saturday

/dobby (slash command) and @dobby (mention) share the same parser, the same booking pipeline, and the same SQLite jobs table. The only difference is how the response is delivered:

Both paths return a 200 to Slack within 3 s and run the booking in the background. The LLM fallback is on for both — when the regex doesn't match, Dobby calls the configured provider, validates the result against BookingRequestSchema, and echoes its interpretation ("Dobby thinks you mean: London → Amsterdam, 1 wheelchair adult + 1 companion") so users can spot misreads before the booking completes.

Parser cascade

  1. Regex parser (src/server/slack/parser.ts) — cheap, fast, handles the canonical grammar below.
  2. LLM fallback (src/llm/parse-prompt.ts) — runs only when the regex parser fails. Uses the configured provider (Anthropic by default; OpenAI optional). The result is validated against the same BookingRequestSchema either way.

Canonical grammar (regex parser)

book <od> <date> [return <date>] [<pax>...] [<class>] [snap|unallocated] [api] [market:<code>]
Token Examples
<od> lon-par, lon par, london paris (anything findStation resolves)
<date> tomorrow, next monday, 2026-05-12 (chrono-node)
<pax> 1a, 2c, 1s, 1y, 1i
<class> standard, plus, premier

Defaults: 1 adult, uk-en market.

Configuration

Env var Required for
SLACK_SIGNING_SECRET Request signature verification
SLACK_BOT_TOKEN Posting & updating messages
ANTHROPIC_API_KEY LLM fallback (default provider)
OPENAI_API_KEY LLM fallback (alt. when DOBBY_LLM_PROVIDER=openai)
DOBBY_LLM_ENABLED false to disable the LLM fallback (regex-only mode)
DOBBY_LLM_DAILY_BUDGET_USD Daily cost cap (default 5)
DOBBY_DB_PATH Optional sqlite override (default ~/.booking-bot/dobby.sqlite)

Slack app setup

Create a Slack app, then in App Manifest use the Events API plus these scopes:

{
    "display_information": {
        "name": "Dobby",
        "description": "DotCom's staging-only booking bot",
        "background_color": "#00286a",
        "long_description": "Use dobby to make bookings in our staging environment, just tell Dobby what you need and he'll create the booking for you (surname is always 'test'). for advanced uses, use cli parameters to select specific classes of accommodation, service numbers, etc"
    },
    "features": {
        "bot_user": {
            "display_name": "Dobby",
            "always_online": true
        },
        "app_home": {
            "home_tab_enabled": false,
            "messages_tab_enabled": true,
            "messages_tab_read_only_enabled": false
        },
        "slash_commands": [
            {
                "command": "/dobby",
                "url": "https://dobby.diegoalto.app/slack/command",
                "description": "Book a staging Eurostar journey",
                "usage_hint": "book lon-par tomorrow",
                "should_escape": false
            }
        ]
    },
    "oauth_config": {
        "scopes": {
            "bot": [
                "app_mentions:read",
                "channels:history",
                "groups:history",
                "im:history",
                "mpim:history",
                "chat:write",
                "commands",
                "reactions:write"
            ]
        },
        "pkce_enabled": false
    },
    "settings": {
        "event_subscriptions": {
            "request_url": "https://dobby.diegoalto.app/slack/events",
            "bot_events": [
                "app_mention",
                "message.im"
            ]
        },
        "org_deploy_enabled": false,
        "socket_mode_enabled": false,
        "token_rotation_enabled": false,
        "is_mcp_enabled": false
    }
}

Why these scopes:

Why these manifest blocks:

app_mentions:read is a strict subset of channels:history for our purposes and Slack now flags it as a legacy scope; the *:history set replaces it cleanly.

Why we don't subscribe to message.channels / message.groups / message.mpim: those would double-fire alongside app_mention for the same @dobby ... message. The controller's isActionable predicate (src/server/slack/controller.ts) reflects that — it accepts app_mention from anywhere, plus message events only when channel_type === "im".

Slack requires a publicly reachable HTTPS URL for both the events request_url and each slash command's url. Use ngrok http 4242 (or a tunnel of your choice) when iterating locally.

Job lifecycle

src/server/slack/jobs.ts keys on event_id so Slack retries (3× exponential backoff) are no-ops. Status transitions: received → parsing → searching → cart → hold → checkout → done | failed.