about summary refs log tree commit diff
diff options
context:
space:
mode:
authorequa <equaa@protonmail.com>2022-03-07 19:14:15 +0000
committerequa <equaa@protonmail.com>2022-03-07 19:14:15 +0000
commit224e8f43c50a513bae78af2152c12c0a5f9564f9 (patch)
tree7748d6ffeba50a43d3a7f55a3c85983d07969776
initial commit
-rw-r--r--TODO6
-rw-r--r--bot.lua199
-rw-r--r--config.example.lua21
-rw-r--r--fifo.lua32
-rw-r--r--fs.lua153
5 files changed, 411 insertions, 0 deletions
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