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:
MissingToken/InvalidTokenFormat— the token was empty, whitespace, too short, or carried quotes.ShardingNotSupported(got)— the config asked forgotshards; this milestone runs exactly one.
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.
Connected(Info) — the websocket handshake succeeded; HELLO has not arrived yet, so identify/resume has not run.Disconnected(close_code, will_resume)(Warn) — the connection closed. The code names the reason (tadpole/gateway’sclose_code_name);will_resumesays whether the next connection resumes the session or identifies fresh.ConnectFailed(error)(Warn) — one connect attempt failed; a retry is already scheduled with backoff. The logged line stays short; the full error goes to the shard’s own logs.
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
handlerruns in tadpole’s dispatcher process. Events are delivered SEQUENTIALLY, one at a time in arrival order: the loop does not read the next event until the handler returns. A slow handler delays everything behind it. In exchange, handler state needs no locks — nothing else ever touches it.- A
Botis a plain record, safe to pass to other processes or send in messages.send_message,reply, andstopmay be called from any process; each REST call is independent and blocks its calling process for the round trip (plus retries and rate-limit sleeps). - The dispatcher owns the event and lifecycle subjects and is the only process that can receive on them. Everyone else is send-only.
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
tadpole— build and validate the configtadpole/gateway/events— what the handler receivestadpole/rest/endpoints— REST calls beyond the two helperstadpole/error/render— errors to readable text
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_messageandreply; hand it to tadpole/rest/endpoints for calls the bot helpers do not wrap. - shard
-
The shard actor.
stopsends itshard.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.