From 224e8f43c50a513bae78af2152c12c0a5f9564f9 Mon Sep 17 00:00:00 2001 From: equa Date: Mon, 7 Mar 2022 19:14:15 +0000 Subject: initial commit --- TODO | 6 ++ bot.lua | 199 +++++++++++++++++++++++++++++++++++++++++++++++++++++ config.example.lua | 21 ++++++ fifo.lua | 32 +++++++++ fs.lua | 153 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 411 insertions(+) create mode 100644 TODO create mode 100644 bot.lua create mode 100644 config.example.lua create mode 100644 fifo.lua create mode 100644 fs.lua diff --git a/TODO b/TODO new file mode 100644 index 0000000..267fedc --- /dev/null +++ b/TODO @@ -0,0 +1,6 @@ +make ed restart itself +- i think we probably want some sort of "process manager" thread which +- handles all of the interrupts +make it support multiple instances per channel +signify what kind of commands we need (we can use b as a prefix?) +find a name (bed? multihEaD? googlEDocs?) diff --git a/bot.lua b/bot.lua new file mode 100644 index 0000000..f957fd3 --- /dev/null +++ b/bot.lua @@ -0,0 +1,199 @@ +local cqueues = require("cqueues") +local socket = require("cqueues.socket") +local condition = require("cqueues.condition") +local std = require("posix.unistd") +local stdio = require("posix.stdio") +local dkjson = require("dkjson") + +local fs = require("fs") + +local fifo = require("fifo") + +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 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 + +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 = {} + +function handle_ed(loop, in_queue, out_queue, command) + local ed_out = { std.pipe() } + if not (ed_out[1] and ed_out[2]) then return nil, "can't create pipe" end + local ed_in = { std.pipe() } + if not (ed_in[1] and ed_in[2]) then return nil, "can't create pipe" end + + local pid, err = std.fork() + if not pid then return nil, err end + + if pid == 0 then + std.close(ed_out[1]) + std.close(ed_in[2]) + std.dup2(ed_in[1], std.STDIN_FILENO) + std.dup2(ed_out[2], std.STDOUT_FILENO) + std.execp(command[1], { select(2, table.unpack(command)) }) + os.exit(100) + else + loop:wrap(function () + local out = fs.new(ed_in[2], "w") + while true do + local x = fifo.get(in_queue) + print("ed: got " .. x) + out:write(x .. "\n") + end + end) + loop:wrap(function () + local inp = fs.new(ed_out[1]) + for l in inp:lines() do + fifo.put(out_queue, l) + end + 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] + fifo.put(state.queue, { command = "JOIN", params = { state.channel } }) +end + +function irc_handlers.PRIVMSG(state, line) + -- fifo.put(state.queue, { command = "NOTICE", params = { + -- state.channel, + -- "A! " .. line.params[2] + -- } }) + fifo.put(state.to_ed, line.params[2]) +end +function irc_connect(loop, host, config) + local state = { + queue = fifo.new(), + nick = config.nick or "ed1bot", + } + + -- populate channels + -- TODO good + for k in pairs(config.channels or { ["#ed1bot"] = {} }) do + state.channel = k + break + end + + local sock = assert(socket.connect(host)) + sock:starttls() + + 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 + + print(dkjson.encode(parse_message(raw_line))) + + 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 + print(line) + fifo.put(state.queue, parse_message(line)) + end + end) + + state.to_ed, state.from_ed = fifo.new(), fifo.new() + handle_ed(loop, state.to_ed, state.from_ed, { "red" }) + + loop:wrap(function() + while true do + local line = fifo.get(state.from_ed) + fifo.put(state.queue, { + command = "PRIVMSG", + params = { + state.channel, + line + } + }) + end + end) + + fifo.put(state.queue, { command = "NICK", params = { "ed1bot" } }) + fifo.put(state.queue, { command = "USER", params = { "ed1bot", "0", "*", ":)" } }) +end + +local main = cqueues.new() +if not arg[1] then + print("usage: edbot [CONFIG]") + os.exit(1) +end +main:wrap(function () irc_connect(main, { host = "localhost", port = 6697 }, dofile(arg[1])) end) +assert(main:loop()) +return { parse_message = parse_message } diff --git a/config.example.lua b/config.example.lua new file mode 100644 index 0000000..1d65b8c --- /dev/null +++ b/config.example.lua @@ -0,0 +1,21 @@ +return { + host = { + host = "localhost", + port = 6667, + tls = false, + }, + nick = "ed1bot", + -- allow dms? + direct = false, + command = { "red" }, + -- a ist of lua patterns (maybe regex later) that + -- tells edbot when to listen for commands + -- %n is replaced with edbot's current nickname + -- %% is replaced by % + invoke = { "%n: ?" }, + channels = { + ["#ed1bot"] = { + invoke = { "%n: ?", ":" }, + }, + }, +} diff --git a/fifo.lua b/fifo.lua new file mode 100644 index 0000000..03a7af7 --- /dev/null +++ b/fifo.lua @@ -0,0 +1,32 @@ +local condition = require("cqueues.condition") + +local fifo = {} + +function fifo.new() + return setmetatable({ cond = condition.new() }, { __index = fifo }) +end + +function fifo.signal(f) + f.cond:signal() +end + +function fifo.get(f) + if not f.head then + f.cond:wait() + end + + local data = f.head.data + f.head = f.head.tail + return data +end + +function fifo.put(f, data) + f.head = { data = data, tail = f.head } + fifo.signal(f) +end + +function fifo.iter(f) + return function () return fifo.get(f) end +end + +return fifo diff --git a/fs.lua b/fs.lua new file mode 100644 index 0000000..2e7638f --- /dev/null +++ b/fs.lua @@ -0,0 +1,153 @@ +-- cqueue filesystem with luaposix +-- TODO: closing files +local unistd = require("posix.unistd") +local cqueues = require("cqueues") +local dkjson = require("dkjson") + +local file = {} + +function file.new(fd, mode) + if not mode then mode = "r" end + local f = setmetatable({}, { __index = file }) + f.pollfd = fd + f.mode = mode + if string.match(mode, "r") then + f.rbuf = {} + end + + if string.match(mode, "w") then + f.wbuf = {} + end + + return f +end + +function file.events(f) + return f.mode +end + +-- input/output buffers +-- +-- buffers are arrays of objects containing "data" (a string) and "index" (their position) +-- indexing allows us to avoid recreating string objects + +function buffer_length(buf) + local total = 0 + for _, entry in ipairs(buf) do + total = total + #entry.data - entry.index + 1 + end + return total +end + +-- returns nil on empty length +function buffer_get(buf, length) + local out = {} + if not length then length = buffer_length(buf) end + while length > 0 do + assert(#buf > 0) + + local x = string.sub(buf[1].data, buf[1].index, buf[1].index + length - 1) + table.insert(out, x) + if #buf[1].data - buf[1].index + 1 <= length then + table.remove(buf, 1) + else + buf[1].index = buf[1].index + length + end + length = length - #x + end + if out[1] then return table.concat(out) else return nil end +end + +function buffer_char_index(buf, char) + local num = 1 + for _, entry in ipairs(buf) do + if string.find(entry.data, char, entry.index, true) then + return num + string.find(entry.data, char, entry.index, true) - entry.index + else + num = num + #entry.data - entry.index + 1 + end + end + + return nil +end + +do + local test_buf = { { data = "abcde", index = 2 }, { data = "\na", index = 1 } } + assert(buffer_length(test_buf) == 6) + assert(buffer_char_index(test_buf, "\n") == 5) + assert(buffer_get(test_buf, 5) == "bcde\n") + assert(buffer_length(test_buf) == 1) +end + +function try_read(buffer, fd, max) + cqueues.poll({ pollfd = fd, events = function () return "r" end }) + + local data, _, errno = unistd.read(fd, max) + + if not data then return nil, errno end + + table.insert(buffer, { data = data, index = 1}) + + return #data +end + +function file.read(f, what) + assert(f.rbuf) + + if (type(what) == "number" and what > 0) then + while buffer_length(f.rbuf) < what do + if try_read(f.rbuf, f.pollfd, what - buffer_length(f.rbuf)) == 0 then + break + end + end + + return buffer_get(f.rbuf, math.min(what, buffer_length(f.rbuf))) + elseif (type(what) == "number" and what < 0) then + while buffer_length(f.rbuf) == 0 do + if try_read(f.rbuf, f.pollfd, 0 - what) == 0 then return nil end + end + return buffer_get(f.rbuf, math.min(0 - what, buffer_length(f.rbuf))) + elseif what == "*l" or what == "*L" then + while not buffer_char_index(f.rbuf, "\n") do + -- TODO: constantize this + if try_read(f.rbuf, f.pollfd, 1024) == 0 then + break + end + end + + local line = buffer_get(f.rbuf, buffer_char_index(f.rbuf, "\n")) + + if not line then + return nil + end + local ret = string.gsub( + line, + "\n", + (what == "*l" and "" or "\n") + ) + return ret + else + -- TODO + end +end + +function file.lines(f, what) + return function () return file.read(f, what or "*l") end +end + +function file.write(f, data) + local index = 1 + while index <= #data do + cqueues.poll({ pollfd = f.pollfd, events = function () return "w" end }) + local nb, _, errno = unistd.write(f.pollfd, string.sub(data, index)) + if nb >= 0 then + index = index + nb + else + return nil, errno -- TODO? + end + end + + return f +end + +return file -- cgit 1.3.0-6-gf8a5