about summary refs log tree commit diff
path: root/bot.lua
blob: 7014eda21b8d0363f4d8e331cb1ce3590457cd1e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
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, host, config)
	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))
	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

			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 } })
	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 }