about summary refs log tree commit diff
path: root/bot.lua
blob: 55ce64fd03bd08103be929f8d20cd212048d5a4c (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
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
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