A scriptable Discord bot built in Go with Lua scripting support.
- Scriptable: Write bot functionality in Lua
- Hot Reloading: Scripts automatically reload when modified
- Persistent Storage: SQLite database for persistent data
- Graceful Shutdown: Proper cleanup on termination
- Modular Design: Clean separation of concerns
- Error Tracking: Script-specific error reporting with file names
- Thread-Safe Lua: Single-threaded event queue ensures Lua state safety
- Bot Commands: Register custom commands with optional cooldowns and role requirements
- User Management: Automatic user tracking with bot-side roles and extensible per-user metadata
discord-bot/
├── cmd/
│ ├── bot/
│ │ └── main.go # Bot entry point
│ └── dev/
│ └── main.go # Dev shell entry point
├── internal/
│ ├── bot/ # Bot lifecycle management
│ ├── config/ # Configuration management
│ ├── database/ # Database connection
│ ├── lua/ # Lua scripting engine
│ ├── users/ # User management (roles, metadata)
│ └── utils/ # misc utility functions
├── scripts/ # Lua scripts
└── go.mod
# Production bot
go build -o discord-bot ./cmd/bot
# Dev shell
go build -o discord-dev ./cmd/dev- Set the
DISCORD_BOT_TOKENenvironment variable - Run the bot:
./discord-bot
The dev shell lets you interact with the Lua scripting engine locally without a real Discord connection. It provides a terminal TUI with a scrollable output viewport and an interactive prompt.
./discord-dev --scripts-dir scripts --db :memory:┌──────────────────────────────────┐
│ Script loaded: jokes.lua │
│ [Bot → #dev-channel]: Pong! │
│ ... │
├──────────────────────────────────┤
│ [#dev-channel] dev> █ │
└──────────────────────────────────┘
Bot replies and log output appear in the viewport asynchronously without corrupting the input prompt. Mouse-wheel scrolling is supported.
| Command | Description |
|---|---|
/channel <id> |
Switch active channel ID |
/user <name> [id] |
Change the simulated author name and optional ID |
/dm |
Toggle DM mode (on_direct_message vs on_channel_message) |
/scripts |
List loaded scripts |
/reload <name> |
Reload a script by name (e.g. jokes.lua) |
/commands |
List registered bot commands |
/hooks |
List registered hooks and which scripts own them |
/lua <code> |
Execute a Lua snippet and print the result |
/quit, /exit |
Exit the dev shell |
Ctrl+C |
Exit the dev shell |
Any input not starting with / is dispatched as a message from the simulated user, triggering hooks and commands exactly as they would fire on Discord.
Messaging
send_message(channel_id, message)- Send a message to a channel
Commands & Hooks
register_hook(hook_name, function)- Register event handlersregister_command(name, description, callback[, cooldown[, required_role]])- Register a bot commandget_commands()- Get a table of all registered commands
Persistent Storage
store_set(namespace, key, value)- Store persistent datastore_get(namespace, key)- Retrieve persistent datastore_get_all(namespace)- Retrieve all data from a namespacestore_delete(namespace, key)- Delete persistent data
User Management
user_ensure(id, display_name)- Upsert a user recorduser_get(id)- Get user info:{id, display_name, roles, created_at}or niluser_has_role(id, role)- Check if a user has a role (returns bool)user_add_role(id, role)- Grant a role to a useruser_remove_role(id, role)- Revoke a role from a useruser_set_meta(id, key, value)- Store arbitrary metadata for a useruser_get_meta(id, key)- Retrieve a metadata value (returns string or nil)user_get_all_meta(id)- Get all metadata for a user as a table
HTTP
http_get(url, options)- Perform HTTP GET requesthttp_post(url, body, options)- Perform HTTP POST request
JSON
json_encode(table)- Convert Lua table to JSON stringjson_decode(string)- Convert JSON string to Lua table
Timers
call_later(seconds, callback, data)- Register a one-shot timer callbackregister_timer(seconds, callback, data)- Register a repeating timer callbackunregister_timer(timer_id)- Cancel a registered timer
Utilities
log(message)- Log a message to the bot's console
Commands provide a structured way to handle user interactions. Commands are triggered when users type messages starting with ! followed by the command name.
Use register_command(name, description, callback[, cooldown[, required_role]]) to register a new command:
name(string): The command name (without the!prefix)description(string): A description of what the command doescallback(function): The function to execute when the command is usedcooldown(number, optional): Cooldown period in seconds (default: no cooldown)required_role(string, optional): Role the caller must have; bot replies "Permission denied." otherwise
Your callback function receives an event table with:
event.args- Table containing command arguments (index 1 is the command name)event.channel_id- The Discord channel ID where the command was usedevent.author- The username of the person who used the commandevent.author_id- The ID of the person who triggered the command
-- Simple ping command with 10-second cooldown
function handle_ping(event)
send_message(event.channel_id, "Pong!")
end
register_command("ping", "Replies with Pong!", handle_ping, 10)
-- Admin-only command (no cooldown needed, so pass 0)
register_command("kick", "Kick a user", function(event)
send_message(event.channel_id, "Kicking " .. (event.args[2] or "nobody"))
end, 0, "admin")When a user types !ping, the bot will respond with "Pong!" and the command will be unavailable for 10 seconds for all users.
-- Command with arguments
function handle_echo(event)
local message = table.concat(event.args, " ", 2) -- Skip the command name
if message == "" then
send_message(event.channel_id, "Usage: !echo <message>")
else
send_message(event.channel_id, message)
end
end
register_command("echo", "Echoes back your message", handle_echo, 5)
-- Command that lists all available commands
function handle_help(event)
local commands = get_commands()
local helpText = "Available commands:\n"
for name, cmd in pairs(commands) do
helpText = helpText .. "!" .. name .. " - " .. cmd.description .. "\n"
end
send_message(event.channel_id, helpText)
end
register_command("help", "Shows all available commands", handle_help, 30)
-- Command with HTTP request
function handle_weather(event)
local city = table.concat(event.args, " ", 2)
if city == "" then
send_message(event.channel_id, "Usage: !weather <city>")
return
end
local response = http_get("https://api.example.com/weather?city=" .. city, {
headers = {["Accept"] = "application/json"},
timeout = 5
})
if response and response.status == 200 then
local data = json_decode(response.body)
send_message(event.channel_id, "Weather in " .. city .. ": " .. data.temperature .. "°C")
else
send_message(event.channel_id, "Failed to get weather data for " .. city)
end
end
register_command("weather", "Get weather for a city", handle_weather, 60)The bot automatically tracks every Discord user it sees. No registration is required — a user record is created the first time a message from that user is processed. New users are assigned the user role automatically.
Two built-in roles exist: user (all members) and admin. Roles are bot-side only and independent of Discord server roles.
Admin bootstrap: On first start with no admin set, the bot generates a one-time claim token and prints it to the log:
[ADMIN BOOTSTRAP] No admin set. DM the bot: !claim_admin XXXXXXXX
Send that command to the bot in a DM to claim admin. The token is consumed on use and is not re-generated once an admin exists.
Scripts can store and read arbitrary per-user metadata using user_set_meta/user_get_meta. Because these write to the database, the data is available to every script without any coupling between them.
-- In script A: record when a user last used a feature
register_hook("on_channel_message", function(event)
user_set_meta(event.author_id, "last_seen", tostring(os.time()))
end)
-- In script B: read the value written by script A
register_command("profile", "Show user profile", function(event)
local last = user_get_meta(event.author_id, "last_seen")
local msg = last and ("Last seen: " .. last) or "No activity recorded."
send_message(event.channel_id, msg)
end)register_command("admininfo", "Admin-only info", function(event)
local u = user_get(event.author_id)
send_message(event.channel_id, "Roles: " .. table.concat(u.roles, ", "))
end, 0, "admin")Outside of getting triggered by commands, scripts can also trigger on various Bot events
on_channel_message- Triggered for messages in channelson_direct_message- Triggered for direct messageson_shutdown- Triggered when the bot is shutting down gracefullyon_unload- Triggered when the script is unloaded
register_hook("on_channel_message", function(event)
if event.content == "ping" then
send_message(event.channel_id, "Pong!")
end
end)A registered hook callback function receives an event table with:
event.content- A string containing a recieved discord messageevent.channel_id- The Discord channel ID where the event took placeevent.author- The username of the person who triggered the eventevent.author_id- The ID of the person who triggered the event
- On bot shutdown, all queued timers are cleared without firing.
- Trying to register new timers during shutdown or while the active script is unloading will result in error.
The bot is configured via environment variables:
| Variable | Required | Default | Description |
|---|---|---|---|
DISCORD_BOT_TOKEN |
Yes | — | Discord bot token |
SCRIPTS_DIR |
No | scripts |
Directory containing Lua scripts |
DATABASE_PATH |
No | data/bot.db |
SQLite database path |
- Add the function to
internal/lua/functions.go - Register it in the
registerFunctions()method - The function will be available to all Lua scripts
- Create a new module in
internal/if needed - Add the feature to the appropriate existing module
- Update the bot initialization in
internal/bot/bot.goif necessary