tadpole/bot

The beginner-facing runner: one config, one handler, one shard. start validates the config, opens the gateway, and hands back a Bot whose events arrive at your handler one at a time, in arrival order. The same Bot answers: send_message and reply post messages over the bot’s REST client, and stop closes the gateway. Multi-shard fleets, handler supervision, and a stop that ends the program are later work — a handler crash takes the whole bot down, which beats a silently dead bot. Stability: Experimental.

New here? tadpole/guide walks from an empty directory to a running bot; tadpole builds the config this module consumes.

When you reach for this

For every first bot. run is the whole program: config, handler, block until killed. Use start instead when the rest of the program must keep working — it returns a Bot once the shard is up, and stop and the REST helpers are callable from any process later. Neither function runs more than one shard; that is refused at startup, not negotiated at runtime.

Failure modes

Startup failures return Error before any process starts, so a rejected config never leaves a half-running bot behind:

After startup, start and run do not fail again. The shard reconnects on its own and reports through lifecycle notices (below). If the dispatcher process fails to report back within 20 seconds of start, that is a tadpole bug and surfaces as error.InternalContractViolation.

At runtime there is one way to die: an exception in the handler. It crashes the dispatcher, and the dispatcher is linked to the caller of start/run, so the whole bot comes down loudly. No supervision, no retry-around-handler in this version.

send_message and reply fail with RestStatus — any non-2xx, where status 0 means no HTTP response happened at all — or with RateLimited once 429 retries run out. stop never fails.

Lifecycle notices

The shard reports what happens to the connection; each notice logs through Erlang’s logging at the config’s log_level threshold. None of them carry the token.

Repeated Disconnected plus ConnectFailed notices mean a connect loop, usually a flaky network or a gateway that keeps refusing. A single Disconnected with a config close code (4014 disallowed intents, for one) is tadpole stopping on purpose: the docs mark those do-not-reconnect, and no amount of retrying fixes config.

Concurrency

Example

The shape of a whole bot (token loading omitted; the real program is dev/echo_bot.gleam in the repository):

import gleam/io
import tadpole
import tadpole/bot
import tadpole/error/render
import tadpole/gateway/events.{MessageCreate, Ready}
import tadpole/intent

pub fn main() {
  let cfg =
    tadpole.new(token())  // from the environment, never source code
    |> tadpole.with_intents(
      intent.new()  // Message Content needs a Developer Portal toggle
      |> intent.enable(intent.guilds)
      |> intent.enable(intent.guild_messages)
      |> intent.enable(intent.message_content),
    )

  case bot.run(cfg, handle_event) {
    Ok(_) -> Nil
    Error(e) -> io.println(render.render_error(e))
  }
}

fn handle_event(tadbot: bot.Bot, event: events.Event) {
  case event {
    Ready(user, _) -> io.println("logged in as " <> user.username)

    MessageCreate(message) ->
      case message.author.bot {
        // Echoing our own messages would loop forever.
        True -> Nil
        False ->
          case
            bot.reply(tadbot, message.channel_id, message.id, message.content)
          {
            Ok(_sent) -> Nil
            Error(e) -> io.println(render.render_error(e))
          }
      }

    _ -> Nil
  }
}

run blocks forever on success; Ctrl+C ends it. Without the Message Content portal toggle, other users’ messages arrive with empty content — the gateway connects fine and then withholds the text.

See also

Types

A running bot: the validated config, the REST client, the shard’s subject, and the transport send_message/reply fire over. The fields are public so tests can build one by hand with a canned transport; normal code gets a Bot from start and passes it around.

pub type Bot {
  Bot(
    config: tadpole.Config,
    rest: rest.RestClient,
    shard: process.Subject(shard.ShardMsg),
    token: String,
    transport: fn(request.Request(String)) -> Result(
      response.Response(String),
      execute.TransportError,
    ),
  )
}

Constructors

  • Bot(
      config: tadpole.Config,
      rest: rest.RestClient,
      shard: process.Subject(shard.ShardMsg),
      token: String,
      transport: fn(request.Request(String)) -> Result(
        response.Response(String),
        execute.TransportError,
      ),
    )

    Arguments

    config

    The config the bot was started with, exactly as passed to start.

    rest

    REST client behind send_message and reply; hand it to tadpole/rest/endpoints for calls the bot helpers do not wrap.

    shard

    The shard actor. stop sends it shard.Stop; every other ShardMsg variant is internal plumbing.

    token

    The token the shard identifies with. Never log it.

    transport

    The HTTP transport the bot helpers run over — gleam_httpc for bots built by start, anything you like for bots built by hand.

Values

pub fn reply(
  bot: Bot,
  channel_id: ids.ChannelId,
  message_id: ids.MessageId,
  content: String,
) -> Result(message.Message, error.TadpoleError)

POST a reply: like send_message but carrying a message_reference, so Discord’s client shows the original message above the reply and pings its author.

Fails like send_message; a 404 here usually means the replied-to message was already deleted.

pub fn run(
  cfg: tadpole.Config,
  handler: fn(Bot, events.Event) -> Nil,
) -> Result(Nil, error.TadpoleError)

start, then block the calling process forever while the bot runs.

Use this in main; use start when something else in the program needs to keep working alongside the bot. The program ends when it is killed (Ctrl+C) or when the handler crashes — there is no graceful shutdown yet. Startup failures return immediately: a rejected config never blocks.

pub fn send_message(
  bot: Bot,
  channel_id: ids.ChannelId,
  content: String,
) -> Result(message.Message, error.TadpoleError)

POST one text message to a channel, over the bot’s REST client.

Fails with RestStatus when Discord answers non-2xx — 403 usually means the bot lacks Send Messages in that channel, 404 that the channel id is wrong — or with RateLimited once 429 retries run out. Discord truncates content past 2000 characters silently; tadpole does not second-guess that.

// Illustrative shape — dev/echo_bot.gleam is the real program.
let assert Ok(tadbot) = bot.start(config, handle)
case bot.send_message(tadbot, channel, "hello pond") {
  Ok(_) -> Nil
  Error(e) -> io.println(render.render_error(e))
}
pub fn start(
  cfg: tadpole.Config,
  handler: fn(Bot, events.Event) -> Nil,
) -> Result(Bot, error.TadpoleError)

Validate the config, open one gateway shard, and dispatch its events to handler.

Fails before any process starts: MissingToken / InvalidTokenFormat for a bad token, ShardingNotSupported for more than one shard. Pass errors to error/render.render_error for human-readable next steps.

Concurrency matches the module docs: the handler runs sequentially with no locks, and a crash in it takes the bot down loudly.

// Illustrative — dev/echo_bot.gleam is the whole program.
let assert Ok(tadbot) = bot.start(config, handle_event)
// later, from any process:
bot.stop(tadbot)
pub fn stop(bot: Bot) -> Nil

Close the gateway: sends the shard actor its Stop message.

Never fails and never blocks — the close is asynchronous, and the shard does not wait for Discord to acknowledge it. The dispatcher keeps running (it holds nothing but memory) and exits when your program does; run does not return when you call this. To end the program, kill it as usual.

Search Document