Skip to content

Executing Lua with your bot

SinisterRectus edited this page Feb 22, 2017 · 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('```%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)) -- 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))

	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 wil need to define a custom environment table.

local function exec(arg, msg)

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

	local sandbox = table.copy(_G) -- create a sandbox environment

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

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

end

Discordia has a table.copy function. Here, it is used to make a shallow copy of the current global environment _G.

Of course, you can customize your environment to include or exclude certain features. For example, you might want to exclude the os library:

local function exec(arg, msg)

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

	local sandbox = table.copy(_G)
	sandbox.os = nil -- remove features if necessary

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

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

end

Or you might want to add Discordia objects:

local function exec(arg, msg)

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

	local sandbox = table.copy(_G)
	sandbox.message = msg -- add features if necessary

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

	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 print 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

	local sandbox = table.copy(_G)
	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))

	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

	local sandbox = table.copy(_G)
	sandbox.message = msg

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

	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 = {}

	local sandbox = table.copy(_G)
	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))

	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 = {}

	local sandbox = table.copy(_G)
	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))

	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 = {}

	local sandbox = table.copy(_G)
	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))

	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 = {}

	local sandbox = table.copy(_G)
	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))

	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