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