mun/core/plugin.lua

326 lines
9.5 KiB
Lua

-- An isolated API for plugins
-- These plugins are not yet made to be a secure sandbox, as hooking
-- into signal might be destructive or reveal sensitive data.
-- The goal is to be able to unload a plugin and remove all its' hooks
-- and commands.
require('lfs')
plugin = {}
plugin.Plugins = {}
plugin.API = {}
local Plugins = plugin.Plugins
local API = plugin.API
-- All these functions start with the 'plugin_id' argument, which
-- will be auto-bound to the plugin name when called from a plugin.
function API.AddHook(plugin_id, event_name, hook_name, callback)
local HookName = plugin_id .. ':' .. hook_name
local PluginHooks = Plugins[plugin_id].Hooks
local HookInfo = {}
HookInfo.EventName = event_name
HookInfo.HookName = HookName
PluginHooks[#PluginHooks + 1] = HookInfo
hook.Add(event_name, HookName, function(...)
local Args = {...}
local Success, Message = pcall(callback, unpack(Args))
if not Success then
hook.Call("plugin.HookCallFailed", HookName, Message)
return nil
else
return Message
end
end)
end
function API.Sleep(plugin_id, Delay)
plugin.PauseQuota()
reactor:Sleep(Delay)
plugin.ResumeQuota()
end
function API.NewEvent(plugin_id, Event)
plugin.PauseQuota()
local Event = reactor:NewEvent(Event)
plugin.ResumeQuota()
return Event
end
function API.AddCommand(plugin_id, command_name, arity, callback, help, access)
bot:AddCommand(command_name, arity, function(...)
plugin.StartQuota(10)
return callback(...)
end, help, access)
local PluginCommands = Plugins[plugin_id].Commands
PluginCommands[#PluginCommands + 1] = command_name
end
function API.Register(plugin_id, plugin_name, version, url, author)
local Plugin = Plugins[plugin_id]
Plugin.Name = plugin_name
Plugin.Version = Version
Plugin.URL = url
Plugin.Author = author
end
function API.CurrentTime(plugin_id)
return os.time()
end
function API.ConfigGet(plugin_id, Key)
return config:Get("plugin-" .. plugin_id, Key)
end
function API.DBOpen(plugin_id, Handle)
return db.Open(Handle)
end
local Quotas = {}
-- Start counting a coroutine quota
function plugin.StartQuota(Seconds)
local co = coroutine.running()
print("Starting quota for " .. tostring(co))
Quotas[co] = {}
Quotas[co].End = os.time() + Seconds
debug.sethook(plugin.CheckQuota, "", 10)
end
-- Pause counting a coroutine quota (because we are about to yield)
function plugin.PauseQuota()
local co = coroutine.running()
if Quotas[co] == nil then
return
end
print("Pausing quota for " .. tostring(co))
local Rest = Quotas[co].End - os.time()
Quotas[co].Rest = Rest
Quotas[co].End = nil
end
-- Resume counting a coroutine quota (because we just came back)
function plugin.ResumeQuota()
local co = coroutine.running()
if Quotas[co] == nil then
return
end
print("Resuming quota for " .. tostring(co))
local End = os.time() + Quotas[co].Rest
Quotas[co].End = End
Quotas[co].Rest = nil
end
-- Check to see if current coroutine exceeds quota
-- (this runs from debug.hook)
function plugin.CheckQuota()
local co = coroutine.running()
if co == nil then
return
end
if Quotas[co] == nil then
return
end
local End = Quotas[co].End
if End ~= nil then
if os.time() >= End then
print("Time quota exceeded.")
error("Time quota exceeded!")
end
end
end
function plugin.Create(plugin_id)
local Plugin = {}
Plugin.Hooks = {}
Plugin.Commands = {}
Plugin.ID = plugin_id
Plugin.Name = ""
Plugin.Version = 1.0
Plugin.URL = ""
Plugin.Author = "Nameless Wonder"
Plugins[plugin_id] = Plugin
Plugins[plugin_id].Env = plugin.PrepareEnvironment(plugin_id)
end
function plugin.Unload(plugin_id)
if Plugins[plugin_id] == nil then
error("No such plugin.")
end
local Plugin = Plugins[plugin_id]
local HooksRemoved = 0
local CommandsRemoved = 0
for K, HookInfo in pairs(Plugin.Hooks) do
hook.Remove(HookInfo.EventName, HookInfo.HookName)
HooksRemoved = HooksRemoved + 1
end
for K, CommandName in pairs(Plugin.Commands) do
bot:RemoveCommand(CommandName)
CommandsRemoved = CommandsRemoved + 1
end
Plugins[plugin_id] = nil
return HooksRemoved, CommandsRemoved
end
function plugin.PrepareEnvironment(plugin_id)
local function DeepCopy(t)
local Copied = {}
local Result = {}
local function Internal(Out, In)
for K, V in pairs(In) do
local Type = type(V)
if Type == "string" or Type == "function" or Type == "number" then
Out[K] = V
elseif Type == "table" then
if Copied[V] ~= nil then
Out[K] = Copied[V]
else
Copied[V] = {}
Internal(Copied[V], V)
Out[K] = Copied[V]
end
end
end
return Out
end
Internal(Result, t)
return Result
end
local function BindPluginID(f)
return function(...)
local Args = {...}
return f(plugin_id, unpack(Args))
end
end
local Env = {}
Env.table = DeepCopy(require('table'))
Env.string = DeepCopy(require('string'))
Env.math = DeepCopy(require('math'))
Env.json = DeepCopy(require('json'))
Env.DBI = DeepCopy(require('DBI'))
Env.redis = DeepCopy(require('redis'))
local https = require('ssl.https')
Env.https = DeepCopy(https)
local http = require("socket.http")
Env.http = DeepCopy(http)
Env.print = print
Env.error = error
Env.tonumber = tonumber
Env.tostring = tostring
Env.pcall = pcall
Env.type = type
Env.os = {}
Env.os.time = os.time
Env.loadstring = function(s)
if s:byte(1) == 27 then
return nil, "Refusing to load bytecode"
else
return loadstring(s)
end
end
Env.setfenv = setfenv
Env.pairs = pairs
Env._G = Env
Env.plugin = {}
for K, F in pairs(API) do
Env.plugin[K] = BindPluginID(F)
end
return Env
end
function plugin.RunCode(plugin_id, code)
if not Plugins[plugin_id] then
plugin.Create(plugin_id)
end
if code:byte(1) == 27 then
return nil, "Refused to load bytecode."
end
local Function, Message = loadstring(code)
if not Function then
return nil, Message
end
setfenv(Function, Plugins[plugin_id].Env)
return pcall(Function)
end
function plugin.AddRuntimeCommands()
local function PluginLoad(Username, Channel, Name)
if not Name:match('^([a-zA-Z0-9%-_]+)$') then
Channel:Say("Invalid plugin name!")
return
end
if Plugins[Name] ~= nil then
Channel:Say(string.format("Plugin %s already loaded!", Name))
return
end
local Filename = "plugins/" .. Name .. ".lua"
local File, Message = io.open(Filename, 'r')
if not File then
Channel:Say(string.format("Could not open plugin file %s: %s", Filename, Message))
return
end
local Data = File:read('*a')
local Success, Message = plugin.RunCode(Name, Data)
if not Success then
Channel:Say(string.format("Could not run plugin code: " .. Message))
return
end
Channel:Say(string.format("Loaded plugin %s successfully (%i bytes).", Name, #Data))
end
local function PluginUnload(Username, Channel, Name)
if Plugins[Name] ~= nil then
local Hooks, Commands = plugin.Unload(Name)
Channel:Say(string.format("Plugin unloaded (removed %i hooks and %i commands).", Hooks, Commands))
else
Channel:Say("Plugin wasn't loaded.")
end
end
local function PluginList(Username, Channel)
local Loaded = {}
for Plugin, V in pairs(Plugins) do
Loaded[#Loaded + 1] = Plugin
end
Channel:Say("Loaded plugins: " .. table.concat(Loaded, ", "))
end
bot:AddCommand('plugin-load', 1, PluginLoad, "Load a plugin from the plugins/ directory.", 100)
bot:AddCommand('plugin-unload', 1, PluginUnload, "Unload a previously loaded plugin.", 100)
bot:AddCommand('plugin-reload', 1, function(Username, Channel, Name)
PluginUnload(Username, Channel, Name)
PluginLoad(Username, Channel, Name)
end, "Unload a previously loaded plugin.", 100)
bot:AddCommand('plugin-list', 0, PluginList, "List loaded plugins.", 10)
end
function plugin.Discover()
for Filename in lfs.dir('plugins/') do
local FullFilename = 'plugins/' .. Filename
local Attributes = lfs.attributes(FullFilename)
if Attributes.mode == 'file' and FullFilename:sub(-4) == '.lua' then
local PluginName = Filename:sub(1, -5)
hook.Call('info', 'Loading plugin ' .. PluginName)
local File, Message = io.open(FullFilename)
if not File then
hook.Call('info', 'Skipping: ' .. Message)
else
local Data = File:read('*a')
local Success, Message = plugin.RunCode(PluginName, Data)
if not Success then
error(string.format("Could not load plugin %s: %s.", PluginName, Message))
end
end
end
end
end