local cqueues = require("cqueues") local socket = require("cqueues.socket") 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 assert(not string.match(i, "[\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 #string <= 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 = {} state.channels[ch_name] = ch ch.command = config.channels[ch_name].command or config.command ch.invoke = config.channels[ch_name].invoke or config.invoke ch.mom_id, ch.tx_queue = state.mom:create( ch.command, ch_name, state.rx_queue ) 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 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], } }) 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) -- - 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 local command = (tonumber(line.command) and "_" or "") .. line.command if irc_handlers[command] then irc_handlers[command](state, line) 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) fifo.put(state.queue, { command = "NICK", params = { config.nick } }) -- TODO: config fifo.put(state.queue, { command = "USER", params = { "ed1bot", "0", "*", ":)" }, }) end do local main = cqueues.new() if not arg[1] then print("usage: edbot [CONFIG]") os.exit(1) end local config = assert(dofile(arg[1])) main:wrap(function () irc_connect(main, config) end) assert(main:loop()) end