-
Notifications
You must be signed in to change notification settings - Fork 0
Home
This document is the complete guide for writing Lua plugins for the Navi Bot engine. It covers every API function, every data structure, and the patterns used in the real plugins that ship with the bot.
- How Plugins Work
- Plugin Anatomy
- Logging
- Configuration (TUI Dashboard)
- Database
- Message Listeners
- Slash Commands
- Slash Command Context
- Buttons and Select Menus
- Component Context
- Modal Dialogs
- Modal Context
- Embeds and Components (UI)
- Inter-Plugin Event Bus
- Global Event Callbacks
- Messaging API
- Member & Role Management
- Channel Management
- Discord Cache
- HTTP Client
- JSON
- Timed Intervals
- Permissions
- Plugin Load Order
- Bot Status / Presence
- Data Type Reference
- Patterns, Tips & Common Mistakes
Every .lua file placed in the plugins/ directory is a plugin. The Rust engine:
- Scans
plugins/on startup (and every time you pressrin the TUI). - Sorts files to respect any
navi.depends_on()declarations, then alphabetically. - Executes each file once, in order, inside a shared Lua state.
- After loading all plugins, syncs any registered slash commands to Discord automatically.
All plugin code runs at the top level of the file during loading. This is where you call navi.register, navi.create_slash, etc. to wire up your handlers.
Hot Reload: Pressing
rin the TUI re-runs all plugin files from scratch. Your handlers are re-registered and slash commands are re-synced. You do not need to restart the bot process to apply code changes.
No sandbox: All plugins share the same Lua globals. Be careful not to overwrite another plugin's globals accidentally — use
localfor everything that doesn't need to be shared.
A well-structured plugin follows this order:
-- ============================================================
-- my_plugin.lua
-- ============================================================
-- (Optional) Declare that this plugin needs another one to load first
navi.depends_on("economy")
-- 1. Log that we're loading (helps debug load errors)
navi.log.info("Loading My Plugin")
-- 2. Declare TUI-configurable settings
navi.register_config("my_plugin", {
{ key = "some_channel", name = "Output Channel", description = "Where to post.", type = "channel", default = "" }
})
-- 3. Local helper functions
local function do_something(user_id)
return navi.db.get("my_plugin:data:" .. user_id) or "nothing"
end
-- 4. Register message listeners
navi.register(function(msg)
if msg.content == "hello" then
navi.say(msg.channel_id, "Hi there!")
end
end)
-- 5. Register slash commands
navi.create_slash("mycommand", "Does something cool", {}, function(ctx)
ctx.reply("Hello, " .. ctx.username .. "!")
end)
-- 6. Register component / modal handlers
navi.register_component("my_button", function(ctx)
ctx.reply("You clicked it!", true)
end)
-- 7. Subscribe to events from other plugins
navi.on("economy:balance_changed", function(data)
navi.log.info("Balance changed for " .. data.user_id)
end)Use navi.log to write structured messages to the TUI log pane. This is far better than print() because it respects log levels and is visible in the dashboard.
navi.log.info("Plugin loaded successfully")
navi.log.warn("Config value missing, using default")
navi.log.error("Something went wrong: " .. tostring(err))| Function | Color | When to use |
|---|---|---|
navi.log.info(msg) |
White | Normal status messages, load notices |
navi.log.warn(msg) |
Yellow | Non-fatal problems, missing optional config |
navi.log.error(msg) |
Red | Failures that affect functionality |
Declares settings that can be edited from the TUI (c key) and are persisted in SQLite. Call this once per plugin, at the top of the file.
-
plugin_name(string): The unique namespace for this plugin's config (usually the filename without.lua). -
schema(table): A list of setting definitions.
navi.register_config("my_plugin", {
{ key = "welcome_channel", name = "Welcome Channel", description = "Where to greet new users.", type = "channel", default = "" },
{ key = "max_points", name = "Max Points", description = "Point cap per user.", type = "number", default = 1000 },
{ key = "enabled", name = "Enabled", description = "Turn the plugin on/off.", type = "boolean", default = true },
})| Field | Type | Required | Description |
|---|---|---|---|
key |
string | Yes | The DB key used to store the value. Read back as config:plugin_name:key. |
name |
string | Yes | Human-readable label shown in the TUI. |
description |
string | Yes | Help text shown below the field in the TUI. |
type |
string | Yes | Controls the input widget. See types below. |
default |
any | No | Value written to DB if the user hasn't configured it yet. Omit for list fields. |
item_schema |
table | Only for list
|
Defines the sub-fields of each list item. |
options |
string[] | Only for enum
|
The allowed values shown in the TUI dropdown. |
| Type | TUI Widget | Notes |
|---|---|---|
"string" |
Text input | Free-form text |
"number" |
Number input | Stored as a string; use tonumber() when reading |
"boolean" |
Toggle |
true / false
|
"channel" |
Channel picker | Stores a channel snowflake ID string |
"role" |
Role picker | Stores a role snowflake ID string |
"category" |
Category picker | Stores a category (channel group) snowflake ID string |
"list" |
Expandable list | Each item is a sub-table; requires item_schema
|
"enum" |
Option dropdown | Stores one of the strings declared in options; requires options
|
Config values are stored with the key format config:plugin_name:key. You read them with navi.db.get:
local channel = navi.db.get("config:my_plugin:welcome_channel")
local max = tonumber(navi.db.get("config:my_plugin:max_points")) or 1000
local enabled = navi.db.get("config:my_plugin:enabled") == "true"A list config field stores multiple structured items — for example, a list of level-up role rewards. Define the sub-fields with item_schema and read them back with navi.db.get_list.
navi.register_config("leveling", {
{ key = "role_rewards", name = "Role Rewards", description = "Roles granted at specific levels.", type = "list",
item_schema = {
{ key = "level", name = "Level", type = "number" },
{ key = "role_id", name = "Role", type = "role" }
}
}
})
-- Reading the list back:
local rewards = navi.db.get_list("config:leveling:role_rewards")
for _, item in ipairs(rewards) do
-- item.level and item.role_id are available as strings
navi.log.info("Level " .. item.level .. " → Role " .. item.role_id)
endEach entry in item_schema supports these fields:
| Field | Required | Description |
|---|---|---|
key |
Yes | The key used inside each item table |
name |
Yes | Human-readable label shown in the TUI |
type |
Yes | Sub-field input type (see below) |
options |
Only for enum
|
List of valid string values shown in the dropdown |
Sub-field types (all types except "list" are allowed):
| Type | TUI Widget |
|---|---|
"string" |
Text input |
"number" |
Text input (use tonumber() when reading) |
"boolean" |
Toggle |
"channel" |
Channel picker |
"role" |
Role picker |
"category" |
Category picker |
"enum" |
Option dropdown — requires options
|
Use type = "enum" when a field (top-level or inside a list's item_schema) must be one of a fixed set of strings. The TUI shows a green dropdown instead of a free-text box, preventing invalid input.
-- Top-level enum field
navi.register_config("myplugin", {
{
key = "mode",
name = "Operating Mode",
description = "Controls how the plugin behaves.",
type = "enum",
options = { "strict", "lenient", "disabled" },
default = "lenient"
}
})
local mode = navi.db.get("config:myplugin:mode") -- "strict", "lenient", or "disabled"-- Enum sub-field inside a list
navi.register_config("permissions", {
{ key = "mappings", name = "Role → Permission Level", description = "Map roles to levels.", type = "list",
item_schema = {
{ key = "role_id", name = "Discord Role", type = "role" },
{ key = "level", name = "Permission Level", type = "enum",
options = { "helper", "moderator", "admin" } }
}
}
})The database is a single SQLite table (kv_store) with key and value columns. All values are stored as strings. The Lua API gives you get/set, raw SQL queries, and list helpers.
navi.db.get and navi.db.set automatically prepend the calling plugin's filename as a namespace. A call to navi.db.get("score") from trivia.lua actually reads the key trivia:score.
To bypass namespacing and use an exact key, include a : anywhere in the key string:
-- From casino.lua — reads economy.lua's balance data directly
local balance = navi.db.get("economy:balance:" .. user_id)This is intentional. Cross-plugin data access is fine as long as you know the key format.
Reads a value from the database.
-
Returns:
string | nil— the value, ornilif the key doesn't exist.
local xp = navi.db.get("xp:" .. user_id)
if xp == nil then
xp = "0" -- default value
end
local xp_num = tonumber(xp) or 0Writes a value to the database. Creates the key if it doesn't exist; overwrites it if it does.
-
valuecan be a string, number, or boolean. Numbers and booleans are automatically converted to strings.
navi.db.set("xp:" .. user_id, tostring(new_xp))
navi.db.set("score", 42) -- stored as "42"
navi.db.set("enabled", true) -- stored as "true"Important: Always use
tonumber()when reading a value you intend to do math with. The database always gives you strings back.
Executes a raw SQL statement against the kv_store table. Returns an array of row tables.
- Each row has a
keyfield and avaluefield. - Use this for sorted queries,
LIKEpattern searches, and leaderboards — things you can't do with simple get/set.
-- Top 10 XP earners, sorted descending
local rows = navi.db.query(
"SELECT key, value FROM kv_store " ..
"WHERE key LIKE 'leveling:xp:%' " ..
"ORDER BY CAST(value AS INTEGER) DESC LIMIT 10"
)
for i, row in ipairs(rows) do
-- Extract the user_id from the key "leveling:xp:<user_id>"
local user_id = row.key:match("leveling:xp:(.+)")
navi.log.info(i .. ". " .. user_id .. " — " .. row.value .. " XP")
endReads a list-type config field (registered with navi.register_config) and returns it as an array of tables. Each table has the keys defined in item_schema.
local rewards = navi.db.get_list("config:leveling:role_rewards")
for _, r in ipairs(rewards) do
print(r.level, r.role_id)
endRegisters a function that is called every time any message is sent in a channel the bot can see. You can register multiple listeners from multiple plugins — they all fire independently.
navi.register(function(msg)
-- Always ignore bots to prevent feedback loops
if msg.author_bot then return end
-- React to a specific word
if msg.content:lower():find("good bot") then
navi.react(tostring(msg.channel_id), tostring(msg.message_id), "❤️")
end
end)| Field | Type | Description |
|---|---|---|
msg.content |
string | The text of the message |
msg.message_id |
number | The message's snowflake ID |
msg.channel_id |
number | The channel's snowflake ID |
msg.author |
string | The sender's username |
msg.author_id |
number | The sender's snowflake ID |
msg.author_bot |
boolean |
true if the sender is a bot or webhook |
msg.author_avatar |
string | URL to the sender's avatar |
msg.guild_id |
string|nil | The guild's snowflake ID, or nil in DMs |
msg.mentions |
table | Array of mentioned user objects ({id, name, avatar}) |
msg.attachments |
string[] | Array of attachment URLs |
Tip:
msg.author_idandmsg.channel_idare numbers. Usetostring()when passing them tonavi.db.set, string concatenation, or functions that expect a string.
Registers a slash command. After registering, slash commands are automatically synced to Discord when plugins reload.
-
name(string): The command name. Lowercase, no spaces (e.g."balance"). -
description(string): The help text shown in Discord's command picker. -
options(table): A list of argument definitions. Pass{}if the command takes no arguments. -
callback(function): Called when the command is used. Receives actxobject (see Section 8).
-- A simple no-argument command
navi.create_slash("ping", "Check if the bot is alive", {}, function(ctx)
ctx.reply("Pong! 🏓")
end)-- A command with arguments
navi.create_slash("greet", "Greet a user", {
{ name = "user", description = "Who to greet", type = "user", required = true },
{ name = "shout", description = "Shout it?", type = "boolean", required = false }
}, function(ctx)
local target = ctx.args.user -- a user snowflake ID string
local shout = ctx.args.shout -- a boolean (true/false) or nil if not provided
local message = "Hey, <@" .. target .. ">!"
if shout then
message = string.upper(message)
end
ctx.reply(message)
end)If a single plugin file registers two or more slash commands, Discord automatically groups them under the plugin's filename as a parent command. For example, a plugin named casino.lua that calls create_slash for coinflip, slots, and dice will appear in Discord as /casino coinflip, /casino slots, and /casino dice.
If a plugin only registers one command, it stays flat (e.g. /rank from leveling.lua would stay as /rank if it were the only command, but since leveling.lua registers two commands it becomes /leveling rank).
This is fully automatic — you don't need to change any Lua code.
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Argument name (lowercase, no spaces) |
description |
string | Yes | Help text in Discord |
type |
string | Yes | See option types below |
required |
boolean | No | Whether the user must provide this arg. Default: false
|
autocomplete |
function | No | If set, Discord calls this as the user types. See below. |
| Type | Lua value in ctx.args
|
Notes |
|---|---|---|
"string" |
string |
Plain text |
"integer" |
number |
Whole number; safe to use directly in math |
"number" |
number |
Float/decimal |
"boolean" |
boolean |
Real true/false
|
"user" |
string |
The selected user's snowflake ID |
"channel" |
string |
The selected channel's snowflake ID |
"role" |
string |
The selected role's snowflake ID |
Note:
user,channel, androleoptions give you an ID string, not the full object. Usenavi.get_memberif you need more info about a user.
If an option has a function in its autocomplete field, Discord will call that function as the user types in that argument slot. Your function receives a context table and must return up to 25 {name, value} pairs.
navi.create_slash("give_item", "Give an item to a user", {
{
name = "item",
description = "The item name",
type = "string",
required = true,
autocomplete = function(ctx)
-- ctx.current_value is what the user has typed so far
local all_items = { "Sword", "Shield", "Potion", "Arrow", "Staff" }
local results = {}
for _, item in ipairs(all_items) do
if item:lower():find(ctx.current_value:lower(), 1, true) then
table.insert(results, { name = item, value = item })
end
end
return results
end
}
}, function(ctx)
ctx.reply("You received a " .. ctx.args.item .. "!")
end)The autocomplete callback receives:
| Field | Type | Description |
|---|---|---|
ctx.current_value |
string | What the user has typed so far (may be empty) |
ctx.user_id |
string | The user's snowflake ID |
ctx.guild_id |
string|nil | The guild's snowflake ID |
The ctx object passed to a slash command callback.
| Field / Method | Type | Description |
|---|---|---|
ctx.user_id |
number | The invoking user's snowflake ID |
ctx.username |
string | The invoking user's username |
ctx.channel_id |
string | The channel's snowflake ID |
ctx.guild_id |
string|nil | The guild's snowflake ID, nil in DMs |
ctx.member_roles |
string[] | Array of the invoking member's role IDs |
ctx.args |
table | Named arguments provided by the user |
ctx.reply(msg, ephemeral?) |
function | Send a plain-text response |
ctx.reply_embed(data, ephemeral?) |
function | Send an embed as the response |
ctx.defer(ephemeral?) |
function | Acknowledge the interaction immediately (for slow commands) |
ctx.followup(msg, ephemeral?) |
function | Send a follow-up message after ctx.defer()
|
ctx.followup_embed(data, ephemeral?) |
function | Send an embed follow-up after ctx.defer()
|
ctx.modal(custom_id, title, fields) |
function | Respond with a modal dialog form |
Sends a plain-text response to the slash command. This is the standard way to respond.
-
message(string): The text to send. -
ephemeral(boolean, optional): Iftrue, only the user who ran the command can see the response. Default:false.
ctx.reply("Done!")
ctx.reply("This is a secret.", true) -- only you can see thisOne response per interaction. Discord only allows one direct response per command invocation. If you want to send a public embed as well as a silent acknowledgement, use
navi.send_messagefor the embed andctx.reply("...", true)for the hidden confirmation.
Sends a rich embed as the direct response to the command.
-
data(table): An embed table. See Section 13 for the full structure. -
ephemeral(boolean, optional): Iftrue, only the invoking user sees it.
ctx.reply_embed({
title = "Your Stats",
description = "Here is your profile.",
color = 0x3498DB,
fields = {
{ name = "Level", value = "12", inline = true },
{ name = "XP", value = "840", inline = true }
}
}, true)For commands that take more than ~3 seconds (API calls, heavy computation), you must defer first. Deferring tells Discord "I received this, give me a moment" which prevents the "This application did not respond" error.
ctx.defer blocks until Discord confirms the acknowledgment, so it is safe to do slow work immediately after calling it.
navi.create_slash("slow_command", "Fetches data from the web", {}, function(ctx)
-- Tell Discord to wait
ctx.defer()
-- Now do the slow work
local body = navi.http.get("https://api.example.com/data", nil)
local data = body and navi.json.decode(body)
-- Respond with a follow-up (can be called multiple times)
if data then
ctx.followup("Got it: " .. tostring(data.result))
else
ctx.followup("Request failed.", true)
end
end)ctx.followup_embed(data, ephemeral?) works the same way but sends an embed:
ctx.defer()
-- ... do slow work ...
ctx.followup_embed({
title = "Result",
description = "Here's what I found."
})Instead of replying with a message, you can open a modal dialog (a popup form). The user fills it in and submits. You handle the submission with navi.register_modal.
Note: A modal is a response to the interaction — you cannot also call
ctx.replyin the same handler. Choose one or the other.
-
custom_id(string): Identifier used to match the modal to itsregister_modalhandler. -
title(string): The title of the popup window. -
fields(table): List of text input definitions.
navi.create_slash("feedback", "Submit feedback", {}, function(ctx)
ctx.modal("feedback_form", "Share Your Feedback", {
{ id = "subject", label = "Subject", style = "short", placeholder = "Brief topic", required = true },
{ id = "body", label = "Your feedback", style = "paragraph", placeholder = "Tell us more…", required = true },
{ id = "rating", label = "Rating (1-10)", style = "short", placeholder = "e.g. 8", required = false },
})
end)
navi.register_modal("feedback_form", function(ctx)
local subject = ctx.values.subject
local body = ctx.values.body
local rating = ctx.values.rating or "not given"
navi.say(FEEDBACK_CHANNEL, "**Feedback from " .. ctx.username .. "**\n**" .. subject .. "**\n" .. body .. "\nRating: " .. rating)
ctx.reply("Thanks for your feedback!", true)
end)| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Key used to read the value in ctx.values
|
label |
string | Yes | The label shown above the input |
style |
"short" | "paragraph"
|
No | Single-line vs multi-line input. Default: "short"
|
placeholder |
string | No | Greyed-out hint text |
required |
boolean | No | Whether the user must fill this in. Default: true
|
Buttons and select menus are attached to messages via the components field in an embed table (see Section 13). When clicked or selected, they fire a register_component handler.
Registers a handler function that fires when a button with the matching custom_id is clicked, or when a select menu with the matching id has its value changed.
-
custom_id(string): The ID you put in theidfield of the button or select menu. -
callback(function): Called with aNaviComponentCtxobject.
-- Send a message with a button
navi.send_message(channel_id, {
description = "Click the button!",
components = {
{ type = "button", id = "my_button", label = "Click Me", style = "primary" }
}
})
-- Handle the click
navi.register_component("my_button", function(ctx)
ctx.reply("You clicked the button, " .. ctx.username .. "!", true)
end)| Style | Color | Use for |
|---|---|---|
"primary" |
Blue | Main action |
"secondary" |
Grey | Secondary/neutral action |
"success" |
Green | Confirming or positive action |
"danger" |
Red | Destructive or risky action |
"link" / "url"
|
Grey (opens URL) | External links — requires a url field instead of id
|
Link buttons open a URL instead of triggering an interaction. They do not need a register_component handler.
components = {
{ type = "button", style = "link", label = "Visit Website", url = "https://example.com" }
}A string-select dropdown. The selected value(s) are available in ctx.values inside the handler.
navi.send_message(channel_id, {
description = "Pick your favorite color.",
components = {
{
type = "select",
id = "color_picker",
placeholder = "Choose a color…",
options = {
{ label = "Red", value = "red", description = "Warm and bold" },
{ label = "Blue", value = "blue", description = "Cool and calm" },
{ label = "Green", value = "green", emoji = "🌿" }
}
}
}
})
navi.register_component("color_picker", function(ctx)
local selected = ctx.values[1] -- ctx.values is an array
ctx.reply("You chose: " .. selected, true)
end)The ctx object passed to a register_component handler.
| Field / Method | Type | Description |
|---|---|---|
ctx.custom_id |
string | The id of the clicked button or selected menu |
ctx.user_id |
string | The snowflake ID of the user who clicked |
ctx.username |
string | The username of the user who clicked |
ctx.channel_id |
string | The channel's snowflake ID |
ctx.guild_id |
string|nil | The guild's snowflake ID, nil in DMs |
ctx.member_roles |
string[] | Role IDs of the clicking member |
ctx.values |
string[] | Selected values (non-empty only for select menus) |
ctx.reply(msg, ephemeral) |
function | Reply to the interaction |
ctx.reply_embed(data, ephemeral?) |
function | Reply with an embed |
ctx.modal(custom_id, title, fields) |
function | Respond with a modal dialog |
navi.register_component("btn_verify_human", function(ctx)
local role_id = navi.db.get("config:verification:verified_role")
if not role_id or role_id == "" then
ctx.reply("Not configured yet.", true)
return
end
navi.add_role(ctx.guild_id, ctx.user_id, role_id)
ctx.reply("✅ You are now verified! Welcome.", true)
end)Registers a handler for when a user submits a modal form with the matching custom_id. The custom_id must match what was passed to ctx.modal(...) when the modal was opened.
navi.register_modal("report_form", function(ctx)
local reason = ctx.values.reason
navi.say(LOG_CHANNEL, ctx.username .. " filed a report: " .. reason)
ctx.reply("Your report was received.", true)
end)The ctx object passed to a register_modal handler.
| Field / Method | Type | Description |
|---|---|---|
ctx.custom_id |
string | The modal's custom_id
|
ctx.user_id |
string | The submitting user's snowflake ID |
ctx.username |
string | The submitting user's username |
ctx.channel_id |
string | The channel's snowflake ID |
ctx.guild_id |
string|nil | The guild's snowflake ID |
ctx.member_roles |
string[] | Role IDs of the submitting member |
ctx.values |
table | Map of field_id → submitted_value (all strings) |
ctx.reply(msg, ephemeral) |
function | Reply to the modal submission |
ctx.reply_embed(data, ephemeral?) |
function | Reply with an embed |
An embed is a rich message card. You pass the same embed table structure to navi.send_message, ctx.reply_embed, and ctx.followup_embed.
{
title = "Optional title",
description = "Main body text. Supports **markdown**.",
color = 0x3498DB, -- hex color integer
image = "https://example.com/image.png", -- large image at bottom
fields = {
{ name = "Field 1", value = "Some text", inline = true },
{ name = "Field 2", value = "More text", inline = true },
{ name = "Long one", value = "Full width", inline = false },
},
components = {
-- Buttons and/or a select menu (see below)
}
}All fields are optional. You can use any combination.
| Field | Type | Description |
|---|---|---|
title |
string | Bold title at the top |
description |
string | Main body text (supports Discord markdown) |
color |
number | Hex color as an integer, e.g. 0xFF0000 for red |
image |
string | URL of a large image to display at the bottom |
fields |
table | Array of {name, value, inline?} objects |
components |
table | Array of buttons and/or one select menu |
fields = {
{ name = "Level", value = "42", inline = true },
{ name = "XP", value = "12,500", inline = true },
{ name = "Status", value = "Active", inline = false },
}When inline = true, up to 3 fields display side-by-side. Use inline = false (or omit it) for fields that should take the full width.
You can attach up to 5 buttons and/or 1 select menu to any message. Mix them freely in the components array — the engine lays them out into Discord action rows automatically.
navi.send_message(channel_id, {
title = "Choose your role",
color = 0x5865F2,
components = {
{ type = "button", id = "role_warrior", label = "Warrior", style = "primary" },
{ type = "button", id = "role_mage", label = "Mage", style = "primary" },
{ type = "button", id = "role_rogue", label = "Rogue", style = "secondary" },
{
type = "select",
id = "faction_picker",
placeholder = "Pick a faction…",
options = {
{ label = "Alliance", value = "alliance" },
{ label = "Horde", value = "horde" },
}
}
}
})0x2ECC71 -- Emerald green (success)
0xE74C3C -- Alizarin red (error / danger)
0xF1C40F -- Sunflower gold (warning / economy)
0x3498DB -- Peter River blue (info)
0x5865F2 -- Discord blurple
0x99AAB5 -- Grey/neutral
0x000000 -- Black (removes the color bar)The event bus lets plugins talk to each other without direct coupling. One plugin emits an event; any number of other plugins can listen for it.
Publishes an event to all current listeners.
-
event_name(string): A namespaced string. Convention:"plugin_name:event". -
data(any): Data passed to the listeners. Usually a table.
-- In economy.lua, after a balance change
navi.emit("economy:balance_changed", {
user_id = user_id,
old_balance = old_balance,
new_balance = new_balance,
amount_changed = amount
})Subscribes to an event. The callback receives whatever data was passed to emit.
-- In leveling.lua, listen for economy changes
navi.on("economy:balance_changed", function(data)
navi.log.info(data.user_id .. " now has " .. data.new_balance .. " credits")
end)The engine itself emits one event:
| Event | Data | Description |
|---|---|---|
"message" |
NaviMsg |
Fired for every message (same data as navi.register) |
"member_join" |
{user_id, username, guild_id} |
Fired when a new member joins the guild |
You can use navi.on("message", ...) as an alternative to navi.register. Both work identically.
-- Alternative to navi.register
navi.on("message", function(msg)
if msg.content == "!hello" then
navi.say(msg.channel_id, "Hi!")
end
end)The economy plugin exposes two events as a "public API" that any other plugin can call:
-- Give a user money (from any plugin)
navi.emit("economy:add", { user_id = user_id, amount = 50 })
-- Take money from a user (from any plugin)
navi.emit("economy:remove", { user_id = user_id, amount = 25 })This is the recommended pattern for inter-plugin data modification. It avoids tight coupling and keeps the economy logic in one place.
These are optional global functions you can define in your plugin. If the engine sees them, it calls them when the corresponding Discord event fires.
Called when a reaction is added or removed from any message.
function on_reaction_add(ctx)
-- ctx.user_id, ctx.channel_id, ctx.message_id, ctx.guild_id, ctx.emoji
if ctx.emoji == "⭐" then
navi.say(STARBOARD_CHANNEL, "Starred message: " .. ctx.message_id)
end
end
function on_reaction_remove(ctx)
navi.log.info(ctx.user_id .. " removed " .. ctx.emoji)
end| Field | Type | Description |
|---|---|---|
ctx.user_id |
string|nil | The user who reacted |
ctx.channel_id |
string | The channel containing the message |
ctx.message_id |
string | The message that was reacted to |
ctx.guild_id |
string|nil | The guild, or nil in DMs |
ctx.emoji |
string | Unicode emoji or <:name:id> for custom emojis |
Called when a member leaves or is removed from the server.
function on_member_leave(data)
navi.say(LOG_CHANNEL, "👋 " .. data.username .. " has left the server.")
end| Field | Type | Description |
|---|---|---|
data.user_id |
string | The user's snowflake ID |
data.username |
string | The user's username |
data.guild_id |
string | The guild's snowflake ID |
Called when any message is edited. Note: new_content may be nil if Discord's gateway event did not include the message body (common for cached messages).
function on_message_edit(data)
if data.new_content then
navi.log.info("Message " .. data.message_id .. " edited: " .. data.new_content)
end
end| Field | Type | Description |
|---|---|---|
data.message_id |
string | The edited message's snowflake ID |
data.channel_id |
string | The channel's snowflake ID |
data.guild_id |
string|nil | The guild's snowflake ID |
data.new_content |
string|nil | The new text content, or nil if unavailable |
Called when a message is deleted.
function on_message_delete(data)
navi.log.warn("Message deleted in channel " .. data.channel_id)
end| Field | Type | Description |
|---|---|---|
data.message_id |
string | The deleted message's snowflake ID |
data.channel_id |
string | The channel's snowflake ID |
data.guild_id |
string|nil | The guild's snowflake ID |
Called whenever a user's voice state changes — joining a channel, leaving, muting, deafening, going live, etc.
function on_voice_state_update(data)
if data.channel_id then
navi.log.info(data.user_id .. " joined voice: " .. data.channel_id)
else
navi.log.info(data.user_id .. " left voice entirely")
end
end| Field | Type | Description |
|---|---|---|
data.user_id |
string | The user's snowflake ID |
data.guild_id |
string|nil | The guild's snowflake ID |
data.channel_id |
string|nil | The channel they are now in, or nil if they disconnected |
data.self_mute |
boolean | Whether the user has muted themselves |
data.self_deaf |
boolean | Whether the user has deafened themselves |
data.self_stream |
boolean | Whether the user is streaming (Go Live) |
data.self_video |
boolean | Whether the user has their camera on |
Important: Only one plugin should define each global callback (e.g.
on_reaction_add). If two plugins both define it, the second one overwrites the first. Usenavi.on("message", ...)and similar event bus patterns for multi-plugin handling instead.
Sends a plain-text message. Fire-and-forget (non-blocking).
navi.say(msg.channel_id, "Hello world!")
navi.say(tostring(msg.channel_id), "Also works with string IDs")Like navi.say but blocks until the message is sent and returns the new message's snowflake ID as a string, or nil on error.
local msg_id = navi.say_sync(channel_id, "This message was sent synchronously")
if msg_id then
navi.log.info("Sent as message " .. msg_id)
endSends a rich embed (with optional buttons/selects). Fire-and-forget. See Section 13 for the full embed structure.
navi.send_message(channel_id, {
title = "Welcome!",
description = "Glad to have you here.",
color = 0x2ECC71
})Sends a plain-text direct message to a user. Fire-and-forget. If the user has DMs disabled, the error is logged but does not crash the plugin.
navi.dm(ctx.user_id, "Hey, check this out!")Adds a reaction to a message.
-
emoji: A Unicode emoji ("❤️","⭐") or a custom emoji string in the format"name:id".
navi.react(tostring(msg.channel_id), tostring(msg.message_id), "⭐")Edits the text content of a message the bot previously sent.
navi.edit_message(channel_id, message_id, "Updated content")Deletes a message.
navi.delete_message(tostring(msg.channel_id), tostring(msg.message_id))Fetches a message from Discord by ID. Blocks until complete. Returns a table or nil if not found.
local fetched = navi.fetch_message(channel_id, message_id)
if fetched then
navi.log.info("Message content: " .. fetched.content)
end| Field | Type | Description |
|---|---|---|
fetched.message_id |
string | The message's snowflake ID |
fetched.channel_id |
string | The channel's snowflake ID |
fetched.guild_id |
string|nil | The guild's snowflake ID |
fetched.content |
string | The message text |
fetched.author_id |
string | The author's snowflake ID |
fetched.author |
string | The author's username |
fetched.attachments |
string[] | Array of attachment URLs |
Assigns a role to a member. All arguments are strings.
navi.add_role(ctx.guild_id, ctx.user_id, verified_role_id)Removes a role from a member.
navi.remove_role(ctx.guild_id, ctx.user_id, unverified_role_id)Fetches live member info from Discord. Blocks until complete. Returns a table or nil.
local member = navi.get_member(ctx.guild_id, ctx.user_id)
if member then
navi.log.info("Display name: " .. member.display_name)
navi.log.info("Roles: " .. table.concat(member.roles, ", "))
end| Field | Type | Description |
|---|---|---|
member.user_id |
string | The member's snowflake ID |
member.username |
string | The member's username |
member.display_name |
string | Nickname if set, otherwise username |
member.nickname |
string|nil | Server-specific nickname, or nil
|
member.joined_at |
string|nil | ISO 8601 timestamp of when they joined |
member.roles |
string[] | Array of role snowflake IDs |
Kicks a member from the guild. The reason is optional and will appear in the audit log.
navi.kick(ctx.guild_id, target_user_id, "Spam")Bans a member. delete_message_days (0–7) controls how many days of their recent messages to delete.
navi.ban(ctx.guild_id, target_user_id, 1, "Harassment")Removes a ban.
navi.unban(ctx.guild_id, target_user_id)Times out a member (they cannot send messages or join voice). Pass 0 to remove an existing timeout.
navi.timeout(ctx.guild_id, target_user_id, 600) -- 10 minutes
navi.timeout(ctx.guild_id, target_user_id, 0) -- remove timeoutCreates a new text channel in the guild. Useful for dynamic systems like tickets.
navi.create_channel(ctx.guild_id, "ticket-username", {
category_id = category_id, -- place under this category
user_id = ctx.user_id, -- grant this user private access
role_id = support_role_id, -- grant this role private access
welcome_message = "Welcome! Staff will be with you shortly.",
close_button = true -- attach a red "Close Ticket" button
})| Option | Type | Description |
|---|---|---|
category_id |
string|nil | Snowflake ID of the parent category |
user_id |
string|nil | Grant this user private View + Send permissions |
role_id |
string|nil | Grant this role private View + Send permissions |
welcome_message |
string|nil | Text sent immediately after the channel is created |
close_button |
boolean|nil | If true, attaches a red "Close Ticket" button to the welcome message |
Permanently deletes a channel. Use with care — this is instant and irreversible.
navi.delete_channel(ctx.channel_id)Creates a thread and returns its channel ID as a string, or nil on failure. Blocks until complete so you can use the returned ID immediately.
-- Standalone public thread
local thread_id = navi.create_thread(ctx.channel_id, "My Thread")
-- Thread attached to a specific message
local thread_id = navi.create_thread(ctx.channel_id, "Discussion", {
message_id = ctx.message_id
})
-- Private thread that archives after an hour
local thread_id = navi.create_thread(ctx.channel_id, "Private Chat", {
private = true,
auto_archive = 60
})
if thread_id then
navi.say(thread_id, "Thread is open!")
end| Option | Type | Default | Description |
|---|---|---|---|
message_id |
string|nil | nil | If set, creates the thread attached to this message |
private |
boolean | false | Create a private thread (invite-only) |
auto_archive |
number | 1440 | Minutes until the thread auto-archives: 60, 1440, 4320, 10080
|
These functions return data from the bot's cached guild state. The cache is populated on startup and when you press u in the TUI to refresh. For the most up-to-date data, press u before relying on these.
Returns an array of role objects for the guild.
local roles = navi.get_roles(ctx.guild_id)
for _, role in ipairs(roles) do
navi.log.info(role.id .. " — " .. role.name)
end| Field | Type | Description |
|---|---|---|
role.id |
string | The role's snowflake ID |
role.name |
string | The role's display name |
role.color |
integer[] | RGB tuple: {r, g, b}
|
Returns an array of text channel objects for the guild.
local channels = navi.get_channels(ctx.guild_id)
for _, ch in ipairs(channels) do
navi.log.info(ch.id .. " — #" .. ch.name)
end| Field | Type | Description |
|---|---|---|
ch.id |
string | The channel's snowflake ID |
ch.name |
string | The channel's display name |
Sends an HTTP GET request. Blocks until complete. Returns the response body as a string, or nil on error.
local body = navi.http.get("https://api.example.com/endpoint", nil)
if body then
local data = navi.json.decode(body)
end-- With custom headers
local body = navi.http.get("https://api.private.com/data", {
["Authorization"] = "Bearer " .. api_key,
["Accept"] = "application/json"
})Sends an HTTP POST request. Blocks until complete. Returns the response body as a string, or nil on error.
local response = navi.http.post(
"https://api.example.com/submit",
navi.json.encode({ key = "value" }),
{ ["Content-Type"] = "application/json" }
)Important: Both
http.getandhttp.postblock the Lua thread while the request is in flight. For commands that make HTTP calls, usectx.defer()first so Discord doesn't time out while waiting.
Serializes a Lua table or value to a JSON string.
local poll_data = { title = "Best pet?", options = { "Dog", "Cat" }, closed = false }
local json_str = navi.json.encode(poll_data)
navi.db.set("polls:data:1", json_str)Parses a JSON string into a Lua value (table, number, string, boolean, or nil).
local json_str = navi.db.get("polls:data:1")
local poll = json_str and navi.json.decode(json_str)
if poll then
navi.log.info("Poll title: " .. poll.title)
endJSON and the database together are the standard pattern for storing structured data (arrays, nested tables) since navi.db.set only accepts a flat string.
Schedules a function to run repeatedly every amount units. Returns an interval ID. All active intervals are automatically cancelled when plugins are reloaded.
-
unit:"ms"(default),"s"/"seconds","m"/"minutes","h"/"hours","d"/"days"
-- Check for expired polls every 60 seconds
navi.set_interval(function()
local now = os.time()
for pid in (navi.db.get("polls:active") or ""):gmatch("[^,]+") do
local poll = navi.json.decode(navi.db.get("polls:data:" .. pid) or "")
if poll and not poll.closed and now >= poll.expires_at then
close_poll(pid)
end
end
end, 60, "s")-- Send a message every 5 minutes
local my_interval = navi.set_interval(function()
navi.say(ANNOUNCE_CHANNEL, "Reminder: read the rules!")
end, 5, "m")Cancels a running interval by the ID returned from set_interval. No-op if the ID doesn't exist.
navi.clear_interval(my_interval)The permissions system maps users and roles to a level hierarchy. Levels from lowest to highest: "user" → "helper" → "moderator" → "admin" → "owner". Permissions are configured from the TUI.
Checks whether the user meets or exceeds the required level. If they do not, it automatically sends them an ephemeral denial message and returns false. If they do, it returns true.
This is the standard way to gate admin-only commands. The pattern is always:
if not navi.require_perm(ctx, "admin") then return endnavi.create_slash("delete_all_posts", "Nuke every post", {}, function(ctx)
if not navi.require_perm(ctx, "moderator") then return end
-- Only reaches here if the user is a moderator, admin, or owner
-- ... do the dangerous thing ...
end)Like require_perm but silent — it just returns true or false with no side effects. Use this when you want to adjust behaviour based on permissions without sending a denial.
if navi.check_perm(ctx, "moderator") then
-- Show extra fields to mods
endReturns the user's highest permission level as a string. Never returns nil; defaults to "user".
local level = navi.get_perm_level(ctx)
ctx.reply("Your permission level is: " .. level)By default, plugins load in alphabetical order. If your plugin uses data or functions from another plugin (e.g. casino reading economy balances), you need to make sure the dependency loads first.
Declares that this plugin depends on another. The Rust engine scans for these calls before executing any Lua and performs a topological sort to ensure dependencies load first.
Call this at the very top of your file, before any other code.
-- casino.lua
navi.depends_on("economy") -- economy.lua will always load before casino.lua
navi.log.info("Loading Casino Plugin")
-- ...You can declare multiple dependencies:
navi.depends_on("economy")
navi.depends_on("leveling")Note:
navi.depends_onis a no-op at runtime — it does nothing when Lua actually executes it. Its only job is to be visible in the source file for the Rust loader to scan.
Changes the bot's Discord presence (the "Playing X" message shown in the member list).
-
activity_type:"playing","listening","watching","competing","custom", or"none"
navi.set_status("watching", "over the server")
navi.set_status("playing", "with fire")
navi.set_status("listening", "your complaints")
navi.set_status("none", "") -- clears the statusDiscord uses 64-bit integer "snowflake" IDs for users, channels, guilds, messages, roles, etc. In Lua 5.4 these are handled as integers, but always use tostring() before passing them to navi.db.set or string concatenation to be safe.
-- Safe pattern:
local uid = tostring(msg.author_id)
navi.db.set("xp:" .. uid, tostring(new_xp))In description or message text, you can mention users and roles with Discord's mention syntax. Discord renders these as clickable mentions.
-- Mention a user
"<@" .. user_id .. ">"
-- Mention a role
"<@&" .. role_id .. ">"
-- Mention a channel
"<#" .. channel_id .. ">"navi.db.get returns nil if the key doesn't exist. Always provide a fallback:
-- Bad — can crash if key is missing
local xp = tonumber(navi.db.get("xp:" .. uid))
local new_xp = xp + 10 -- error: attempt to add nil and number
-- Good
local xp = tonumber(navi.db.get("xp:" .. uid)) or 0
local new_xp = xp + 10Message and user IDs come from the engine as numbers. Store them as strings by calling tostring() right away:
navi.register(function(msg)
local uid = tostring(msg.author_id) -- do this once, at the top
local bal = tonumber(navi.db.get("balance:" .. uid)) or 0
navi.db.set("balance:" .. uid, tostring(bal + 5))
end)If you respond to every message and your bot also sends messages, you'll get an infinite loop. Always guard against it:
navi.register(function(msg)
if msg.author_bot then return end
-- safe to process now
end)Any slash command that makes an HTTP request, does a heavy database query, or takes more than ~2 seconds to respond must call ctx.defer() first. Discord will time out the interaction otherwise.
navi.create_slash("weather", "Get current weather", {
{ name = "city", description = "City name", type = "string", required = true }
}, function(ctx)
ctx.defer() -- tell Discord to wait
local url = "https://api.weather.example.com/city/" .. ctx.args.city
local body = navi.http.get(url, nil)
local data = body and navi.json.decode(body)
if data then
ctx.followup("🌤️ " .. ctx.args.city .. ": " .. data.description .. ", " .. data.temp .. "°C")
else
ctx.followup("❌ Couldn't fetch weather data.", true)
end
end)Don't read another plugin's database keys directly when the event bus is available. The economy plugin exposes clean events:
-- Preferred: use the public API
navi.emit("economy:add", { user_id = uid, amount = 100 })
navi.emit("economy:remove", { user_id = uid, amount = 50 })
-- Acceptable: read economy's balance (read-only, no side effects)
local balance = tonumber(navi.db.get("economy:balance:" .. uid)) or 0The database stores strings only. If you need to save a table (arrays, nested objects), encode it with navi.json.encode first:
local poll_data = {
title = "Best pet?",
options = { "Dog", "Cat", "Fish" },
expires_at = os.time() + 3600,
closed = false
}
navi.db.set("polls:data:42", navi.json.encode(poll_data))
-- Reading back:
local raw = navi.db.get("polls:data:42")
local poll = raw and navi.json.decode(raw)
if poll and not poll.closed then
navi.log.info("Poll is still open: " .. poll.title)
endDeclare all helper functions as local. If two plugins both define a non-local function with the same name, the second one silently overwrites the first.
-- Bad — pollutes globals, may conflict with other plugins
function get_balance(uid)
return tonumber(navi.db.get("balance:" .. uid)) or 0
end
-- Good — scoped to this plugin file
local function get_balance(uid)
return tonumber(navi.db.get("balance:" .. uid)) or 0
endWhen you press r in the TUI, every plugin file is re-executed from scratch. This means:
- All
navi.register,navi.create_slash,navi.register_component, andnavi.register_modalcalls run again — this is expected and correct. - Any global variables your plugin set in the previous load are still there. If your plugin does something like
INITIALIZED = trueand checks it, that check will betrueon the second load. Be careful. - Intervals are cancelled automatically before reload. You don't need to track and cancel them yourself.
Config values of type channel and role store the snowflake ID as a string. You can use them directly in Discord mention syntax:
local role_id = navi.db.get("config:my_plugin:mod_role")
-- Mention it in a message
navi.say(channel_id, "Paging <@&" .. role_id .. ">!")
-- Use it to assign a role
navi.add_role(guild_id, user_id, role_id)No extra lookup is needed. The ID is the ID.
When navi.register_config writes defaults to the database, it stores them under the key config:plugin_name:key. You must use this exact prefix when reading config values manually — navi.db.get auto-namespacing does not apply to config: keys (because the config: prefix already makes the key explicit with a colon).
-- Reading a config value (always use the full key)
local reward = tonumber(navi.db.get("config:economy:message_reward")) or 5