itte/itte.lua

697 lines
21 KiB
Lua
Raw Normal View History

2022-03-14 06:57:49 +00:00
-- ---------------------------------------------------------------------------
-- 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,
notify_errors = false,
messages = {
help = "Service codes available: {{codes}}",
join = "We're in.",
part = "The bot is greater than the sum of its parts.",
ping = "Pong!",
quit = "Goodbye.",
reload = "Splines recticulated.",
},
errors = {
no_perm = "I'm sorry, {{user}}. I'm afraid I can't do that.",
notify_no_perm = "{{user}} attempted to use service code: {{code}}",
ns_auth_failed = "Error: Nickserv identification failed.",
sasl_auth_failed = "Error: SASL authentication failed.",
unknown_code = "I don't understand what you just said.",
},
}
-- Store server info
itte.servers = {}
-- Store custom handlers
itte.handlers = {}
-- Store global admins
itte.admins = {}
2022-03-14 06:57:49 +00:00
-- 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,
}
itte.contexts = {}
2022-03-14 06:57:49 +00:00
-- ---------------------------------------------------------------------------
-- 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 = [[ ()
Load the config file.
]]
function itte.get_config(reload)
2022-03-14 06:57:49 +00:00
-- 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("config", "Error: servers not found.", itte.config.debug)
do return end
end
if itte_config ~= nil then itte.config = itte_config end
if itte_handlers ~= nil then itte.handlers = itte_handlers end
if itte_admins ~= nil then itte.admins = itte_admins end
-- If reloading, reconstruct the context tables.
-- This only works if the instance name has not changed between reloads.
if reload then
for instance, prop in pairs(itte.servers) do
cxt_old = itte.contexts[instance]
itte.contexts[instance] = prop
itte.contexts[instance].name = instance
itte.contexts[instance].cmds = itte.get_commands(prop)
itte.contexts[instance].con = cxt_old.con
itte.contexts[instance].con:settimeout(0.5)
end
end
2022-03-14 06:57:49 +00:00
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",
2022-03-14 06:57:49 +00:00
resp = "JOIN ",
},
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,
2022-03-14 06:57:49 +00:00
resp = "PRIVMSG ",
},
quit = {
check = nil,
resp = "QUIT :",
},
user = {
check = "NOTICE",
resp = "USER " .. svr.auth_user .. " 0 * " .. svr.auth_user,
},
}
return default
end
itte.docs.send_command = [[ (context_table, cmd_str)
Format a IRC command string to send through a socket connection.
]]
function itte.send_command(con, str)
con:send(str .. "\r\n")
util.debug("send", str, itte.config.debug)
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.connect_server = [[ (name_str, server_table)
2022-03-14 06:57:49 +00:00
Initialise a socket connection to a server and return a context table.
]]
function itte.connect_server(name, svr)
2022-03-14 06:57:49 +00:00
-- Load server context
local context = svr
context.name = name
2022-03-14 06:57:49 +00:00
context.cmds = itte.get_commands(svr)
context.state = {
connected = false,
2022-03-14 06:57:49 +00:00
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",
}
2022-03-14 06:57:49 +00:00
util.debug("conn", "Connecting to " .. 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(0.1)
-- Set context and global states
2022-03-14 06:57:49 +00:00
context.state.connected = true
itte.state.connected = true
2022-03-14 06:57:49 +00:00
return context
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("conn", "Connection to " .. name .. " closed.",
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("conn", "Exiting ...", itte.config.debug)
itte.state.connected = false
end
end
2022-03-14 06:57:49 +00:00
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("auth", 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)
-- 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
2022-03-14 06:57:49 +00:00
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("auth", 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)
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
2022-03-14 06:57:49 +00:00
itte.message(cxt, util.table_keys(cxt.admins).
itte.config.errors.ns_auth_failed)
end
end
end
end
itte.docs.is_admin = [[ (admins_table, message_table, mode_str)
Check whether a user is an admin. Modes: global, instance. If `mode` is
unspecified, check for the user in the instance's admins table. Return true
if the user and password are in the given admin table, or false otherwise.
2022-03-14 06:57:49 +00:00
]]
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
2022-03-14 06:57:49 +00:00
do return false end
-- Incorrect password provided
elseif msg.code_params ~= nil then
if mode == "global" then
-- For global admins, both user and password are supplied in the message
-- to keep the auth network-independent
in_admins = util.is_entry(admins, 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
2022-03-14 06:57:49 +00:00
if not in_admins then
do return false end
else
return true
end
end
end
itte.docs.notify_no_perms = [[ (context_table, message_table)
Respond to an attempt to use service code by a non-admin user and send a
message to the admins if `notify_errors` is enabled in the config.
]]
function itte.notify_no_perms(cxt, msg)
itte.message(cxt, { msg.reply_to }, string.gsub(itte.config.errors.no_perm,
"{{user}}", msg.sender))
if (itte.config.notify_errors) and (cxt.admins ~= nil) then
2022-03-14 06:57:49 +00:00
local notify_msg = string.gsub(itte.config.errors.notify_no_perm,
"{{user}}", msg.sender)
notify_msg = string.gsub(notify_msg, "{{code}}", msg.code)
itte.message(cxt, util.table_keys(cxt.admins), notify_msg)
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
if chans ~= nil then channels = chans end
if mode == "join" then
itte.send_command(cxt.con, cxt.cmds.join.resp .. table.concat(channels,
","))
elseif mode == "part" then
itte.send_command(cxt.con, cxt.cmds.part.resp .. table.concat(channels,
","))
end
end
itte.docs.parse_privmsg = [[ (context_table, data_str)
Given the context table and a PRIVMSG data string, parse the string and
return an associative table of values.
]]
function itte.parse_privmsg(cxt, str)
local code_full = ""
-- Separator marks the start of the message body
body_sep, _ = string.find(str, ":", 2)
-- Service code found with specified prefix
if util.is_substr(str, ":" .. cxt.code_prefix) then
code_full = string.sub(str, string.find(str, ":" ..
cxt.code_prefix) + 1 + string.len(cxt.code_prefix))
end
2022-03-14 06:57:49 +00:00
local 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, ":" .. cxt.code_prefix) - 2),
2022-03-14 06:57:49 +00:00
reply_to = string.sub(str, string.find(str, "!") + 2,
string.find(str, "@") - 1),
body = string.sub(str, body_sep + 1),
2022-03-14 06:57:49 +00:00
}
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
util.debug("privmsg", "{ " ..
"sender: " .. msg.sender ..
", recipient: " .. msg.recipient ..
", reply_to: " .. msg.reply_to ..
", code: " .. msg.code ..
", code_params: " .. table.concat(msg.code_params, " ") ..
", body: " .. msg.body ..
2022-03-14 06:57:49 +00:00
" }", itte.config.debug)
return msg
end
-- ---------------------------------------------------------------------------
-- Service code handlers
-- ---------------------------------------------------------------------------
itte.docs._h.help = [[ (context_table, message_table)
Send a help message listing available service codes.
]]
-- Send a help message listing available service codes.
function itte._h.help(cxt, msg)
local custom_h = util.table_keys(itte.handlers)
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
2022-03-14 06:57:49 +00:00
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_msg = string.gsub(itte.config.messages.help, "{{codes}}", codes)
itte.message(cxt, { msg.reply_to }, help_msg)
end
itte.docs._h.join = [[ (context_table, message_table)
Join specified channels.
]]
function itte._h.join(cxt, msg)
if not itte.is_admin(cxt.admins, msg) then
2022-03-14 06:57:49 +00:00
itte.notify_no_perms(cxt, msg)
do return end
end
if msg.code_params ~= {} then
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.
]]
function itte._h.part(cxt, msg)
if not itte.is_admin(cxt.admins, msg) then
2022-03-14 06:57:49 +00:00
itte.notify_no_perms(cxt, msg)
do return end
end
if msg.code_params ~= {} then
itte.traverse_channels(cxt, "part", msg.code_params)
itte.message(cxt, { msg.reply_to }, itte.config.messages.part)
end
end
itte.docs._h.ping = [[ (context_table, message_table)
Send a "pong" message.
]]
function itte._h.ping(cxt, msg)
itte.message(cxt, { msg.reply_to }, itte.config.messages.ping)
end
itte.docs._h.quit = [[ (context_table, message_table)
Disconnect from the server.
]]
function itte._h.quit(cxt, msg)
if not itte.is_admin(itte.admins, msg, "global") then
2022-03-14 06:57:49 +00:00
itte.notify_no_perms(cxt, msg)
do return end
end
itte.send_command(cxt.con, cxt.cmds.quit.resp .. itte.config.messages.quit)
end
itte.docs._h.reload = [[ (context_table, message_table)
Reload the server config.
]]
function itte._h.reload(cxt, msg)
if not itte.is_admin(itte.admins, msg, "global") then
2022-03-14 06:57:49 +00:00
itte.notify_no_perms(cxt, msg)
do return end
end
itte.get_config(true)
2022-03-14 06:57:49 +00:00
itte.message(cxt, { msg.reply_to }, itte.config.messages.reload)
end
-- ---------------------------------------------------------------------------
-- Service code mapping and runtime
-- ---------------------------------------------------------------------------
itte.docs.listen = [[ (name_str, data_str)
2022-03-14 06:57:49 +00:00
Parse the socket data string and trigger a response to the server if the
string matches preset patterns.
]]
function itte.listen(name, str)
2022-03-14 06:57:49 +00:00
util.debug("listen", str, itte.config.debug)
local cxt = itte.contexts[name]
2022-03-14 06:57:49 +00:00
-- Respond to server ping
if util.is_substr(str, cxt.cmds.ping.check) then
itte.send_command(cxt.con, string.gsub(str, cxt.cmds.ping.check,
cxt.cmds.ping.resp))
-- Respond to service code
elseif util.is_substr(str, cxt.cmds.privmsg.check) then
local msg = itte.parse_privmsg(cxt, str)
-- Check for the service code in the functions table before attempting to
-- call the function.
if 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)
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
2022-03-14 06:57:49 +00:00
end
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)
2022-03-14 06:57:49 +00:00
elseif status == "closed" then
itte.disconnect_server(cxt.name)
2022-03-14 06:57:49 +00:00
end
end
itte.docs.run = [[ ()
Run the client.
]]
function itte.run()
itte.get_config()
-- Connect to servers and get context with socket ref for each server
itte.contexts = {}
2022-03-14 06:57:49 +00:00
for instance, prop in pairs(itte.servers) do
itte.contexts[instance] = itte.connect_server(instance, prop)
2022-03-14 06:57:49 +00:00
-- For PASS-based authentication, send PASS before greeting the server
if itte.contexts[instance].auth_type == "pass" then
itte.send_command(itte.contexts[instance].con,
itte.contexts[instance].cmds.pass.resp)
itte.contexts[instance].state.authed = true
2022-03-14 06:57:49 +00:00
end
-- Greet the server in all auth scenarios except SASL, which greets the
-- server during CAP negotiation.
if (itte.contexts[instance].auth_type ~= "sasl") then
itte.send_command(itte.contexts[instance].con,
itte.contexts[instance].cmds.user.resp)
itte.send_command(itte.contexts[instance].con,
itte.contexts[instance].cmds.nick.resp)
2022-03-14 06:57:49 +00:00
end
local joined_chans = false
while (itte.contexts[instance].state.connected) and (not joined_chans) do
local data, status = itte.contexts[instance].con:receive()
2022-03-14 06:57:49 +00:00
if itte.contexts[instance].auth_type == "sasl" then
itte.negotiate_cap(itte.contexts[instance], data)
2022-03-14 06:57:49 +00:00
elseif itte.contexts[instance].auth_type == "nickserv" then
itte.auth_nickserv(itte.contexts[instance], data)
2022-03-14 06:57:49 +00:00
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[instance].state.authed then
itte.traverse_channels(itte.contexts[instance], "join")
2022-03-14 06:57:49 +00:00
joined_chans = true
elseif (data ~= nil) then
if util.is_substr(data, itte.contexts[instance].cmds.join.check)
then
itte.traverse_channels(itte.contexts[instance], "join")
2022-03-14 06:57:49 +00:00
joined_chans = true
end
end
end
end
-- Add listeners
while itte.state.connected do
for instance, cxt in pairs(itte.contexts) do
2022-03-14 06:57:49 +00:00
itte.add_listener(cxt)
end
end
end
return itte