-- --------------------------------------------------------------------------- -- 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 = {} -- 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, connections = {}, } -- --------------------------------------------------------------------------- -- 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() -- 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 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 = { -- 001 is a welcome message -- Cannot join earlier on some servers if not registered check = "001 " .. svr.nick, 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(.*):!", 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 = [[ (server_table) Initialise a socket connection to a server and return a context table. ]] function itte.connect_server(svr) -- Load server context local context = svr context.cmds = itte.get_commands(svr) context.state = { 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() context.state.connected = true return context 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) 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) then itte.message(cxt, util.table_keys(cxt.admins). itte.config.errors.ns_auth_failed) end end end end itte.docs.is_admin = [[ (context_table, message_table) Check whether a user is an admin. Return true if the user and password are in the admin table, or false otherwise. ]] function itte.is_admin(cxt, msg) -- No password provided if table.concat(msg.code_params) == "" then do return false end -- Incorrect password provided elseif msg.code_params ~= nil then local in_admins = util.is_entry(cxt.admins, msg.sender, msg.code_params[#msg.code_params]) 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 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 = string.sub(str, string.find(str, ":" .. cxt.code_prefix) + 1 + string.len(cxt.code_prefix)) 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, ":!") - 2), reply_to = string.sub(str, string.find(str, "!") + 2, string.find(str, "@") - 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) .. " }", 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, 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, 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, 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(cxt, msg) 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(cxt, msg) then itte.notify_no_perms(cxt, msg) do return end end itte.get_config() itte.message(cxt, { msg.reply_to }, itte.config.messages.reload) end -- --------------------------------------------------------------------------- -- Service code mapping and runtime -- --------------------------------------------------------------------------- itte.docs.listen = [[ (context_table, data_str) Parse the socket data string and trigger a response to the server if the string matches preset patterns. ]] function itte.listen(cxt, str) util.debug("listen", str, itte.config.debug) -- 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 itte.message(cxt, { msg.reply_to }, itte.config.errors.unknown_code) 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) -- Set a short timeout to be non-blocking cxt.con:settimeout(0.5) local data, status = cxt.con:receive() if data ~= nil then itte.listen(cxt, data) elseif status == "closed" then util.debug("conn", "Connection " .. status, itte.config.debug) cxt.con:close() itte.state.connections[cxt.name] = nil -- Check if it is the last connection and trigger client exit -- if no connections remain. if #itte.state.connections == 0 then util.debug("conn", "Exiting ...", itte.config.debug) itte.state.connected = false end 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 local contexts = {} for instance, prop in pairs(itte.servers) do contexts[instance] = itte.connect_server(prop) contexts[instance].name = instance itte.state.connected = true itte.state.connections[instance] = true contexts[instance].con:settimeout(1) -- For PASS-based authentication, send PASS before greeting the server if contexts[instance].auth_type == "pass" then itte.send_command(contexts[instance].con, contexts[instance].cmds.pass.resp) contexts[instance].state.authed = true end -- Greet the server in all auth scenarios except SASL, which greets the -- server during CAP negotiation. if (contexts[instance].auth_type ~= "sasl") then itte.send_command(contexts[instance].con, contexts[instance].cmds.user.resp) itte.send_command(contexts[instance].con, contexts[instance].cmds.nick.resp) end local joined_chans = false while (itte.state.connections[instance]) and (not joined_chans) do local data, status = contexts[instance].con:receive() if contexts[instance].auth_type == "sasl" then itte.negotiate_cap(contexts[instance], data) elseif contexts[instance].auth_type == "nickserv" then itte.auth_nickserv(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 contexts[instance].state.authed then itte.traverse_channels(contexts[instance], "join") joined_chans = true elseif (data ~= nil) then if util.is_substr(data, contexts[instance].cmds.join.check) then itte.traverse_channels(contexts[instance], "join") joined_chans = true end end end end -- Add listeners while itte.state.connected do for instance, cxt in pairs(contexts) do itte.add_listener(cxt) end end end return itte