1 /++
2 + Module for supporting IRCv3's BATCH capability.
3 +/
4 module virc.ircv3.batch;
5 import virc.ircv3.tags;
6 import virc.ircmessage;
7 /++
8 +
9 +/
10 struct BatchProcessor {
11 	///
12 	IRCMessage[] batchless;
13 	private Batch[string] batchCache;
14 	///
15 	Batch[] batches;
16 	///
17 	bool[] consumeBatch;
18 	///
19 	void put(string line) @safe pure {
20 		put(IRCMessage(line));
21 	}
22 	///
23 	void put(IRCMessage msg) @safe pure {
24 		auto processed = BatchCommand(msg);
25 		Batch newBatch;
26 		if (processed.isValid && processed.isNew) {
27 			newBatch.info.referenceTag = processed.referenceTag;
28 			newBatch.info.type = processed.type;
29 			newBatch.info.parameters = processed.parameters;
30 			newBatch.info.tags = processed.tags;
31 		}
32 		if ("batch" !in msg.tags) {
33 			if (processed.isValid) {
34 				if (processed.isNew) {
35 					batchCache[newBatch.info.referenceTag] = newBatch;
36 				} else if (processed.isClosed) {
37 					batches ~= batchCache[processed.referenceTag];
38 					consumeBatch ~= true;
39 					batchCache.remove(processed.referenceTag);
40 				}
41 			} else {
42 				batchless ~= msg;
43 				consumeBatch ~= false;
44 			}
45 		} else {
46 			void findBatch(ref Batch[string] searchBatches, string identifier) @safe pure {
47 				foreach (ref batch; searchBatches) {
48 					if (batch.info.referenceTag == identifier) {
49 						if (processed.isValid) {
50 							if (processed.isNew)
51 								batch.nestedBatches[newBatch.info.referenceTag] = newBatch;
52 						} else {
53 							batch.put(msg);
54 						}
55 						return;
56 					}
57 					else
58 						findBatch(batch.nestedBatches, identifier);
59 				}
60 			}
61 			findBatch(batchCache, msg.tags["batch"]);
62 		}
63 	}
64 	///
65 	auto empty() {
66 		import std.range : empty;
67 		return (batches.empty && batchless.empty);
68 	}
69 	///
70 	void popFront() @safe pure {
71 		if (consumeBatch[0]) {
72 			batches = batches[1..$];
73 		} else
74 			batchless = batchless[1..$];
75 		consumeBatch = consumeBatch[1..$];
76 	}
77 	///
78 	auto front() {
79 		if (consumeBatch[0])
80 			return batches[0];
81 		else
82 			return Batch(BatchInformation(false, "", "NOT A BATCH", []), [batchless[0]]);
83 	}
84 }
85 private struct BatchCommand {
86 	import std.typecons : Nullable;
87 	import virc.common : User;
88 	Nullable!User source;
89 	string referenceTag;
90 	string type;
91 	string[] parameters;
92 	bool isNew;
93 	bool isValid = false;
94 	IRCTags tags;
95 	this(IRCMessage msg) @safe pure nothrow {
96 		import std.array : array;
97 		if (msg.verb != "BATCH") {
98 			return;
99 		}
100 		isValid = true;
101 		source = msg.sourceUser;
102 		auto args = msg.args;
103 		referenceTag = args.front[1..$];
104 		tags = msg.tags;
105 		isNew = (args.front[0] == '+');
106 		if (isNew) {
107 			args.popFront();
108 			type = args.front;
109 			args.popFront();
110 			if (!args.empty) {
111 				parameters = args.array;
112 			}
113 		}
114 	}
115 	auto isClosed() { return !isNew; }
116 }
117 /++
118 +
119 +/
120 struct Batch {
121 	///Metadata attached to this batch
122 	BatchInformation info;
123 	///Lines captured minus the batch tag and starting/ending commands.
124 	IRCMessage[] lines;
125 	///Any batches nested inside this one.
126 	Batch[string] nestedBatches;
127 	///
128 	void put(IRCMessage line) @safe pure {
129 		line.batch = info;
130 		lines ~= line;
131 	}
132 }
133 /++
134 +
135 +/
136 struct BatchInformation {
137 	///
138 	bool isValidBatch = false;
139 	///A simple string identifying the batch. Uniqueness is not guaranteed?
140 	string referenceTag;
141 	///Indicates how the batch is to be processed. Examples include netsplit, netjoin, chathistory
142 	string type;
143 	///Miscellaneous details associated with the batch. Meanings vary based on type.
144 	string[] parameters;
145 	///Tags attached to the opening BATCH verb
146 	IRCTags tags;
147 }
148 @safe pure /+nothrow+/ unittest {
149 	import std.algorithm : copy;
150 	import std.range : isInputRange, isOutputRange, takeOne;
151 	static assert(isOutputRange!(BatchProcessor, string), "BatchProcessor failed outputrange test");
152 	static assert(isInputRange!BatchProcessor, "BatchProcessor failed inputrange test");
153 	//Example from http://ircv3.net/specs/extensions/batch-3.2.html
154 	{
155 		auto batchProcessor = new BatchProcessor;
156 		auto lines = [`:irc.host BATCH +yXNAbvnRHTRBv netsplit irc.hub other.host`,
157 					`@batch=yXNAbvnRHTRBv :aji!a@a QUIT :irc.hub other.host`,
158 					`@batch=yXNAbvnRHTRBv :nenolod!a@a QUIT :irc.hub other.host`,
159 					`:nick!user@host PRIVMSG #channel :This is not in batch, so processed immediately`,
160 					`@batch=yXNAbvnRHTRBv :jilles!a@a QUIT :irc.hub other.host`,
161 					`:irc.host BATCH -yXNAbvnRHTRBv`];
162 		copy(lines, batchProcessor);
163 		{
164 			const batch = takeOne(batchProcessor).front;
165 			assert(batch.lines == [
166 				IRCMessage(":nick!user@host PRIVMSG #channel :This is not in batch, so processed immediately")
167 			]);
168 		}
169 		batchProcessor.popFront();
170 		{
171 			const batch = takeOne(batchProcessor).front;
172 			assert(batch.info.referenceTag == `yXNAbvnRHTRBv`);
173 			assert(batch.info.type == `netsplit`);
174 			assert(batch.info.parameters == [`irc.hub`, `other.host`]);
175 			assert(batch.lines == [
176 				IRCMessage(`@batch=yXNAbvnRHTRBv :aji!a@a QUIT :irc.hub other.host`),
177 				IRCMessage(`@batch=yXNAbvnRHTRBv :nenolod!a@a QUIT :irc.hub other.host`),
178 				IRCMessage(`@batch=yXNAbvnRHTRBv :jilles!a@a QUIT :irc.hub other.host`)
179 			]);
180 		}
181 		batchProcessor.popFront();
182 		assert(batchProcessor.empty);
183 	}
184 	//ditto
185 	{
186 		auto batchProcessor = new BatchProcessor;
187 		auto lines = [`:irc.host BATCH +1 example.com/foo`,
188 					`@batch=1 :nick!user@host PRIVMSG #channel :Message 1`,
189 					`:irc.host BATCH +2 example.com/foo`,
190 					`@batch=1 :nick!user@host PRIVMSG #channel :Message 2`,
191 					`@batch=2 :nick!user@host PRIVMSG #channel :Message 4`,
192 					`@batch=1 :nick!user@host PRIVMSG #channel :Message 3`,
193 					`:irc.host BATCH -1`,
194 					`@batch=2 :nick!user@host PRIVMSG #channel :Message 5`,
195 					`:irc.host BATCH -2`];
196 		copy(lines, batchProcessor);
197 		{
198 			const batch = takeOne(batchProcessor).front;
199 			assert(batch.info.type == "example.com/foo");
200 			assert(batch.info.referenceTag == "1");
201 			assert(batch.lines == [
202 				IRCMessage("@batch=1 :nick!user@host PRIVMSG #channel :Message 1"),
203 				IRCMessage("@batch=1 :nick!user@host PRIVMSG #channel :Message 2"),
204 				IRCMessage("@batch=1 :nick!user@host PRIVMSG #channel :Message 3")
205 			]);
206 		}
207 		batchProcessor.popFront();
208 		{
209 			const batch = takeOne(batchProcessor).front;
210 			assert(batch.info.type == "example.com/foo");
211 			assert(batch.info.referenceTag == "2");
212 			assert(batch.lines == [
213 				IRCMessage("@batch=2 :nick!user@host PRIVMSG #channel :Message 4"),
214 				IRCMessage("@batch=2 :nick!user@host PRIVMSG #channel :Message 5")
215 			]);
216 		}
217 		batchProcessor.popFront();
218 		assert(batchProcessor.empty);
219 	}
220 	//ditto
221 	{
222 		auto batchProcessor = new BatchProcessor;
223 		auto lines = [`:irc.host BATCH +outer example.com/foo`,
224 					`@batch=outer :irc.host BATCH +inner example.com/bar`,
225 					`@batch=inner :nick!user@host PRIVMSG #channel :Hi`,
226 					`@batch=outer :irc.host BATCH -inner`,
227 					`:irc.host BATCH -outer`];
228 		copy(lines, batchProcessor);
229 		{
230 			auto batch = takeOne(batchProcessor).front;
231 			assert(batch.info.type == "example.com/foo");
232 			assert(batch.info.referenceTag == "outer");
233 			assert(batch.info.parameters == []);
234 			assert(batch.lines == []);
235 			assert("inner" in batch.nestedBatches);
236 			assert(batch.nestedBatches["inner"].info.type == "example.com/bar");
237 			assert(batch.nestedBatches["inner"].info.referenceTag == "inner");
238 			assert(batch.nestedBatches["inner"].info.parameters == []);
239 			assert(batch.nestedBatches["inner"].lines == [
240 				IRCMessage("@batch=inner :nick!user@host PRIVMSG #channel :Hi")
241 			]);
242 		}
243 		batchProcessor.popFront();
244 		assert(batchProcessor.empty);
245 	}
246 	//Example from http://ircv3.net/specs/extensions/batch/netsplit-3.2.html
247 	{
248 		auto batchProcessor = new BatchProcessor;
249 		auto lines = [`:irc.host BATCH +yXNAbvnRHTRBv netsplit irc.hub other.host`,
250 					`@batch=yXNAbvnRHTRBv :aji!a@a QUIT :irc.hub other.host`,
251 					`@batch=yXNAbvnRHTRBv :nenolod!a@a QUIT :irc.hub other.host`,
252 					`@batch=yXNAbvnRHTRBv :jilles!a@a QUIT :irc.hub other.host`,
253 					`:irc.host BATCH -yXNAbvnRHTRBv`];
254 		copy(lines, batchProcessor);
255 		{
256 			const batch = takeOne(batchProcessor).front;
257 			assert(batch.info.type == "netsplit");
258 			assert(batch.info.referenceTag == "yXNAbvnRHTRBv");
259 			assert(batch.info.parameters == ["irc.hub", "other.host"]);
260 			assert(batch.lines == [
261 				IRCMessage("@batch=yXNAbvnRHTRBv :aji!a@a QUIT :irc.hub other.host"),
262 				IRCMessage("@batch=yXNAbvnRHTRBv :nenolod!a@a QUIT :irc.hub other.host"),
263 				IRCMessage(`@batch=yXNAbvnRHTRBv :jilles!a@a QUIT :irc.hub other.host`)
264 			]);
265 		}
266 		batchProcessor.popFront();
267 		assert(batchProcessor.empty);
268 	}
269 	//ditto
270 	{
271 		auto batchProcessor = new BatchProcessor;
272 		auto lines = [`:irc.host BATCH +4lMeQwsaOMs6s netjoin irc.hub other.host`,
273 					`@batch=4lMeQwsaOMs6s :aji!a@a JOIN #atheme`,
274 					`@batch=4lMeQwsaOMs6s :nenolod!a@a JOIN #atheme`,
275 					`@batch=4lMeQwsaOMs6s :jilles!a@a JOIN #atheme`,
276 					`@batch=4lMeQwsaOMs6s :nenolod!a@a JOIN #ircv3`,
277 					`@batch=4lMeQwsaOMs6s :jilles!a@a JOIN #ircv3`,
278 					`@batch=4lMeQwsaOMs6s :Elizacat!a@a JOIN #ircv3`,
279 					`:irc.host BATCH -4lMeQwsaOMs6s`];
280 		copy(lines, batchProcessor);
281 		{
282 			const batch = takeOne(batchProcessor).front;
283 			assert(batch.info.type == "netjoin");
284 			assert(batch.info.referenceTag == "4lMeQwsaOMs6s");
285 			assert(batch.info.parameters == ["irc.hub", "other.host"]);
286 			assert(batch.lines == [
287 				IRCMessage("@batch=4lMeQwsaOMs6s :aji!a@a JOIN #atheme"),
288 				IRCMessage("@batch=4lMeQwsaOMs6s :nenolod!a@a JOIN #atheme"),
289 				IRCMessage(`@batch=4lMeQwsaOMs6s :jilles!a@a JOIN #atheme`),
290 				IRCMessage(`@batch=4lMeQwsaOMs6s :nenolod!a@a JOIN #ircv3`),
291 				IRCMessage(`@batch=4lMeQwsaOMs6s :jilles!a@a JOIN #ircv3`),
292 				IRCMessage(`@batch=4lMeQwsaOMs6s :Elizacat!a@a JOIN #ircv3`)
293 			]);
294 		}
295 		batchProcessor.popFront();
296 		assert(batchProcessor.empty);
297 	}
298 	//Upcoming chathistory batch, subject to change
299 	{
300 		auto batchProcessor = new BatchProcessor;
301 		auto lines = [`:irc.host BATCH +sxtUfAeXBgNoD chathistory #channel`,
302 					`@batch=sxtUfAeXBgNoD;time=2015-06-26T19:40:31.230Z :foo!foo@example.com PRIVMSG #channel :I like turtles.`,
303 					`@batch=sxtUfAeXBgNoD;time=2015-06-26T19:43:53.410Z :bar!bar@example.com NOTICE #channel :Tortoises are better.`,
304 					`@batch=sxtUfAeXBgNoD;time=2015-06-26T19:48:18.140Z :irc.host PRIVMSG #channel :Squishy animals are inferior to computers.`,
305 					`:irc.host BATCH -sxtUfAeXBgNoD`];
306 		copy(lines, batchProcessor);
307 		{
308 			const batch = takeOne(batchProcessor).front;
309 			assert(batch.info.type == "chathistory");
310 			assert(batch.info.referenceTag == "sxtUfAeXBgNoD");
311 			assert(batch.info.parameters == ["#channel"]);
312 			assert(batch.lines == [
313 				IRCMessage("@batch=sxtUfAeXBgNoD;time=2015-06-26T19:40:31.230Z :foo!foo@example.com PRIVMSG #channel :I like turtles."),
314 				IRCMessage("@batch=sxtUfAeXBgNoD;time=2015-06-26T19:43:53.410Z :bar!bar@example.com NOTICE #channel :Tortoises are better."),
315 				IRCMessage(`@batch=sxtUfAeXBgNoD;time=2015-06-26T19:48:18.140Z :irc.host PRIVMSG #channel :Squishy animals are inferior to computers.`)
316 			]);
317 		}
318 		batchProcessor.popFront();
319 		assert(batchProcessor.empty);
320 	}
321 	//ditto
322 	{
323 		auto batchProcessor = new BatchProcessor;
324 		auto lines = [`:irc.host BATCH +sxtUfAeXBgNoD chathistory remote`,
325 					`@batch=sxtUfAeXBgNoD;time=2015-06-26T19:40:31.230Z :remote!foo@example.com PRIVMSG local :I like turtles.`,
326 					`@batch=sxtUfAeXBgNoD;time=2015-06-26T19:43:53.410Z :local!bar@example.com PRIVMSG remote :Tortoises are better.`,
327 					`:irc.host BATCH -sxtUfAeXBgNoD`];
328 		copy(lines, batchProcessor);
329 		{
330 			const batch = takeOne(batchProcessor).front;
331 			assert(batch.info.type == "chathistory");
332 			assert(batch.info.referenceTag == "sxtUfAeXBgNoD");
333 			assert(batch.info.parameters == ["remote"]);
334 			assert(batch.lines == [
335 				IRCMessage("@batch=sxtUfAeXBgNoD;time=2015-06-26T19:40:31.230Z :remote!foo@example.com PRIVMSG local :I like turtles."),
336 				IRCMessage("@batch=sxtUfAeXBgNoD;time=2015-06-26T19:43:53.410Z :local!bar@example.com PRIVMSG remote :Tortoises are better.")
337 			]);
338 		}
339 		batchProcessor.popFront();
340 		assert(batchProcessor.empty);
341 	}
342 	{ //Non-batch
343 		auto batchProcessor = new BatchProcessor;
344 		auto lines = [`@time=2015-06-26T19:40:31.230Z :remote!foo@example.com PRIVMSG local :I like turtles.`];
345 		copy(lines, batchProcessor);
346 		{
347 			const batch = takeOne(batchProcessor).front;
348 			assert(batch.lines == [IRCMessage("@time=2015-06-26T19:40:31.230Z :remote!foo@example.com PRIVMSG local :I like turtles.")]);
349 		}
350 		batchProcessor.popFront();
351 		assert(batchProcessor.empty);
352 	}
353 	{ //Non-batch
354 		auto batchProcessor = new BatchProcessor;
355 		batchProcessor.put(`@time=2015-06-26T19:40:31.230Z :remote!foo@example.com PRIVMSG local :I like turtles.`);
356 		{
357 			const batch = takeOne(batchProcessor).front;
358 			assert(batch.lines == [IRCMessage("@time=2015-06-26T19:40:31.230Z :remote!foo@example.com PRIVMSG local :I like turtles.")]);
359 		}
360 		batchProcessor.popFront();
361 		batchProcessor.put(`@time=2015-06-26T19:40:31.230Z :remote!foo@example.com PRIVMSG local :I like turtles.`);
362 		{
363 			const batch = takeOne(batchProcessor).front;
364 			assert(batch.lines == [IRCMessage("@time=2015-06-26T19:40:31.230Z :remote!foo@example.com PRIVMSG local :I like turtles.")]);
365 		}
366 		batchProcessor.popFront();
367 		assert(batchProcessor.empty);
368 	}
369 	//Tagged batch
370 	{
371 		auto batchProcessor = new BatchProcessor;
372 		auto lines = [`@tag=value;another=something :irc.host BATCH +yXNAbvnRHTRBv netsplit irc.hub other.host`,
373 					`@batch=yXNAbvnRHTRBv :aji!a@a QUIT :irc.hub other.host`,
374 					`@batch=yXNAbvnRHTRBv :nenolod!a@a QUIT :irc.hub other.host`,
375 					`@batch=yXNAbvnRHTRBv :jilles!a@a QUIT :irc.hub other.host`,
376 					`:irc.host BATCH -yXNAbvnRHTRBv`];
377 		copy(lines, batchProcessor);
378 		{
379 			const batch = takeOne(batchProcessor).front;
380 			assert(batch.info.type == "netsplit");
381 			assert(batch.info.tags["tag"] == "value");
382 			assert(batch.info.tags["another"] == "something");
383 			assert(batch.info.referenceTag == "yXNAbvnRHTRBv");
384 			assert(batch.info.parameters == ["irc.hub", "other.host"]);
385 			assert(batch.lines == [
386 				IRCMessage("@batch=yXNAbvnRHTRBv :aji!a@a QUIT :irc.hub other.host"),
387 				IRCMessage("@batch=yXNAbvnRHTRBv :nenolod!a@a QUIT :irc.hub other.host"),
388 				IRCMessage(`@batch=yXNAbvnRHTRBv :jilles!a@a QUIT :irc.hub other.host`)
389 			]);
390 		}
391 		batchProcessor.popFront();
392 		assert(batchProcessor.empty);
393 	}
394 }