A Discord bot for Krunker stats, built in Haskell.
See the Pull Request and Commit Policies for contribution guidelines.
The bot uses two separate connections to Discord:
- Gateway (WebSocket) - Receives real-time events (interactions, messages, etc.)
- REST API (HTTP) - Registers commands, sends responses
┌─────────────────────────────────────────────────────────────┐
│ Main.hs │
│ (Event Handler) │
└─────────────────┬───────────────────────────┬───────────────┘
│ │
▼ ▼
┌─────────────────────────────┐ ┌───────────────────────────┐
│ Gateway (WebSocket) │ │ REST API (HTTP) │
│ │ │ │
│ • Connects to Discord │ │ • Registers slash cmds │
│ • Receives HELLO │ │ • Responds to interactions│
│ • Sends IDENTIFY │ │ • Message builder creates │
│ • Heartbeats every ~41s │ │ JSON payloads │
│ • Receives interactions │ │ │
│ • Handles reconnection │ │ │
└─────────────────────────────┘ └───────────────────────────┘
- Connect to
wss://gateway.discord.gg - Receive
HELLOwith heartbeat interval - Send
IDENTIFYwith bot token - Receive
READYwith session info - Start heartbeat loop (runs in separate thread via
withAsync) - Enter event loop, dispatch events to handler
On disconnect, the gateway attempts reconnection with exponential backoff. If a session ID exists, it sends RESUME instead of IDENTIFY to replay missed events.
Events are parsed from raw JSON into a typed sum type:
data Event
= ReadyEvent { readySessionId, readyResumeGatewayUrl }
| MessageCreateEvent { msgId, msgChannelId, msgGuildId, msgContent, msgAuthor }
| InteractionCreateEvent Interaction
| UnknownEvent Text Value
data InteractionData
= SlashCommandData { commandName, commandOptions }
| ComponentData { componentCustomId, componentType }Commands are registered via the REST API on startup. Use guild commands for development (instant updates) and global commands for production (up to 1 hour to propagate).
-- Register guild commands (instant)
Command.registerGuildCommands client appId guildId
[ Command.SlashCommand "ping" "Check if the bot is alive" [],
Command.SlashCommand "player" "Look up a Krunker player"
[ Command.CommandOption "name" "Player name" Command.StringOption True
]
]
-- Register global commands (up to 1 hour delay)
Command.registerGlobalCommands client appId [...]data OptionType
= StringOption -- Text input
| IntegerOption -- Whole numbers
| BooleanOption -- True/False
| UserOption -- User mention
| ChannelOption -- Channel mention
| RoleOption -- Role mentionInteractions arrive via the gateway as InteractionCreateEvent:
handleEvent client event = void $ forkIO $ case event of
InteractionCreateEvent interaction ->
case interactionData interaction of
Just (SlashCommandData name opts) ->
handleSlashCommand client interaction name opts
Just (ComponentData customId _) ->
void $ Interaction.acknowledge client (interactionId interaction) (interactionToken interaction)
Nothing -> pure ()
_ -> pure ()
handleSlashCommand client interaction "ping" _ =
void $ Interaction.respond client (interactionId interaction) (interactionToken interaction) $
content "Pong! 🏓"
handleSlashCommand client interaction "player" opts = do
let playerName = case lookup "name" opts of
Just (StringValue n) -> n
_ -> "Unknown"
void $ Interaction.respond client (interactionId interaction) (interactionToken interaction) $ do
embed $ do
embedTitle $ "Player: " <> playerName
embedColor 0xF5A623Messages are constructed using a Writer monad DSL.
embed $ do
embedTitle "My Embed"
embedDescription "A description here"
embedColor 0x5865F2
embedField "Field 1" "Value 1" True
embedField "Field 2" "Value 2" True
embedFooter "Footer text" Nothing
embedImage "https://example.com/image.png"
embedThumbnail "https://example.com/thumb.png"actionRow $ do
button Primary "Click Me" "btn_click"
button Secondary "Cancel" "btn_cancel"
button Success "Confirm" "btn_confirm"
button Danger "Delete" "btn_delete"
linkButton "Website" "https://example.com"actionRow $ do
stringSelect "my_select" $ do
selectPlaceholder "Choose..."
selectOption "Option A" "a" $ selectOptionDescription "First option"
selectOption "Option B" "b" $ selectOptionDefaultexport DISCORD_TOKEN="your-bot-token"
export DISCORD_APP_ID="your-application-id"
export DISCORD_GUILD_ID="your-test-guild-id"
cabal runThis README was AI-generated from a given spec.