325 lines
9.5 KiB
Lua
325 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
|