1161 lines
38 KiB
Lua
1161 lines
38 KiB
Lua
-- ---------------------------------------------------------------------------
|
|
-- Itte
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
--[[
|
|
-- The main module file.
|
|
--]]
|
|
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Variables
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
local itte = {}
|
|
|
|
-- Store config names
|
|
itte.confs = {
|
|
prefix = nil,
|
|
config = ".config.lua",
|
|
servers = ".servers.lua",
|
|
}
|
|
|
|
-- Default application settings
|
|
itte.config = {
|
|
debug = false,
|
|
admin_handlers = { "connect", "quit", "reload", "servers", "join", "part" },
|
|
task_handler_prefix = "th_",
|
|
target_handler = "target",
|
|
messages = {
|
|
connect_success = "Connected to {{server}}.",
|
|
help = "Service codes available: {{codes}}",
|
|
join = "We're in.",
|
|
list_all_servers = "Known servers: {{servers}}",
|
|
list_channels = "Channels: {{channels}}",
|
|
list_conn_servers = "Connected servers: {{servers}}",
|
|
part = "The bot is greater than the sum of its parts.",
|
|
ping = "Pong!",
|
|
quit = "Goodbye.",
|
|
quit_success = "Quit {{server}}.",
|
|
reload = "Splines recticulated.",
|
|
},
|
|
errors = {
|
|
invalid_name = "Error: invalid server name `{{server}}`.",
|
|
no_perm = "I'm sorry, {{user}}. I'm afraid I can't do that.",
|
|
ns_auth_failed = "Error: Nickserv identification failed.",
|
|
sasl_auth_failed = "Error: SASL authentication failed.",
|
|
unknown_code = "I don't understand what you just said.",
|
|
},
|
|
debugs = {
|
|
cap = { "auth_cap" },
|
|
conn_svr = { "conn", "Connecting to {{server}} ..." },
|
|
dc_svr = { "conn", "Connection to {{server}} closed." },
|
|
exit = { "conn", "Exiting ..." },
|
|
listen = { "listen" },
|
|
names = { "names", "channel", "users" },
|
|
nickserv = { "auth_nickserv" },
|
|
privmsg = { "privmsg", "sender", "recipient", "reply_to", "code",
|
|
"code_params", "body" },
|
|
redact = { "********" },
|
|
send = { "send" },
|
|
svrs_not_found = { "config", "Error: servers not found." },
|
|
task_added = { "task", "Task `{{name}}` added." },
|
|
task_activated = { "task", "Task `{{name}}` activated." },
|
|
},
|
|
}
|
|
|
|
-- Store server info
|
|
itte.servers = {}
|
|
|
|
-- Store custom handlers
|
|
itte.handlers = {}
|
|
|
|
-- Store global admins
|
|
itte.admins = {}
|
|
|
|
-- Internal only: store core handlers
|
|
itte._h = {}
|
|
|
|
-- Internal only: store function docstrings
|
|
itte.docs = {}
|
|
itte.docs._h = {}
|
|
|
|
-- Internal only: track application state
|
|
itte.state = {
|
|
connected = false,
|
|
timeout = 0.1,
|
|
}
|
|
itte.contexts = {}
|
|
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Functions
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
local util = require("itteutil")
|
|
|
|
itte.docs.help = [[ ([func_str])
|
|
Given a function name, print a corresponding description. If no function name
|
|
is provided, print all function descriptions.
|
|
]]
|
|
function itte.help(name)
|
|
util.get_docs(itte.docs, name)
|
|
end
|
|
|
|
|
|
itte.docs.get_config = [[ ([reload_bool])
|
|
Load the config file.
|
|
]]
|
|
function itte.get_config(reload)
|
|
-- If prefix is not set, try custom file names or defaults
|
|
if itte.confs.prefix == nil then
|
|
util.source_file(itte.confs.config)
|
|
-- Avoid duplicate load if config and servers are set to the same file
|
|
if (itte.servers == nil) or (itte.confs.servers ~= itte.conf.config) then
|
|
util.source_file(itte.confs.servers)
|
|
end
|
|
|
|
-- Prefix is set, use prefixed file names
|
|
else
|
|
util.source_file(itte.confs.prefix .. itte.confs.config)
|
|
util.source_file(itte.confs.prefix .. itte.confs.servers)
|
|
end
|
|
|
|
-- Check for server info
|
|
if itte.servers ~= nil then
|
|
itte.servers = itte_servers
|
|
else
|
|
util.debug(itte.config.debugs.svrs_not_found[1],
|
|
itte.config.debugs.svrs_not_found[2], itte.config.debug)
|
|
do return end
|
|
end
|
|
-- Update config with value overrides from itte_config
|
|
if itte_config ~= nil then
|
|
for k, v in pairs(itte_config) do
|
|
-- Second-level nested tables
|
|
if type(v) == "table" then
|
|
for kk, vv in pairs(v) do
|
|
if (type(vv) == "table") or (type(vv) == "function") then
|
|
itte.config[k] = v
|
|
else
|
|
itte.config[k][kk] = vv
|
|
end
|
|
end
|
|
else
|
|
itte.config[k] = v
|
|
end
|
|
end
|
|
end
|
|
if itte_handlers ~= nil then itte.handlers = itte_handlers end
|
|
if itte_admins ~= nil then itte.admins = itte_admins end
|
|
|
|
if reload then
|
|
-- Reconstruct the context tables
|
|
-- This only works if the server name has not changed between reloads.
|
|
for name, prop in pairs(itte.servers) do
|
|
cxt_old = itte.contexts[name]
|
|
itte.contexts[name] = prop
|
|
itte.contexts[name].name = name
|
|
itte.contexts[name].cmds = itte.get_commands(prop)
|
|
itte.contexts[name].con = cxt_old.con
|
|
itte.contexts[name].con:settimeout(itte.state.timeout)
|
|
|
|
-- Reconstruct the task coroutines
|
|
itte.add_tasks(itte.contexts[name])
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
itte.docs.get_commands = [[ (server_table)
|
|
Return a table of IRC commands to parse and their responses.
|
|
]]
|
|
function itte.get_commands(svr)
|
|
local default = {
|
|
-- CAP and SASL negotiation commands
|
|
cap_ls = {
|
|
-- No check, command is sent immediately after socket connect
|
|
-- before the USER/NICK greet
|
|
check = nil,
|
|
resp = "CAP LS 302",
|
|
},
|
|
cap_req = {
|
|
-- Escape the * in "CAP * LS ..."
|
|
check = "CAP(.*)LS(.*)sasl",
|
|
resp = "CAP REQ ",
|
|
},
|
|
auth_plain = {
|
|
-- The format of the CAP ACK varies among servers
|
|
-- e.g. "CAP [user] ACK ...", "CAP * ACK ..."
|
|
check = "CAP(.*)ACK(.*)sasl",
|
|
resp = "AUTHENTICATE PLAIN",
|
|
},
|
|
auth_pass = {
|
|
check = "AUTHENTICATE +",
|
|
resp = "AUTHENTICATE ",
|
|
},
|
|
cap_end = {
|
|
-- Text of the 903 success message varies among servers
|
|
-- e.g. "903 * :Authentication successful",
|
|
-- "903 [user] :SASL authentication successful"
|
|
check = "903(.*)successful",
|
|
resp = "CAP END",
|
|
},
|
|
-- Nickserv commands
|
|
ns_identify = {
|
|
check = "NickServ IDENTIFY",
|
|
resp = "NickServ IDENTIFY " .. svr.auth_user .. " " ..
|
|
tostring(svr.auth_pass),
|
|
},
|
|
ns_identify_pass = {
|
|
-- No response needed
|
|
check = "You are now identified",
|
|
resp = nil,
|
|
},
|
|
-- Main commands
|
|
join = {
|
|
-- Cannot join earlier on some servers if not registered
|
|
-- "MODE" seems to be a more reliable check
|
|
check = "MODE",
|
|
resp = "JOIN ",
|
|
},
|
|
names = {
|
|
-- Check and response are reversed
|
|
check = "353",
|
|
resp = "NAMES",
|
|
},
|
|
nick = {
|
|
check = "NOTICE",
|
|
resp = "NICK " .. svr.nick,
|
|
},
|
|
pass = {
|
|
-- No check, command is sent immediately after socket connect
|
|
-- before the USER/NICK greet
|
|
check = nil,
|
|
resp = "PASS " .. svr.auth_user .. ":" .. tostring(svr.auth_pass),
|
|
},
|
|
part = {
|
|
check = nil,
|
|
resp = "PART ",
|
|
},
|
|
ping = {
|
|
check = "PING ",
|
|
resp = "PONG ",
|
|
},
|
|
privmsg = {
|
|
check = "PRIVMSG(.*):" .. svr.code_prefix,
|
|
resp = "PRIVMSG ",
|
|
},
|
|
quit = {
|
|
check = nil,
|
|
resp = "QUIT :",
|
|
},
|
|
target_private = {
|
|
-- Like privmsg but targets bot nick in a direct message without a
|
|
-- service code.
|
|
-- Escape magic character "-"
|
|
check = "PRIVMSG(.*)" .. string.gsub(svr.nick, "-", "%%-") .. "(.*):",
|
|
resp = nil,
|
|
},
|
|
target_private_code = {
|
|
-- Like privmsg but targets bot nick in a direct message
|
|
-- Escape magic character "-"
|
|
check = "PRIVMSG(.*)" .. string.gsub(svr.nick, "-", "%%-") .. "(.*):" ..
|
|
string.gsub(svr.nick, "-", "%%-") .. "(.*)" .. svr.code_prefix,
|
|
resp = nil,
|
|
},
|
|
target_public = {
|
|
-- Like privmsg but targets bot nick in a channel without a service code.
|
|
-- Escape magic character "-"
|
|
check = "PRIVMSG(.*)#(.*):" .. string.gsub(svr.nick, "-", "%%-"),
|
|
resp = nil,
|
|
},
|
|
target_public_code = {
|
|
-- Like privmsg but targets bot nick in a channel
|
|
-- Escape magic character "-"
|
|
check = "PRIVMSG(.*)#(.*):" .. string.gsub(svr.nick, "-", "%%-") ..
|
|
"(.*)" .. svr.code_prefix,
|
|
resp = nil,
|
|
},
|
|
user = {
|
|
check = "NOTICE",
|
|
resp = "USER " .. svr.auth_user .. " 0 * " .. svr.auth_user,
|
|
},
|
|
}
|
|
|
|
return default
|
|
end
|
|
|
|
|
|
itte.docs.send_command = [[ (socket_obj, command_str [, redact_bool])
|
|
Format a IRC command string to send through a socket connection.
|
|
If `redact` is set to true, the debug output contents will be redacted.
|
|
]]
|
|
function itte.send_command(con, str, redact)
|
|
con:send(str .. "\r\n")
|
|
if redact == true then
|
|
util.debug(itte.config.debugs.send[1], itte.config.debugs.redact[1],
|
|
itte.config.debug)
|
|
else
|
|
util.debug(itte.config.debugs.send[1], str, itte.config.debug)
|
|
end
|
|
end
|
|
|
|
|
|
itte.docs.message = [[ (context_table, users_table, message_str)
|
|
Format and send a private message to a table of users.
|
|
]]
|
|
function itte.message(cxt, users, str)
|
|
for n = 1, #users do
|
|
itte.send_command(cxt.con, cxt.cmds.privmsg.resp .. users[n] .. " :" ..
|
|
str)
|
|
end
|
|
end
|
|
|
|
|
|
itte.docs.init_socket = [[ (name_str, server_table)
|
|
Initialise a socket connection to a server and return a context table.
|
|
]]
|
|
function itte.init_socket(name, svr)
|
|
-- Load server context
|
|
local context = svr
|
|
context.name = name
|
|
context.cmds = itte.get_commands(svr)
|
|
context.state = {
|
|
connected = false,
|
|
cap_greeted = false,
|
|
cap_ls = false,
|
|
cap_checked = false,
|
|
ns_checked = false,
|
|
authed = false,
|
|
}
|
|
|
|
-- Connect
|
|
local sock = require("socket")
|
|
local ssl = require("ssl")
|
|
context.con = sock.tcp()
|
|
local con_params = {
|
|
mode = "client",
|
|
protocol = "tlsv1_2",
|
|
verify = "none",
|
|
options = "all",
|
|
}
|
|
|
|
util.debug(itte.config.debugs.conn_svr[1],
|
|
string.gsub(itte.config.debugs.conn_svr[2],
|
|
"{{server}}", svr.host .. "/" .. svr.port), itte.config.debug)
|
|
context.con:connect(svr.host, svr.port)
|
|
context.con = ssl.wrap(context.con, con_params)
|
|
context.con:dohandshake()
|
|
-- Set a short timeout to be non-blocking
|
|
context.con:settimeout(itte.state.timeout)
|
|
|
|
-- Set context and global states
|
|
context.state.connected = true
|
|
itte.state.connected = true
|
|
|
|
return context
|
|
end
|
|
|
|
|
|
itte.docs.connect_server = [[ (name_str)
|
|
Connect to a server and join the channel(s) specified in the config.
|
|
]]
|
|
function itte.connect_server(name)
|
|
itte.contexts[name] = itte.init_socket(name, itte.servers[name])
|
|
|
|
-- For PASS-based authentication, send PASS before greeting the server
|
|
if itte.contexts[name].auth_type == "pass" then
|
|
itte.send_command(itte.contexts[name].con,
|
|
itte.contexts[name].cmds.pass.resp, true)
|
|
itte.contexts[name].state.authed = true
|
|
end
|
|
|
|
-- Greet the server in all auth scenarios except SASL, which greets the
|
|
-- server during CAP negotiation.
|
|
if (itte.contexts[name].auth_type ~= "sasl") then
|
|
itte.send_command(itte.contexts[name].con,
|
|
itte.contexts[name].cmds.user.resp)
|
|
itte.send_command(itte.contexts[name].con,
|
|
itte.contexts[name].cmds.nick.resp)
|
|
end
|
|
|
|
local joined_chans = false
|
|
while (itte.contexts[name].state.connected) and (not joined_chans) do
|
|
local data, status = itte.contexts[name].con:receive()
|
|
|
|
if itte.contexts[name].auth_type == "sasl" then
|
|
itte.negotiate_cap(itte.contexts[name], data)
|
|
|
|
elseif itte.contexts[name].auth_type == "nickserv" then
|
|
itte.auth_nickserv(itte.contexts[name], data)
|
|
end
|
|
|
|
-- Minimum requirements for joining channels: client has authenticated if
|
|
-- an auth type is set, or has received a NOTICE [nick] message during an
|
|
-- ident lookup.
|
|
if itte.contexts[name].state.authed then
|
|
itte.traverse_channels(itte.contexts[name], "join")
|
|
joined_chans = true
|
|
elseif (data ~= nil) then
|
|
if util.is_substr(data, itte.contexts[name].cmds.join.check)
|
|
then
|
|
itte.traverse_channels(itte.contexts[name], "join")
|
|
joined_chans = true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
itte.docs.disconnect_server = [[ (name_str)
|
|
Close a socket connection to a server.
|
|
]]
|
|
function itte.disconnect_server(name)
|
|
if itte.contexts[name] ~= nil then
|
|
util.debug(itte.config.debugs.dc_svr[1],
|
|
string.gsub(itte.config.debugs.dc_svr[2], "{{server}}", name),
|
|
itte.config.debug)
|
|
end
|
|
itte.contexts[name].con:close()
|
|
itte.contexts[name] = nil
|
|
|
|
-- Check if it is the last connection and trigger client exit
|
|
-- if no connections remain
|
|
if #util.table_keys(itte.contexts) == 0 then
|
|
util.debug(itte.config.debugs.exit[1], itte.config.debugs.exit[2],
|
|
itte.config.debug)
|
|
itte.state.connected = false
|
|
os.exit()
|
|
end
|
|
end
|
|
|
|
|
|
itte.docs.negotiate_cap = [[ (context_table, data_str)
|
|
Negotiate client capabilities and authenticate with SASL.
|
|
]]
|
|
function itte.negotiate_cap(cxt, str)
|
|
-- Some servers have a delay between ls and req
|
|
if (not cxt.state.cap_greeted) and (not cxt.state.cap_checked) and
|
|
(not cxt.state.cap_ls) then
|
|
itte.send_command(cxt.con, cxt.cmds.cap_ls.resp)
|
|
cxt.state.cap_ls = true
|
|
end
|
|
|
|
if (str ~= nil) then
|
|
util.debug(itte.config.debugs.cap[1], str, itte.config.debug)
|
|
|
|
-- Wait for server to respond with capabilities list before greeting
|
|
if (not cxt.state.cap_greeted) then
|
|
itte.send_command(cxt.con, cxt.cmds.user.resp)
|
|
itte.send_command(cxt.con, cxt.cmds.nick.resp)
|
|
cxt.state.cap_greeted = true
|
|
end
|
|
|
|
-- Request capabilities
|
|
if util.is_substr(str, cxt.cmds.cap_req.check) then
|
|
itte.send_command(cxt.con, cxt.cmds.cap_req.resp .. ":" ..
|
|
table.concat(cxt.cap, " "))
|
|
-- Authenticate with plain SASL
|
|
elseif (util.is_substr(str, cxt.cmds.auth_plain.check)) then
|
|
itte.send_command(cxt.con, cxt.cmds.auth_plain.resp)
|
|
|
|
elseif (util.is_substr(str, cxt.cmds.auth_pass.check)) then
|
|
local mime = require("mime")
|
|
-- Format of the string to encode: "user\0user\0password"
|
|
local sasl_pass, _ = mime.b64(cxt.auth_user .. "\0" .. cxt.auth_user ..
|
|
"\0" .. cxt.auth_pass)
|
|
itte.send_command(cxt.con, cxt.cmds.auth_pass.resp .. sasl_pass, true)
|
|
|
|
-- Look for auth success signal and end cap negotiation
|
|
elseif (util.is_substr(str, cxt.cmds.cap_end.check)) then
|
|
itte.send_command(cxt.con, cxt.cmds.cap_end.resp)
|
|
cxt.state.authed = true
|
|
cxt.state.cap_checked = true
|
|
if (cxt.state.cap_checked) and (not cxt.state.authed) and
|
|
(itte.config.notify_errors) and (cxt.admins ~= nil) then
|
|
itte.send_command(cxt.con, cxt.cmds.cap_end.resp)
|
|
itte.message(cxt, util.table_keys(cxt.admins),
|
|
itte.config.errors.sasl_auth_failed)
|
|
cxt.state.cap_checked = true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
itte.docs.auth_nickserv = [[ (context_table, data_str)
|
|
Authenticate with NickServ commands.
|
|
]]
|
|
function itte.auth_nickserv(cxt, str)
|
|
if str ~= nil then
|
|
util.debug(itte.config.debugs.nickserv[1], str, itte.config.debug)
|
|
|
|
if util.is_substr(str, cxt.cmds.ns_identify.check) then
|
|
itte.send_command(cxt.con, cxt.cmds.ns_identify.resp, true)
|
|
cxt.state.ns_checked = true
|
|
elseif util.is_substr(str, cxt.cmds.ns_identify_pass.check) then
|
|
cxt.state.authed = true
|
|
if (cxt.state.ns_checked) and (not cxt.state.authed) and
|
|
(itte.config.notify_errors) and (cxt.admins ~= nil) then
|
|
itte.message(cxt, util.table_keys(cxt.admins).
|
|
itte.config.errors.ns_auth_failed)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
itte.docs.is_pm = [[ (message_table)
|
|
Check whether a message is a private message. Return true if it is a private
|
|
message or false otherwise.
|
|
]]
|
|
function itte.is_pm(msg)
|
|
if util.is_substr(msg.reply_to, "#") then
|
|
return false
|
|
else
|
|
return true
|
|
end
|
|
end
|
|
|
|
|
|
itte.docs.is_admin = [[ (admins_table, message_table [, mode_str])
|
|
Check whether a user is an admin. Modes: global, name. If `mode` is
|
|
unspecified, check for the user in the name's admins table. Return true
|
|
if the user and password are in the given admin table, or false otherwise.
|
|
]]
|
|
function itte.is_admin(admins, msg, mode)
|
|
local in_admins = false
|
|
-- If no admin users set or no password provided
|
|
if (admins == nil) or (table.concat(msg.code_params) == "") then
|
|
do return false end
|
|
|
|
elseif msg.code_params ~= nil then
|
|
-- No password provided in global mode
|
|
if (mode == "global") and (#util.table_keys(msg.code_params) < 2) then
|
|
do return false end
|
|
elseif (mode == "global") then
|
|
-- For global admins, both user and password are supplied as the last two
|
|
-- values in the message parameters to keep the auth network-independent.
|
|
in_admins = util.is_entry(admins, msg.code_params[#msg.code_params - 1],
|
|
msg.code_params[#msg.code_params])
|
|
else
|
|
-- User is the IRC user, password is suppled in the message
|
|
in_admins = util.is_entry(admins, msg.sender,
|
|
msg.code_params[#msg.code_params])
|
|
end
|
|
|
|
-- Incorrect password provided
|
|
if not in_admins then
|
|
do return false end
|
|
else
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
itte.docs.is_allowed = [[ (context_table, message_table [, mode_str])
|
|
Check if user is allowed to run a handler. If the handler is for admins only,
|
|
check whether the request was sent in a private message and the user is an
|
|
admin (authorised). Set `mode` to "global" to check for global admins.
|
|
Returns true if the user is allowed or false otherwise.
|
|
]]
|
|
function itte.is_allowed(cxt, msg, mode)
|
|
if not util.has_key(itte.config.admin_handlers, msg.code) then
|
|
return true
|
|
else
|
|
local admins = cxt.admins
|
|
if mode == "global" then admins = itte.admins end
|
|
if (itte.is_pm(msg)) and itte.is_admin(admins, msg, mode) then
|
|
return true
|
|
else
|
|
itte.message(cxt, { msg.reply_to },
|
|
string.gsub(itte.config.errors.no_perm, "{{user}}", msg.sender))
|
|
return false
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
itte.docs.traverse_channels = [[ (context_table, mode_str, channels_table)
|
|
Given a context table, task mode and optional table of IRC channels, join or
|
|
part from the channels. Task modes: join, part. If no channel table is given,
|
|
it will join or leave the channels specified in the server configuration.
|
|
]]
|
|
function itte.traverse_channels(cxt, mode, chans)
|
|
local channels = cxt.channels
|
|
cxt.chan_meta = {}
|
|
if chans ~= nil then channels = chans end
|
|
|
|
if mode == "join" then
|
|
itte.send_command(cxt.con, cxt.cmds.join.resp .. table.concat(channels,
|
|
","))
|
|
-- Append to the channels table
|
|
for c = 1, #channels do
|
|
if not util.has_key(cxt.channels, channels[c]) then
|
|
table.insert(cxt.channels, channels[c])
|
|
cxt.chan_meta[channels[c]:sub(2)] = {}
|
|
end
|
|
end
|
|
|
|
elseif mode == "part" then
|
|
itte.send_command(cxt.con, cxt.cmds.part.resp .. table.concat(channels,
|
|
","))
|
|
-- Remove from the channels table
|
|
for c = 1, #channels do
|
|
if util.has_key(cxt.channels, channels[c]) then
|
|
table.remove(cxt.channels, util.find_key(cxt.channels, channels[c]))
|
|
cxt.chan_meta[channels[c]:sub(2)] = nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
itte.docs.parse_privmsg = [[ (context_table, data_str [, mode_str])
|
|
Given the context table, a PRIVMSG data string and mode, parse the string and
|
|
return an associative table of values. If "code" mode is specified, it will
|
|
check the body for a service code. If "target" mode is specified, it will
|
|
extract the message body excluding the bot nick, but will not look for a
|
|
service code. The default mode is "code" if no mode is specified.
|
|
]]
|
|
function itte.parse_privmsg(cxt, str, mode)
|
|
local msg = {}
|
|
-- Separator marks the start of the message body
|
|
body_sep, _ = string.find(str, ":", 2)
|
|
|
|
-- Message in the format `[body]` (private message) or `[nick]: [body]`
|
|
if mode == "target" then
|
|
msg = {
|
|
sender = string.sub(str, string.find(str, "!") + 2,
|
|
string.find(str, "@") - 1),
|
|
recipient = string.sub(str, string.find(str, "PRIVMSG") + 8,
|
|
string.find(str, " :") - 1),
|
|
reply_to = string.sub(str, string.find(str, "!") + 2,
|
|
string.find(str, "@") - 1),
|
|
body = string.sub(str, body_sep + 1),
|
|
}
|
|
-- Reply to sender by default (private message), or reply in a channel if
|
|
-- original message recipient is a channel.
|
|
if util.is_substr(msg.recipient, "#") then
|
|
msg.reply_to = msg.recipient
|
|
end
|
|
util.debug(itte.config.debugs.privmsg[1], "{ " ..
|
|
itte.config.debugs.privmsg[2] .. ": " .. msg.sender ..
|
|
", " .. itte.config.debugs.privmsg[3] .. ": " .. msg.recipient ..
|
|
", " .. itte.config.debugs.privmsg[4] .. ": " .. msg.reply_to ..
|
|
", " .. itte.config.debugs.privmsg[7] .. ": " .. msg.body ..
|
|
" }", itte.config.debug)
|
|
|
|
-- Message in the format `[code] [params]` or `[nick] [code] [params]`
|
|
else
|
|
-- Set the code prefix position variable to a number out of range of the
|
|
-- service code parsing buffer, then get the position. Check for the colon
|
|
-- separator at exactly the start of the message body, i.e. `[code]`, or
|
|
-- the space char for `[nick] [code]`.
|
|
local code_full = ""
|
|
local cp_pos = body_sep + string.len(cxt.nick) + 4
|
|
local co_sep, _ = string.find(str, ":" .. cxt.code_prefix)
|
|
if co_sep == body_sep then
|
|
cp_pos = string.find(str, ":" .. cxt.code_prefix)
|
|
elseif string.find(str, " " .. cxt.code_prefix) ~= nil then
|
|
cp_pos = string.find(str, " " .. cxt.code_prefix)
|
|
end
|
|
-- Only parse service code if it is near the start of the message body,
|
|
-- within a small buffer in position to allow some variation in style, e.g.
|
|
-- `[nick]: [code]`, `[nick] [code]`, etc.
|
|
if cp_pos <= body_sep + string.len(cxt.nick) + 3 then
|
|
code_full = string.sub(str, cp_pos + 1 + string.len(cxt.code_prefix))
|
|
end
|
|
msg = {
|
|
sender = string.sub(str, string.find(str, "!") + 2,
|
|
string.find(str, "@") - 1),
|
|
recipient = string.sub(str, string.find(str, "PRIVMSG") + 8,
|
|
string.find(str, " :") - 1),
|
|
reply_to = string.sub(str, string.find(str, "!") + 2,
|
|
string.find(str, "@") - 1),
|
|
body = string.sub(str, body_sep + 1),
|
|
}
|
|
if util.is_substr(code_full, " ") then
|
|
msg.code = string.sub(code_full, 0, string.find(code_full, " ") - 1)
|
|
msg.code_params = util.split_str(string.sub(code_full,
|
|
string.find(code_full, " ") + 1))
|
|
else
|
|
msg.code = code_full
|
|
msg.code_params = {}
|
|
end
|
|
-- Reply to sender by default (private message), or reply in a channel if
|
|
-- original message recipient is a channel.
|
|
if util.is_substr(msg.recipient, "#") then
|
|
msg.reply_to = msg.recipient
|
|
end
|
|
-- Redact debug output for admin-only handlers to avoid logging passwords.
|
|
local body = msg.body
|
|
local code_params = table.concat(msg.code_params, " ")
|
|
if util.has_key(itte.config.admin_handlers, msg.code) then
|
|
body = itte.config.debugs.redact[1]
|
|
code_params = itte.config.debugs.redact[1]
|
|
end
|
|
util.debug(itte.config.debugs.privmsg[1], "{ " ..
|
|
itte.config.debugs.privmsg[2] .. ": " .. msg.sender ..
|
|
", " .. itte.config.debugs.privmsg[3] .. ": " .. msg.recipient ..
|
|
", " .. itte.config.debugs.privmsg[4] .. ": " .. msg.reply_to ..
|
|
", " .. itte.config.debugs.privmsg[5] .. ": " .. msg.code ..
|
|
", " .. itte.config.debugs.privmsg[6] .. ": " .. code_params ..
|
|
", " .. itte.config.debugs.privmsg[7] .. ": " .. body ..
|
|
" }", itte.config.debug)
|
|
end
|
|
return msg
|
|
end
|
|
|
|
|
|
itte.docs.parse_names = [[ (context_table, data_str)
|
|
Given the context table and a NAMES data string, parse the string and
|
|
return the channel name and a table of users.
|
|
]]
|
|
function itte.parse_names(cxt, str)
|
|
-- Separator marks the start of the message body
|
|
body_sep, _ = string.find(str, ":", 2)
|
|
local channel = string.sub(str, string.find(str, "#"),
|
|
string.find(str, ":", 2) - 2)
|
|
local names = util.split_str(string.sub(str, body_sep + 1))
|
|
util.debug(itte.config.debugs.names[1],
|
|
itte.config.debugs.names[2] .. ": " .. channel ..
|
|
", " .. itte.config.debugs.names[3] .. ": { " .. table.concat(names, ", ")
|
|
.. " }", itte.config.debug)
|
|
return channel, names
|
|
end
|
|
|
|
|
|
itte.docs.get_users = [[ (context_table, channel_str)
|
|
Given the context table and a channel, return a table of users in the
|
|
channel.
|
|
]]
|
|
function itte.get_users(cxt, str)
|
|
local chan = str
|
|
if string.find(chan, "#") == 1 then chan = str:sub(2) end
|
|
if not util.has_key(cxt.chan_meta, chan) then
|
|
do return end
|
|
end
|
|
-- Query server for the latest list of names
|
|
itte.send_command(cxt.con, cxt.cmds.names.resp .. " #" .. chan)
|
|
return cxt.chan_meta[chan].users
|
|
end
|
|
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Service code handlers
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
itte.docs._h.connect = [[ (context_table, message_table)
|
|
Connect to a server.
|
|
]]
|
|
function itte._h.connect(cxt, msg)
|
|
if not itte.is_allowed(cxt, msg, "global") then
|
|
do return end
|
|
end
|
|
|
|
if #util.table_keys(msg.code_params) > 2 then
|
|
-- Look up name and connect to the server
|
|
for s = 1, #msg.code_params - 2 do
|
|
if util.has_key(itte.servers, msg.code_params[s]) then
|
|
itte.connect_server(msg.code_params[s])
|
|
local succ_str = string.gsub(itte.config.messages.connect_success,
|
|
"{{server}}", msg.code_params[s])
|
|
itte.message(cxt, { msg.reply_to }, succ_str)
|
|
else
|
|
local err_str = string.gsub(itte.config.errors.invalid_name,
|
|
"{{server}}", msg.code_params[s])
|
|
itte.message(cxt, { msg.reply_to }, err_str)
|
|
end
|
|
end
|
|
else
|
|
local err_str = string.gsub(itte.config.errors.invalid_name,
|
|
"{{server}}", msg.code_params[1])
|
|
itte.message(cxt, { msg.reply_to }, err_str)
|
|
end
|
|
end
|
|
|
|
|
|
itte.docs._h.quit = [[ (context_table, message_table)
|
|
Disconnect from a server or multiple servers.
|
|
]]
|
|
function itte._h.quit(cxt, msg)
|
|
if not itte.is_allowed(cxt, msg, "global") then
|
|
do return end
|
|
end
|
|
|
|
-- If no server is specified, quit the server name where the message
|
|
-- originated
|
|
if #util.table_keys(msg.code_params) == 2 then
|
|
itte.send_command(cxt.con, cxt.cmds.quit.resp .. itte.config.messages.quit)
|
|
else
|
|
-- Look up name and send quit message to the specified server(s)
|
|
for s = 1, #msg.code_params - 2 do
|
|
if util.has_key(itte.contexts, msg.code_params[s]) then
|
|
itte.send_command(itte.contexts[msg.code_params[s]].con,
|
|
itte.contexts[msg.code_params[s]].cmds.quit.resp ..
|
|
itte.config.messages.quit)
|
|
local succ_str = string.gsub(itte.config.messages.quit_success,
|
|
"{{server}}", msg.code_params[s])
|
|
itte.message(cxt, { msg.reply_to }, succ_str)
|
|
else
|
|
local err_str = string.gsub(itte.config.errors.invalid_name,
|
|
"{{server}}", msg.code_params[s])
|
|
itte.message(cxt, { msg.reply_to }, err_str)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
itte.docs._h.reload = [[ (context_table, message_table)
|
|
Reload the server config.
|
|
]]
|
|
function itte._h.reload(cxt, msg)
|
|
if not itte.is_allowed(cxt, msg, "global") then
|
|
do return end
|
|
end
|
|
|
|
itte.get_config(true)
|
|
itte.message(cxt, { msg.reply_to }, itte.config.messages.reload)
|
|
end
|
|
|
|
|
|
itte.docs._h.servers = [[ (context_table, message_table)
|
|
List known servers.
|
|
]]
|
|
function itte._h.servers(cxt, msg)
|
|
if not itte.is_allowed(cxt, msg, "global") then
|
|
do return end
|
|
end
|
|
|
|
if itte.contexts ~= nil then
|
|
local conn_svrs_str = string.gsub(itte.config.messages.list_conn_servers,
|
|
"{{servers}}", table.concat(util.table_keys(itte.contexts), ", "))
|
|
itte.message(cxt, { msg.reply_to }, conn_svrs_str)
|
|
end
|
|
if itte.servers ~= nil then
|
|
local all_svrs_str = string.gsub(itte.config.messages.list_all_servers,
|
|
"{{servers}}", table.concat(util.table_keys(itte.servers), ", "))
|
|
itte.message(cxt, { msg.reply_to }, all_svrs_str)
|
|
end
|
|
end
|
|
|
|
|
|
itte.docs._h.join = [[ (context_table, message_table)
|
|
Join specified channels on the current server.
|
|
]]
|
|
function itte._h.join(cxt, msg)
|
|
if not itte.is_allowed(cxt, msg) then
|
|
do return end
|
|
end
|
|
|
|
if msg.code_params ~= {} then
|
|
-- Trim the last parameter, which is a password and not a channel
|
|
table.remove(msg.code_params)
|
|
itte.traverse_channels(cxt, "join", msg.code_params)
|
|
itte.message(cxt, { msg.reply_to }, itte.config.messages.join)
|
|
end
|
|
end
|
|
|
|
|
|
itte.docs._h.part = [[ (context_table, message_table)
|
|
Leave specified channels on the current server.
|
|
]]
|
|
function itte._h.part(cxt, msg)
|
|
if not itte.is_allowed(cxt, msg) then
|
|
do return end
|
|
end
|
|
|
|
if msg.code_params ~= {} then
|
|
-- Trim the last parameter, which is a password and not a channel
|
|
table.remove(msg.code_params)
|
|
itte.traverse_channels(cxt, "part", msg.code_params)
|
|
itte.message(cxt, { msg.reply_to }, itte.config.messages.part)
|
|
end
|
|
end
|
|
|
|
|
|
itte.docs._h.help = [[ (context_table, message_table)
|
|
Send a help message listing available service codes.
|
|
]]
|
|
function itte._h.help(cxt, msg)
|
|
if not itte.is_allowed(cxt, msg) then
|
|
do return end
|
|
end
|
|
|
|
local custom_h = util.table_keys(itte.handlers)
|
|
-- Remove target and task handlers
|
|
for h = 1, #custom_h do
|
|
if (string.find(custom_h[h], itte.config.target_handler) == 1) or
|
|
(string.find(custom_h[h], itte.config.task_handler_prefix) == 1) then
|
|
custom_h[h] = nil
|
|
end
|
|
end
|
|
local codes = cxt.code_prefix .. table.concat(custom_h, ", " ..
|
|
cxt.code_prefix)
|
|
-- Core service codes are shown only to admins
|
|
if itte.is_admin(cxt.admins, msg) then
|
|
local core_h = util.table_keys(itte._h)
|
|
codes = cxt.code_prefix .. table.concat(core_h, ", " .. cxt.code_prefix) ..
|
|
", " .. cxt.code_prefix .. table.concat(custom_h, ", " ..
|
|
cxt.code_prefix)
|
|
end
|
|
local help_str = string.gsub(itte.config.messages.help, "{{codes}}", codes)
|
|
itte.message(cxt, { msg.reply_to }, help_str)
|
|
end
|
|
|
|
|
|
itte.docs._h.channels = [[ (context_table, message_table)
|
|
List the known channels for the current server.
|
|
]]
|
|
function itte._h.channels(cxt, msg)
|
|
if not itte.is_allowed(cxt, msg) then
|
|
do return end
|
|
end
|
|
|
|
if cxt.channels ~= nil then
|
|
local channels_str = string.gsub(itte.config.messages.list_channels,
|
|
"{{channels}}", table.concat(cxt.channels, ", "))
|
|
itte.message(cxt, { msg.reply_to }, channels_str)
|
|
end
|
|
end
|
|
|
|
|
|
itte.docs._h.ping = [[ (context_table, message_table)
|
|
Send a "pong" message.
|
|
]]
|
|
function itte._h.ping(cxt, msg)
|
|
if not itte.is_allowed(cxt, msg) then
|
|
do return end
|
|
end
|
|
|
|
itte.message(cxt, { msg.reply_to }, itte.config.messages.ping)
|
|
end
|
|
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Service code and taskd task mapping
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
itte.docs.listen = [[ (name_str, data_str)
|
|
Parse the socket data string and trigger a response to the server if the
|
|
string matches preset patterns.
|
|
]]
|
|
function itte.listen(name, str)
|
|
local cxt = itte.contexts[name]
|
|
|
|
-- Respond to server ping
|
|
if string.find(str, cxt.cmds.ping.check) == 1 then
|
|
util.debug(itte.config.debugs.listen[1], str, itte.config.debug)
|
|
itte.send_command(cxt.con, string.gsub(str, cxt.cmds.ping.check,
|
|
cxt.cmds.ping.resp))
|
|
|
|
-- Update channel names list
|
|
elseif string.find(str, ":" .. cxt.host .. " " .. cxt.cmds.names.check) == 1
|
|
then
|
|
util.debug(itte.config.debugs.listen[1], str, itte.config.debug)
|
|
local channel, names = itte.parse_names(cxt, str)
|
|
cxt.chan_meta[channel:sub(2)] = { users = names }
|
|
|
|
-- Respond to service code, checking for `[code]` and `[nick]: [code]`.
|
|
-- Code prefix cannot be the last character in the data string.
|
|
elseif (util.is_substr(str, cxt.cmds.privmsg.check) or
|
|
(util.is_substr(str, cxt.cmds.target_public_code.check)) or
|
|
(util.is_substr(str, cxt.cmds.target_private_code.check))) and
|
|
(str:sub(string.len(str)) ~= cxt.code_prefix) then
|
|
local msg = itte.parse_privmsg(cxt, str)
|
|
-- Check for the service in the admin handlers table and redact the debug
|
|
-- output for admin handlers.
|
|
if util.has_key(itte.config.admin_handlers, msg.code) then
|
|
util.debug(itte.config.debugs.listen[1], itte.config.debugs.redact[1],
|
|
itte.config.debug)
|
|
else
|
|
util.debug(itte.config.debugs.listen[1], str, itte.config.debug)
|
|
end
|
|
|
|
-- Ignore calls to the target and task handlers
|
|
if (string.find(msg.code, itte.config.target_handler) == 1) or
|
|
(string.find(msg.code, itte.config.task_handler_prefix) == 1) then
|
|
do return end
|
|
-- Check for the service code in the functions table before attempting to
|
|
-- call the function.
|
|
elseif util.has_key(itte._h, msg.code) then
|
|
itte._h[msg.code](cxt, msg)
|
|
-- Check the handlers table
|
|
elseif util.has_key(itte.handlers, msg.code) then
|
|
itte.handlers[msg.code](cxt, msg)
|
|
-- If there is no service code found near the start of the message body,
|
|
-- respond as a direct address and pass the message to the target handler
|
|
-- if it exists.
|
|
elseif (util.has_key(itte.handlers, itte.config.target_handler)) and
|
|
(msg.code == "") then
|
|
itte.handlers[itte.config.target_handler](cxt, msg)
|
|
else
|
|
-- Only hint with unknown code error in private messages as there may be
|
|
-- prefix collision in channels.
|
|
if not util.is_substr(msg.reply_to, "#") then
|
|
itte.message(cxt, { msg.reply_to }, itte.config.errors.unknown_code)
|
|
end
|
|
end
|
|
|
|
-- Respond to direct address in a channel
|
|
elseif (util.is_substr(str, cxt.cmds.target_public.check)) or
|
|
(util.is_substr(str, cxt.cmds.target_private.check)) then
|
|
util.debug(itte.config.debugs.listen[1], str, itte.config.debug)
|
|
local msg = itte.parse_privmsg(cxt, str, "target")
|
|
|
|
-- Check for the target handler function before attempting to call it.
|
|
if util.has_key(itte.handlers, itte.config.target_handler) then
|
|
itte.handlers[itte.config.target_handler](cxt, msg)
|
|
end
|
|
|
|
-- Output to stdout anyway if debug is enabled
|
|
else
|
|
util.debug(itte.config.debugs.listen[1], str, itte.config.debug)
|
|
end
|
|
end
|
|
|
|
|
|
itte.docs.add_listener = [[ (context_table)
|
|
Monitor a socket connection and pass the data received for parsing. If the
|
|
connection is closed by the server and was the only server connection,
|
|
set `state.connected` to false to activate a client exit.
|
|
]]
|
|
function itte.add_listener(cxt)
|
|
local data, status = cxt.con:receive()
|
|
if data ~= nil then
|
|
itte.listen(cxt.name, data)
|
|
|
|
elseif status == "closed" then
|
|
itte.disconnect_server(cxt.name)
|
|
end
|
|
end
|
|
|
|
|
|
itte.docs.add_tasks = [[ (context_table)
|
|
Create coroutines for tasks.
|
|
]]
|
|
function itte.add_tasks(cxt)
|
|
-- Exit function if there are no tasks for the context
|
|
if (cxt.tasks == nil) or (cxt.tasks == {}) then
|
|
do return end
|
|
end
|
|
|
|
-- Construct a task coroutine and call its handler function when resumed
|
|
local co = function(name, task)
|
|
if util.has_key(itte.handlers, task.handler) then
|
|
local ts = os.date("%Y-%m-%d %H:%M:%S", os.time())
|
|
local task_str = string.gsub(itte.config.debugs.task_activated[2],
|
|
"{{name}}", name)
|
|
util.debug(itte.config.debugs.task_activated[1], task_str,
|
|
itte.config.debug)
|
|
itte.handlers[task.handler](cxt, task)
|
|
-- Suspend coroutine to be reactivated later
|
|
coroutine.yield(co)
|
|
task.done = true
|
|
end
|
|
end
|
|
|
|
-- Initialise task coroutine
|
|
for name, task in pairs(cxt.tasks) do
|
|
task.co = coroutine.create(co)
|
|
task.done = false
|
|
util.debug(itte.config.debugs.task_added[1],
|
|
string.gsub(itte.config.debugs.task_added[2], "{{name}}", name),
|
|
itte.config.debug)
|
|
end
|
|
end
|
|
|
|
|
|
itte.docs.check_tasks = [[ (context_table)
|
|
Compare task times to the current time and resume a task's coroutine if
|
|
the task interval or time match the current time.
|
|
]]
|
|
function itte.check_tasks(cxt)
|
|
-- Exit function if there are no tasks for the context
|
|
if (cxt.tasks == nil) or (cxt.tasks == {}) then
|
|
do return end
|
|
end
|
|
|
|
local dt = util.split_str(os.date("%w %Y %m %d %H %M", os.time()))
|
|
-- Support preset minute intervals
|
|
local interval_min = { 2, 5, 10, 15, 20, 30 }
|
|
for name, task in pairs(cxt.tasks) do
|
|
if task.time == nil then task.time = "00:00" end
|
|
local task_min = string.sub(task.time, string.find(task.time, ":") + 1,
|
|
string.find(task.time, ":") + 2)
|
|
if (util.has_key(interval_min, tonumber(task.interval:sub(1, -2))) and
|
|
tonumber(dt[6]) % tonumber(task.interval:sub(1, -2)) == 0) or
|
|
(task.interval == "hourly" and task_min == dt[6]) or
|
|
(task.interval == "daily" and task.time == dt[5] .. ":" .. dt[6]) or
|
|
(task.interval == "weekly" and task.weekday == dt[1] and
|
|
task.time == dt[5] .. ":" .. dt[6]) or
|
|
(task.interval == "monthly" and task.day == dt[4] and
|
|
task.time == dt[5] .. ":" .. dt[6])
|
|
then
|
|
|
|
-- Activate task if not already done in the current interval
|
|
if not task.done then
|
|
coroutine.resume(task.co, name, task)
|
|
|
|
elseif (task.done) and (coroutine.status(task.co) == "dead") then
|
|
-- Reconstruct the task coroutine after it has been done
|
|
local co = function(name, task)
|
|
if util.has_key(itte.handlers, task.handler) then
|
|
local ts = os.date("%Y-%m-%d %H:%M:%S", os.time())
|
|
local task_str = string.gsub(itte.config.debugs.task_activated[2],
|
|
"{{name}}", name)
|
|
util.debug(itte.config.debugs.task_activated[1], task_str,
|
|
itte.config.debug)
|
|
itte.handlers[task.handler](cxt, task)
|
|
-- Suspend coroutine to be reactivated later
|
|
coroutine.yield(co)
|
|
task.done = true
|
|
end
|
|
end
|
|
task.co = coroutine.create(co)
|
|
end
|
|
|
|
else
|
|
-- Reset the done flag after running task
|
|
task.done = false
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Runtime
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
itte.docs.run = [[ ()
|
|
Run the client.
|
|
]]
|
|
function itte.run()
|
|
itte.get_config()
|
|
|
|
for name, _ in pairs(itte.servers) do
|
|
itte.connect_server(name)
|
|
end
|
|
|
|
if itte.state.connected then
|
|
for _, cxt in pairs(itte.contexts) do
|
|
itte.add_tasks(cxt)
|
|
end
|
|
end
|
|
|
|
while itte.state.connected do
|
|
for _, cxt in pairs(itte.contexts) do
|
|
itte.add_listener(cxt)
|
|
itte.check_tasks(cxt)
|
|
end
|
|
|
|
end
|
|
end
|
|
|
|
|
|
return itte
|