Skip to content

Executing Lua with your bot

Nameless edited this page Oct 20, 2018 · 15 revisions

I've seen a significant number of people attempt to execute Lua code with their Discordia bots with varying success. In order to clear up confusion about how this can be done, I will explain one method of doing this.

Note that this guide is for experienced Lua users. Do not attempt to do this if you are new to Lua or programming.

Additionally, you should restrict the use of this feature only to yourself, the bot owner. Allowing other users to execute code via your bot is a security risk. Trust no one!

Content Parsing

This tutorial assumes that you have already parsed your message content for a specific prefix (such as ! or /) and command (such as lua, eval, or exec). Once you have the prefix and command stripped from the content, you should be left with a string of Lua code. For example:

"/lua print('hello world')"

becomes

"print('hello world')"

Dynamic Execution

Setting Up

To help out later, we will define a helper function that will wrap text in a markdown code block:

local function code(str)
    return string.format('```\n%s```', str)
end

Early Filtering

Let's say you have a function that takes Lua code arg to execute and the corresponding message object msg as arguments. The first thing you want to do is filter some situations:

local function exec(arg, msg)

    if not arg then return end -- make sure arg exists
    if msg.author ~= msg.client.owner then return end -- restrict to owner only

end

You'll probably want to make sure arg is valid if your command handler did not already do this. I also strongly recommend that you restrict the usage of this command to only the owner of the bot, as executing random code can be a security risk.

Executing Lua Code

In Lua 5.1, load is used for executing functions and loadstring is used for executing strings. In Lua 5.2, loadstring was removed and load was changed to execute strings, and was given more features. In LuaJIT and Luvit, the 5.2 version of load is used, and loadstring is an alias for the same function. For simplicity, I will use load, which is documented here.

As documented, load returns a function if it was successful, or a syntax error if it was not. Thus, you can execute code and check for syntax errors first:

local function exec(arg, msg)

    if not arg then return end
    if msg.author ~= msg.client.owner then return end

    local fn, syntaxError = load(arg) -- load the code
    if not fn then return msg:reply(code(syntaxError)) end -- handle syntax errors

end

If a syntax error is discovered, it can be sent as a reply to the Discord message. If the function is created, we can execute the code that it represents:

local function exec(arg, msg)

    if not arg then return end
    if msg.author ~= msg.client.owner then return end

    local fn, syntaxError = load(arg)
    if not fn then return msg:reply(code(syntaxError)) end

    local success, runtimeError = pcall(fn) -- run the code
    if not success then return msg:reply(code(runtimeError)) end -- handle runtime errors

end

Notice that I used pcall here. If there is a runtime error, pcall will prevent it from crashing your bot, and will return the error message instead. Like with the syntax error, this can be sent as a reply to the Discord message.

This is actually enough information to execute Lua code, but there are probably some more things that you will want to do.

Function Environments

Without getting into excessive detail, Lua functions have an environment that contains all of the variables that the function can "see". The default environment for a function created by load is a sandboxed standard Lua global environment with nothing special; no Luvit or Discordia features. In order to have advanced functionality, you will need to define a custom environment table.

local sandbox = {}

    local function exec(arg, msg)

    if not arg then return end
    if msg.author ~= msg.client.owner then return end

    local fn, syntaxError = load(arg, 'DiscordBot', 't', sandbox)
    if not fn then return msg:reply(code(syntaxError)) end

    local success, runtimeError = pcall(fn)
    if not success then return msg:reply(code(runtimeError)) end

end

Unfortunately, an empty table is not useful for doing much. You can whitelist necessary components:

local sandbox = {
	math = math,
	string = string,
}

If you'd like to have the global environment of your Luvit script be available, you can use a metatable to indirectly access _G:

local sandbox = setmetatable({ }, { __index = _G })

If you'd like to blacklist certain libraries, you can set them to be an empty table. Use of a metatable prevents modification of _G itself:

local sandbox = setmetatable({
	os = { }
}, { __index = _G })

When executing the dynamic code, you might want to drop some Discordia objects into the sandbox:

local function exec(arg, msg)

    if not arg then return end
    if msg.author ~= msg.client.owner then return end

    sandbox.message = msg -- add features as necessary

    local fn, syntaxError = load(arg, 'DiscordBot', 't', sandbox)
    if not fn then return msg:reply(code(syntaxError)) end

    local success, runtimeError = pcall(fn)
    if not success then return msg:reply(code(runtimeError)) end

end

Now you can do things like send messages to a channel or check your guild's member count.

Re-directing print statements

One thing you might have noticed is that print will print strings to your Lua console (which is expected). What if you want to use your Discord channel as the console? The naive way to do this would be to define print as a message creator:

local function exec(arg, msg)

    if not arg then return end
    if msg.author ~= msg.client.owner then return end

    sandbox.message = msg

    sandbox.print = function(...) -- don't do this
        msg:reply(...)
    end)

    local fn, syntaxError = load(arg, 'DiscordBot', 't', sandbox)
    if not fn then return msg:reply(code(syntaxError)) end

    local success, runtimeError = pcall(fn)
    if not success then return msg:reply(code(runtimeError)) end

end

