local cqueues = require("cqueues") local socket = require("cqueues.socket") local signal = require("cqueues.signal") local fs = require("fs") local fifo = require("fifo") local mom = require("mom") local function parse_message(data) if string.match(data, "[\0\r\n]") then return nil, "illegal character" end local pos = 1 local prefix local command local params = {} if string.match(data, "^:") then prefix, pos = string.match(data, "^:([^ ]+)()") end command, pos = string.match(data, "([^ ]+)()", pos) while pos < #data do local start, param start, param, pos = string.match(data, "()([^ ]+)()", pos) if not start then break end if string.sub(param, 1, 1) == ":" then table.insert(params, string.sub(data, start + 1)) break else table.insert(params, param) end end return { params = params, command = command, prefix = prefix } end local function emit_message(message) local out = {} if message.prefix then table.insert(out, message.prefix) table.insert(out, " ") end table.insert(out, message.command) for i, v in ipairs(message.params or {}) do v = string.gsub(v, "[\0\r\n]", "") if i == #(message.params or {}) then table.insert(out, " :") table.insert(out, v) else assert(not string.match(v, "^:")) table.insert(out, " ") table.insert(out, v) end end return table.concat(out) end local irc_handlers = {} local function source_to_nick(source) return string.match(source, "[^!@]*") end do -- test source_to_nick assert(source_to_nick("a!username@host") == "a") assert(source_to_nick("argh") == "argh") assert(source_to_nick("man@host") == "man") end local function irc_dump(fifo, socket) for data in fifo:iter() do data = emit_message(data) if not string.match(data, "[\r\n]") and #data <= 510 then socket:write(data .. "\r\n") end end end function irc_handlers.PING(state, line) fifo.put(state.queue, { command = "PONG", params = line.params }) end function irc_handlers._001(state, line) state.nick = line.params[1] for k, v in pairs(state.config.channels) do state.queue:put({ command = "JOIN", params = { k } }) end end function irc_handlers.JOIN(state, line) local config = state.config if not (line.prefix and source_to_nick(line.prefix) == state.nick) then return end -- TODO several channels at once with commas if not config.channels[line.params[1]] then return end if not state.channels[line.params[1]] then -- TODO validate the params at all local ch_name = line.params[1] local ch_config = config.channels[ch_name] local ch = {} state.channels[ch_name] = ch ch.command = ch_config.command or config.command ch.invoke = ch_config.invoke or config.invoke ch.directory = ch_config.directory or config.directory ch.mom_id, ch.tx_queue = state.mom:create( ch.command, ch_name, state.rx_queue, ch.directory ) else state.mom:start_process( state.channels[line.params[1]].mom_id ) end end function irc_handlers.PRIVMSG(state, line) -- TODO validate params existing, etc local ch = line.params[1] -- fifo.put(state.to_ed, line.params[2]) if not state.channels[ch] then -- TODO return end local full_line = line.params[2] local line for _, prefix in ipairs(state.channels[ch].invoke) do -- TODO: escape the nick itself prefix = string.gsub(prefix, "%%n", state.nick) prefix = string.gsub(prefix, "%%%", "%") -- TODO parens and stuff maybe local pat = "^" .. prefix .. "(.*)" line = string.match(full_line, pat) if line then break end end if not line then return end state.channels[ch].tx_queue:put(line) end local function handle_ed_rx(state) for line in state.rx_queue:iter() do if line[2] == "line" then state.queue:put({ command = "PRIVMSG", params = { line[1], line[3] .. "\x0f", } }) elseif line[2] == "quit" then state.queue:put({ command = "PART", params = { line[1], } }) -- we do a little trolling state.loop:wrap(function() cqueues.poll(10.0) state.queue:put({ command = "JOIN", params = { line[1], -- TODO config joins -- (for keys etc) }, }) end) end end end local function irc_connect(loop, config) local host = config.host local state = { -- IRC output queue (message objects) queue = fifo.new(), loop = loop, nick = config.nick or "ed1bot", command = config.command, config = config, -- global rx queue from various eds rx_queue = fifo.new(), -- active channels! we populate this when we get a join -- that we allow (i.e. that has an entry in the config) -- a channel has the fields: -- - invoke, a list of patterns (as in config, but being -- properly populated with defaults) -- - command, the command to run as a list -- - directory, the dir to run it in -- - mom_id, the id in the state's process mom -- - tx_queue, to send messages to the ed queue -- (note we don't have rx_queue: that is shared) channels = {}, } local sock = assert(socket.connect(host.host, host.port)) if host.tls then sock:starttls() end loop:wrap(function () irc_dump(state.queue, sock) end) loop:wrap(function () for raw_line in sock:lines("*l") do -- TODO set line length local line = parse_message(raw_line) -- TODO assert line or something if not line then print("bad line! " .. raw_line) line = { command = "BADLINE", params = { line } } end if line.command == "ERROR" or state.config.debug then print(raw_line) end local command = (tonumber(line.command) and "_" or "") .. line.command if irc_handlers[command] then local ok, err = pcall( irc_handlers[command], state, line ) if not ok then print(err) end end end os.exit(0) end) loop:wrap(function () local f = fs.new(0) for line in f:lines() do fifo.put(state.queue, parse_message(line)) end end) state.mom = mom.new(loop) loop:wrap(function () handle_ed_rx(state) end) if config.pass then fifo.put(state.queue, { command = "PASS", params = { config.pass } }) end fifo.put(state.queue, { command = "NICK", params = { config.nick } }) -- TODO: config fifo.put(state.queue, { command = "USER", params = { config.user, "0", "*", ":)" }, }) end local function validate_config(c) local function valid(name, thing, kind) assert(type(thing) == kind, kind .. " " .. name .. " must be specified" ) end local function optional(name, thing, kind) if thing then valid(name, thing, kind) end end valid("host", c.host, "table") valid("host.host", c.host.host, "string") valid("host.port", c.host.port, "number") valid("nick", c.nick, "string") valid("user", c.user, "string") optional("pass", c.pass, "string") valid("directory", c.directory, "string") -- TODO better valid("command", c.command, "table") valid("invoke", c.invoke, "table") valid("channels", c.channels, "table") for k, v in pairs(c.channels) do local name = string.format("channels[%q]", k) valid(name, v, "table") optional(name .. ".invoke", v.invoke, "table") optional(name .. ".command", v.command, "table") optional(name .. ".directory", v.directory, "string") end end do signal.ignore(signal.SIGPIPE) local main = cqueues.new() if not arg[1] then print("usage: edbot [CONFIG]") os.exit(1) end local config = assert(dofile(arg[1])) validate_config(config) main:wrap(function () irc_connect(main, config) end) assert(main:loop()) end