-- --------------------------------------------------------------------------- -- 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 = {} -- 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 = {} -- --------------------------------------------------------------------------- -- 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) -- 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 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 ", }, 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 :", }, 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) Initialise a socket connection to a server and return a context table. ]] function itte.connect_server(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("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 context.state.connected = true itte.state.connected = true 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 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 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 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. ]] 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 -- 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 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 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 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), 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 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 .. " }", 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 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 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 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 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 itte.notify_no_perms(cxt, msg) do return end end itte.get_config(true) itte.message(cxt, { msg.reply_to }, itte.config.messages.reload) end -- --------------------------------------------------------------------------- -- Service code mapping and runtime -- --------------------------------------------------------------------------- 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) util.debug("listen", str, itte.config.debug) local cxt = itte.contexts[name] -- 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 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) elseif status == "closed" then itte.disconnect_server(cxt.name) 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 = {} for instance, prop in pairs(itte.servers) do itte.contexts[instance] = itte.connect_server(instance, prop) -- 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 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) end local joined_chans = false while (itte.contexts[instance].state.connected) and (not joined_chans) do local data, status = itte.contexts[instance].con:receive() if itte.contexts[instance].auth_type == "sasl" then itte.negotiate_cap(itte.contexts[instance], data) elseif itte.contexts[instance].auth_type == "nickserv" then itte.auth_nickserv(itte.contexts[instance], 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[instance].state.authed then itte.traverse_channels(itte.contexts[instance], "join") 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") joined_chans = true end end end end -- Add listeners while itte.state.connected do for instance, cxt in pairs(itte.contexts) do itte.add_listener(cxt) end end end return itte