The problem with this is that for every call to print, you will create a message. This can become excessive if you have a lot of things to print, or are printing from inside of a lengthy loop. Wouldn't it be better if you can combine all of these printed lines into one message?

To do this takes some trickery. First, you need a place to collect all the printed lines as they are generated during the code execution. Let's use a table:

local function exec(arg, msg)

    if not arg then return end
    if msg.author ~= msg.client.owner then return end

    local lines = {} -- this is where our printed lines will collect

    sandbox.message = msg

    local fn, syntaxError = load(arg, 'DiscordBot', 't', sandbox)
    if not fn then return msg:reply(code(syntaxError)) end

    local success, runtimeError = pcall(fn)
    if not success then return msg:reply(code(runtimeError)) end

end

Now you need to re-define print so that instead of printing to the console, it dumps its lines to the lines table. We can use a helper function to do this:

local function printLine(...)
    local ret = {}
    for i = 1, select('#', ...) do
        local arg = tostring(select(i, ...))
        table.insert(ret, arg)
    end
    return table.concat(ret, '\t')
end

This function simulates the behavior of Lua's print function. It takes a variable number of arguments, converts them to strings, and concatenates them with tabs. The difference is that instead of writing the line to stdout, it returns the line.

You can do the same thing for Luvit's pretty-print function:

local pp = require('pretty-print')

local function prettyLine(...)
    local ret = {}
    for i = 1, select('#', ...) do
        local arg = pp.strip(pp.dump(select(i, ...)))
        table.insert(ret, arg)
    end
    return table.concat(ret, '\t')
end

These can now be used to populate your lines table when ever print or p are encountered in your code:

local function exec(arg, msg)

    if not arg then return end
    if msg.author ~= msg.client.owner then return end

    local lines = {}

    sandbox.message = msg

    sandbox.print = function(...) -- intercept printed lines with this
        table.insert(lines, printLine(...))
    end

    sandbox.p = function(...) -- intercept pretty-printed lines with this
        table.insert(lines, prettyLine(...))
    end

    local fn, syntaxError = load(arg, 'DiscordBot', 't', sandbox)
    if not fn then return msg:reply(code(syntaxError)) end

    local success, runtimeError = pcall(fn)
    if not success then return msg:reply(code(runtimeError)) end

end

After the table has been filled, you need to concatenate the lines and send them to the channel:

local function exec(arg, msg)

    if not arg then return end
    if msg.author ~= msg.client.owner then return end

    local lines = {}

    sandbox.message = msg

    sandbox.print = function(...)
        table.insert(lines, printLine(...))
    end

    sandbox.p = function(...)
        table.insert(lines, prettyLine(...))
    end

    local fn, syntaxError = load(arg, 'DiscordBot', 't', sandbox)
    if not fn then return msg:reply(code(syntaxError)) end

    local success, runtimeError = pcall(fn)
    if not success then return msg:reply(code(runtimeError)) end

    lines = table.concat(lines, '\n') -- bring all the lines together
    return msg:reply(code(lines)) -- and send them as a message reply

end

Stripping Input and Output

What if you have a lot of code to input? Sometimes it's easier to put it into a code block, but Lua cannot interpret markdown code blocks. You have to strip the markdown characters from the content before running the code:

local function exec(arg, msg)

    if not arg then return end
    if msg.author ~= msg.client.owner then return end

    arg = arg:gsub('```\n?', '') -- strip markdown codeblocks

    local lines = {}

    sandbox.message = msg

    sandbox.print = function(...)
        table.insert(lines, printLine(...))
    end

    sandbox.p = function(...)
        table.insert(lines, prettyLine(...))
    end

    local fn, syntaxError = load(arg, 'DiscordBot', 't', sandbox)
    if not fn then return msg:reply(code(syntaxError)) end

    local success, runtimeError = pcall(fn)
    if not success then return msg:reply(code(runtimeError)) end

    lines = table.concat(lines, '\n') -- bring all the lines together
    return msg:reply(code(lines)) -- and send them as a message reply

end

What if the output is greater than the default Discord message length of 2000 characters? You'll probably want to handle this, since sometimes code output can become lengthy. You can either split it into multiple messages or just truncate it like so:

local function exec(arg, msg)

    if not arg then return end
    if msg.author ~= msg.client.owner then return end

    arg = arg:gsub('```\n?', '') -- strip markdown codeblocks

    local lines = {}

    sandbox.message = msg

    sandbox.print = function(...)
        table.insert(lines, printLine(...))
    end

    sandbox.p = function(...)
        table.insert(lines, prettyLine(...))
    end

    local fn, syntaxError = load(arg, 'DiscordBot', 't', sandbox)
    if not fn then return msg:reply(code(syntaxError)) end

    local success, runtimeError = pcall(fn)
    if not success then return msg:reply(code(runtimeError)) end

    lines = table.concat(lines, '\n')

    if #lines > 1990 then -- truncate long messages
        lines = lines:sub(1, 1990)
    end

    return msg:reply(code(lines))
        
end 

You now should have enough information to set up your own Lua code executor in your Discordia bot. Hopefully this tutorial was informative. Thanks for reading.

Clone this wiki locally