1 /// Provides an IRC connection to Bancho (osu!'s server system) and access to its commands (Multiplayer room creation)
2 module bancho.irc;
3 
4 import core.time;
5 
6 import std.algorithm;
7 import std.array;
8 import std.conv;
9 import std.datetime.stopwatch;
10 import std.datetime.systime;
11 import std.datetime.timezone;
12 import std.functional;
13 import std.path;
14 import std..string;
15 import std.typecons;
16 import std.uni : asLowerCase;
17 
18 import vibe.core.core;
19 import vibe.core.log;
20 import vibe.core.net;
21 import vibe.stream.operations;
22 
23 import tinyevent;
24 
25 import bancho.ratelimit;
26 
27 /// Username of BanchoBot (for sending !mp commands & checking source)
28 static immutable string banchoBotNick = "BanchoBot";
29 
30 enum SettingsLineMinLength = 55;
31 
32 private
33 {
34 	auto fixUsername(in char[] username)
35 	{
36 		return username.replace(" ", "_");
37 	}
38 
39 	auto extractUsername(in char[] part)
40 	{
41 		auto i = part.countUntil('!');
42 		if (i == -1 || !part.length)
43 			return part[0 .. $];
44 		else
45 			return part[1 .. i];
46 	}
47 
48 	void sendLine(TCPConnection conn, in char[] line)
49 	{
50 		logDebugV("send %s", line);
51 		conn.write(line);
52 		conn.write("\r\n");
53 		conn.flush();
54 	}
55 
56 	void banchoAuthenticate(TCPConnection conn, in char[] host, in char[] username, in char[] password)
57 	{
58 		auto fixed = username.fixUsername;
59 		conn.sendLine("CAP LS 302");
60 		logDebugV("send PASS ******");
61 		conn.write("PASS " ~ password ~ "\r\n");
62 		conn.sendLine("NICK " ~ fixed);
63 		conn.sendLine("USER " ~ fixed ~ " " ~ fixed ~ " " ~ host ~ " :" ~ fixed);
64 	}
65 
66 	void privMsg(TCPConnection conn, in char[] destination, in char[] message)
67 	{
68 		conn.sendLine("PRIVMSG " ~ destination ~ " :" ~ message);
69 	}
70 
71 	void banchoQuit(TCPConnection conn)
72 	{
73 		conn.sendLine("QUIT :Leaving");
74 	}
75 }
76 
77 /// Represents a simple sent message from bancho
78 struct Message
79 {
80 	/// User who wrote this message
81 	string sender;
82 	/// Target channel (#channel) or username
83 	string target;
84 	/// content of the message
85 	string message;
86 
87 	/// Serializes to "[sender] -> [target]: [message]"
88 	string toString()
89 	{
90 		return sender ~ " -> " ~ target ~ ": " ~ message;
91 	}
92 }
93 
94 /// Represents a topic change event (in the case of a multi room being created)
95 struct TopicChange
96 {
97 	///
98 	string channel, topic;
99 }
100 
101 /// Represents a user quitting or joining the IRC ingame or via web/irc
102 struct Quit
103 {
104 	///
105 	string user;
106 	/// Mostly one out of:
107 	/// - "ping timeout 80s"
108 	/// - "quit"
109 	/// - "replaced (None 0ea7ac91-60bf-448b-adda-bc6b8d096dc7)"
110 	/// - "replaced (Supporter c83820bb-a602-43f0-8f17-04718e34b72d)"
111 	string reason;
112 }
113 
114 /// Represents a join/leave event.
115 struct MembershipEvent
116 {
117 	///
118 	enum Type
119 	{
120 		///
121 		join,
122 		///
123 		part,
124 		///
125 		notFound
126 	}
127 
128 	///
129 	string channel;
130 	///
131 	Type type;
132 }
133 
134 ///
135 struct BeatmapInfo
136 {
137 	/// b/ ID of the beatmap, extracted from url. Empty if couldn't be parsed
138 	string id;
139 	/// URL to the beatmap
140 	string url;
141 	/// Artist + Name + Difficulty
142 	string name;
143 
144 	/// Parses a map in the format `Ariabl'eyeS - Kegare Naki Bara Juuji (Short ver.) [Rose†kreuz] (https://osu.ppy.sh/b/1239875)`
145 	static BeatmapInfo parseChange(string map)
146 	{
147 		BeatmapInfo ret;
148 		if (map.endsWith(")"))
149 		{
150 			auto start = map.lastIndexOf("(");
151 			ret.name = map[0 .. start].strip;
152 			ret.url = map[start + 1 .. $ - 1];
153 			if (ret.url.startsWith("https://osu.ppy.sh/b/"))
154 				ret.id = ret.url["https://osu.ppy.sh/b/".length .. $];
155 		}
156 		else
157 			ret.name = map;
158 		return ret;
159 	}
160 }
161 
162 /// Utility mixin template implementing dynamic timeout based event subscribing + static backlog
163 /// Creates methods waitFor<fn>, process[fn], fetchOld[fn]Log, clear[fn]Log
164 mixin template Processor(string fn, Arg, size_t backlog)
165 {
166 	struct Backlog
167 	{
168 		long at;
169 		Arg value;
170 	}
171 
172 	bool delegate(Arg)[] processors;
173 	// keep a few around for events called later
174 	Backlog[backlog] backlogs;
175 
176 	void process(Arg arg)
177 	{
178 		static if (is(typeof(mixin("this.preProcess" ~ fn))))
179 			mixin("this.preProcess" ~ fn ~ "(arg);");
180 		foreach_reverse (i, proc; processors)
181 		{
182 			if (proc(arg))
183 			{
184 				processors[i] = processors[$ - 1];
185 				processors.length--;
186 				return;
187 			}
188 		}
189 		auto i = backlogs[].minIndex!"a.at < b.at";
190 		if (backlogs[i].at != 0)
191 		{
192 			static if (is(Arg == Quit))
193 				logTrace("Disposing " ~ fn ~ " %s", backlogs[i].value);
194 			else
195 				logDebugV("Disposing " ~ fn ~ " %s", backlogs[i].value);
196 		}
197 		backlogs[i].at = Clock.currStdTime;
198 		backlogs[i].value = arg;
199 	}
200 
201 	Arg waitFor(bool delegate(Arg) check, Duration timeout)
202 	{
203 		foreach (ref log; backlogs[].sort!"a.at < b.at")
204 		{
205 			if (log.at == 0)
206 				continue;
207 			if (check(log.value))
208 			{
209 				log.at = 0;
210 				return log.value;
211 			}
212 		}
213 		if (timeout <= Duration.zero)
214 			throw new InterruptException();
215 		Arg ret;
216 		bool got = false;
217 		bool delegate(Arg) del = (msg) {
218 			if (check(msg))
219 			{
220 				ret = msg;
221 				got = true;
222 				return true;
223 			}
224 			return false;
225 		};
226 		scope (failure)
227 			processors = processors.remove!(a => a == del, SwapStrategy.unstable);
228 		processors ~= del;
229 		StopWatch sw;
230 		sw.start();
231 		while (!got && sw.peek < timeout)
232 			sleep(10.msecs);
233 		sw.stop();
234 		if (!got)
235 			throw new InterruptException();
236 		return ret;
237 	}
238 
239 	Arg[] fetchOldLog(bool delegate(Arg) check, bool returnIt = true)
240 	{
241 		Arg[] ret;
242 		foreach (ref log; backlogs[].sort!"a.at < b.at")
243 		{
244 			if (log.at == 0)
245 				continue;
246 			if (check(log.value))
247 			{
248 				log.at = 0;
249 				if (returnIt)
250 					ret ~= log.value;
251 			}
252 		}
253 		return ret;
254 	}
255 
256 	void clearLog()
257 	{
258 		backlogs[] = Backlog.init;
259 		processors.length = 0;
260 	}
261 
262 	mixin("alias processors" ~ fn ~ " = processors;");
263 	mixin("alias backlogs" ~ fn ~ " = backlogs;");
264 	mixin("alias waitFor" ~ fn ~ " = waitFor;");
265 	mixin("alias process" ~ fn ~ " = process;");
266 	mixin("alias fetchOld" ~ fn ~ "Log = fetchOldLog;");
267 	mixin("alias clear" ~ fn ~ "Log = clearLog;");
268 }
269 
270 /// Represents a Bancho IRC connection.
271 /// Examples:
272 /// ---
273 /// BanchoBot bot = new BanchoBot("User", "hunter2");
274 /// runTask({
275 ///   while (true)
276 ///   {
277 ///     bot.connect();
278 ///     logDiagnostic("Got disconnected from bancho...");
279 ///     sleep(2.seconds);
280 ///   }
281 /// });
282 /// ---
283 class BanchoBot
284 {
285 	version (D_Ddoc)
286 	{
287 		/// list of event subscribers. Gets removed automatically when called and returns true, otherwise caller has to remove it.
288 		bool delegate(Message)[] processorsMessage;
289 		bool delegate(Quit)[] processorsQuit; /// ditto
290 		bool delegate(TopicChange)[] processorsTopic; /// ditto
291 		bool delegate(MembershipEvent)[] processorsMembership; /// ditto
292 
293 		/// list of backlog which hasn't been handled by any event subscribers, oldest one will always be replaced on new ones.
294 		Message[256] backlogsMessage;
295 		Quit[256] backlogsQuit; /// ditto
296 		TopicChange[8] backlogsTopic; /// ditto
297 		MembershipEvent[8] backlogsMembership; /// ditto
298 
299 		/// Waits for an event or returns one which is in the backlog already. Removes matching backlog entries.
300 		/// Throws: InterruptException on timeout
301 		/// Params:
302 		///   check = a delegate checking for which object to check for. Return true to return this object & not add it to the backlog.
303 		///   timeout = the timeout after which to interrupt the waiting task
304 		Message waitForMessage(bool delegate(Message) check, Duration timeout);
305 		/// ditto
306 		Quit waitForQuit(bool delegate(Quit) check, Duration timeout);
307 		/// ditto
308 		TopicChange waitForTopic(bool delegate(TopicChange) check, Duration timeout);
309 		/// ditto
310 		MembershipEvent waitForMembership(bool delegate(MembershipEvent) check, Duration timeout);
311 
312 		/// Calls all event subscribers to try to match this object, otherwise replace the oldest element in the backlog with this.
313 		/// Throws: anything thrown in event subscribers will get thrown here too.
314 		void processMessage(Message message);
315 		/// ditto
316 		void processQuit(Quit quit);
317 		/// ditto
318 		void processTopic(TopicChange change);
319 		/// ditto
320 		void processMembership(MembershipEvent change);
321 
322 		/// Goes through the backlog and removes and optionally returns all matching objects.
323 		/// Params:
324 		///   check = a delegate checking for which object to check for. Return true to return this object & removing it from the backlog.
325 		///   returnIt = pass true to return the list of objects (GC), pass false to simply return an empty list and only remove from the backlog.
326 		Message[] fetchOldMessageLog(bool delegate(Message) check, bool returnIt = true);
327 		/// ditto
328 		Quit[] fetchOldQuitLog(bool delegate(Quit) check, bool returnIt = true);
329 		/// ditto
330 		TopicChange[] fetchOldTopicLog(bool delegate(TopicChange) check, bool returnIt = true);
331 		/// ditto
332 		MembershipEvent[] fetchOldMembershipLog(bool delegate(MembershipEvent) check,
333 				bool returnIt = true);
334 
335 		/// Clears all backlog & removes all event listeners for the object type.
336 		void clearMessageLog();
337 		/// ditto
338 		void clearQuitLog();
339 		/// ditto
340 		void clearTopicLog();
341 		/// ditto
342 		void clearMembershipLog();
343 	}
344 	else
345 	{
346 		mixin Processor!("Message", Message, 256);
347 		mixin Processor!("Quit", Quit, 256);
348 		mixin Processor!("Topic", TopicChange, 8);
349 		mixin Processor!("Membership", MembershipEvent, 8);
350 	}
351 
352 	///
353 	OsuRoom[] rooms;
354 	///
355 	TCPConnection client;
356 	/// Credentials to use for authentication when connecting.
357 	string username, password;
358 	/// IRC host to connect to.
359 	string host;
360 	/// IRC port to use to connect.
361 	ushort port;
362 	/// Event emitted on a private message to the bot.
363 	Event!Message onDirectMessage;
364 	/// true after the first line has been received.
365 	bool gotLine;
366 
367 	/// Set to true to slow down message sending to the limit allowed on osu.
368 	bool doRatelimit;
369 	/// Bancho ratelimiter instance.
370 	BanchoRatelimiter!() ratelimiter;
371 
372 	/// Prepares a bancho IRC connection with username & password (can be obtained from https://osu.ppy.sh/p/irc)
373 	this(string username, string password, string host = "irc.ppy.sh", ushort port = 6667)
374 	{
375 		if (!password.length)
376 			throw new Exception("Password can't be empty");
377 		this.username = username;
378 		this.password = password;
379 		this.host = host;
380 		this.port = port;
381 		ratelimiter.load();
382 	}
383 
384 	/// Clears all logs, called by connect
385 	void clear()
386 	{
387 		clearMessageLog();
388 		clearTopicLog();
389 		clearQuitLog();
390 		clearMembershipLog();
391 		gotLine = false;
392 	}
393 
394 	/// Connects to `this.host:this.port` (irc.ppy.sh:6667) and authenticates with username & password. Blocks and processes all messages sent by the TCP socket. Recommended to be called in runTask.
395 	/// Cleans up on exit properly and is safe to be called again once returned.
396 	/// Returns: false immediately if credentials are invalid or true after successful connection is closed.
397 	bool connect()
398 	{
399 		clear();
400 
401 		client = connectTCP(host, port);
402 		client.banchoAuthenticate(host, username, password);
403 		try
404 		{
405 			while (client.connected)
406 			{
407 				if (!client.waitForData)
408 					break;
409 				// :Stepan1404!cho@ppy.sh QUIT :quit
410 				char[] line = cast(char[]) client.readLine(1024, "\n");
411 				if (line.endsWith('\r'))
412 					line = line[0 .. $ - 1];
413 				logTrace("recv %s", line);
414 				auto parts = line.splitter(' ');
415 				size_t eaten = 0;
416 				auto user = parts.front;
417 				if (user == "PING")
418 				{
419 					line[1] = 'O'; // P[I]NG -> P[O]NG;
420 					client.sendLine(line);
421 					continue;
422 				}
423 				eaten += parts.front.length + 1;
424 				parts.popFront;
425 				auto cmd = parts.front;
426 				eaten += parts.front.length + 1;
427 				parts.popFront;
428 				if (isNumeric(cmd))
429 				{
430 					string lineDup = line.idup;
431 					auto cmdi = cmd.to!int;
432 					if (cmdi == 464)
433 						return false;
434 					runTask(&processNumeric, cmdi, lineDup, lineDup[eaten .. $]);
435 				}
436 				else if (cmd == "PART")
437 				{
438 					// >> :WebFreak!cho@ppy.sh PART :#mp_44448104
439 					if (user.extractUsername == username)
440 					{
441 						string channel = line[eaten + 1 .. $].idup;
442 						processMembership(MembershipEvent(channel, MembershipEvent.Type.part));
443 						disownChannel(channel, true);
444 					}
445 				}
446 				else if (cmd == "JOIN")
447 				{
448 					// >> :WebFreak!cho@ppy.sh JOIN :#mp_44448993
449 					if (user.extractUsername == username)
450 					{
451 						string channel = line[eaten + 1 .. $].idup;
452 						processMembership(MembershipEvent(channel, MembershipEvent.Type.join));
453 					}
454 				}
455 				else if (cmd == "QUIT")
456 					runTask(&processQuit, Quit(user.extractUsername.idup, line[eaten + 1 .. $].idup));
457 				else if (cmd == "PRIVMSG")
458 				{
459 					auto target = parts.front;
460 					eaten += parts.front.length + 1;
461 					parts.popFront;
462 					if (line[eaten] != ':')
463 						throw new Exception("Malformed message received: " ~ line.idup);
464 					runTask(&processMessage, Message(user.extractUsername.idup,
465 							target.idup, line[eaten + 1 .. $].idup));
466 				}
467 				else
468 					logDiagnostic("Unknown line %s", line.idup);
469 				gotLine = true;
470 			}
471 		}
472 		catch (Exception e)
473 		{
474 			logError("Exception in IRC task: %s", e);
475 		}
476 		if (client.connected)
477 			client.banchoQuit();
478 		client.close();
479 		return true;
480 	}
481 
482 	void waitUntilLoggedIn()
483 	{
484 		sleep(10.msecs);
485 		while (client.connected && !gotLine)
486 			sleep(10.msecs);
487 	}
488 
489 	~this()
490 	{
491 		disconnect();
492 	}
493 
494 	/// Disconnects & closes the TCP socket.
495 	void disconnect()
496 	{
497 		if (client.connected)
498 		{
499 			client.banchoQuit();
500 			client.close();
501 		}
502 	}
503 
504 	/// Processes messages meant for mutliplayer rooms to update their state.
505 	/// called by mixin template
506 	void preProcessMessage(Message message)
507 	{
508 		foreach (room; rooms)
509 			if (room.open && message.target == room.channel)
510 				runTask((OsuRoom room, Message message) { room.onMessage.emit(message); }, room, message);
511 		if (!message.target.startsWith("#"))
512 			runTask((Message message) { onDirectMessage.emit(message); }, message);
513 		if (message.sender != banchoBotNick)
514 			return;
515 		foreach (room; rooms)
516 		{
517 			if (room.open && message.target == room.channel)
518 			{
519 				try
520 				{
521 					if (message.message == "All players are ready")
522 					{
523 						runTask((OsuRoom room) { room.onPlayersReady.emit(); }, room);
524 						foreach (ref slot; room.slots)
525 							if (slot != OsuRoom.Settings.Player.init)
526 								slot.ready = true;
527 						break;
528 					}
529 					if (message.message == "Countdown finished")
530 					{
531 						runTask((OsuRoom room) {
532 							room.timerRunning = false;
533 							room.timerIsStart = false;
534 							room.onCountdownFinished.emit();
535 						}, room);
536 						break;
537 					}
538 					if (message.message == "Countdown aborted")
539 					{
540 						runTask((OsuRoom room) {
541 							room.timerRunning = false;
542 							room.onCountdownAborted.emit();
543 						}, room);
544 						break;
545 					}
546 					if (message.message == "Aborted the match")
547 					{
548 						runTask((OsuRoom room) {
549 							room.inProgress = false;
550 							room.onMatchAborted.emit();
551 						}, room);
552 						break;
553 					}
554 					if (message.message == "The match is not in progress")
555 					{
556 						runTask((OsuRoom room) {
557 							room.inProgress = false;
558 						}, room);
559 						break;
560 					}
561 					if (message.message == "Host is changing map...")
562 					{
563 						runTask((OsuRoom room) { room.onBeatmapPending.emit(); }, room);
564 						break;
565 					}
566 					if (message.message == "The match has started!")
567 					{
568 						runTask((OsuRoom room) {
569 							room.inProgress = true;
570 							room.timerRunning = false;
571 							room.timerIsStart = true;
572 							room.onMatchStart.emit();
573 						}, room);
574 						break;
575 					}
576 					if (message.message == "The match has finished!")
577 					{
578 						room.processMatchFinish();
579 						break;
580 					}
581 					if (message.message.startsWith("Beatmap changed to: "))
582 					{
583 						// Beatmap changed to: Ariabl'eyeS - Kegare Naki Bara Juuji (Short ver.) [Rose†kreuz] (https://osu.ppy.sh/b/1239875)
584 						room.onBeatmapChanged.emit(BeatmapInfo.parseChange(
585 								message.message["Beatmap changed to: ".length .. $]));
586 						break;
587 					}
588 					if (message.message.startsWith("Changed match to size "))
589 					{
590 						room.processSize(message.message["Changed match to size ".length .. $].strip.to!ubyte);
591 						break;
592 					}
593 					if (message.message.endsWith(" left the game."))
594 					{
595 						room.processLeave(message.message[0 .. $ - " left the game.".length]);
596 						break;
597 					}
598 					if (message.message.endsWith(" changed to Blue"))
599 					{
600 						room.processTeam(message.message[0 .. $ - " changed to Blue.".length], Team.Blue);
601 						break;
602 					}
603 					if (message.message.endsWith(" changed to Red"))
604 					{
605 						room.processTeam(message.message[0 .. $ - " changed to Red.".length], Team.Red);
606 						break;
607 					}
608 					if (message.message.endsWith(" became the host."))
609 					{
610 						room.processHost(message.message[0 .. $ - " became the host.".length]);
611 						break;
612 					}
613 					size_t index;
614 					if ((index = message.message.indexOf(" joined in slot ")) != -1)
615 					{
616 						if (message.message.endsWith("."))
617 							message.message.length--;
618 						string s = message.message[index + " joined in slot ".length .. $];
619 						Team team = Team.None;
620 						auto forTeam = s.indexOf(" for team ");
621 						if (forTeam != -1)
622 						{
623 							auto teamStr = s[forTeam + " for team ".length .. $];
624 							if (teamStr.asLowerCase.startsWith("blue"))
625 								team = Team.Blue;
626 							else if (teamStr.asLowerCase.startsWith("red"))
627 								team = Team.Red;
628 						}
629 						// UnlimitedGamez joined in slot 1 for team blue.
630 						// UnlimitedGamez joined in slot 2 for team red.
631 						room.processJoin(message.message[0 .. index], cast(ubyte)(s.parse!ubyte - 1), team);
632 						break;
633 					}
634 					if ((index = message.message.indexOf(" moved to slot ")) != -1)
635 					{
636 						if (message.message.endsWith("."))
637 							message.message.length--;
638 						room.processMove(message.message[0 .. index],
639 								cast(ubyte)(message.message[index + " moved to slot ".length .. $].to!int - 1));
640 						break;
641 					}
642 					if ((index = message.message.indexOf(" finished playing (Score: ")) != -1)
643 					{
644 						string user = message.message[0 .. index];
645 						long score = message.message[index
646 							+ " finished playing (Score: ".length .. $ - ", PASSED).".length].to!long;
647 						bool pass = message.message.endsWith("PASSED).");
648 						room.processFinishPlaying(user, score, pass);
649 						break;
650 					}
651 					break;
652 				}
653 				catch (Exception e)
654 				{
655 					if (!room.fatal)
656 					{
657 						room.sendMessage(
658 								"An internal exception occurred: " ~ e.msg ~ " in "
659 								~ e.file.baseName ~ ":" ~ e.line.to!string);
660 						room.fatal = true;
661 						logError("%s", e);
662 					}
663 					break;
664 				}
665 			}
666 		}
667 	}
668 
669 	void processNumeric(int num, string line, string relevantPart)
670 	{
671 		// internal function processing numeric commands
672 		if (num == 332)
673 		{
674 			// :cho.ppy.sh 332 WebFreak #mp_40121420 :multiplayer game #24545
675 			auto parts = relevantPart.splitter(' ');
676 			size_t eaten;
677 			if (parts.empty || parts.front != username)
678 			{
679 				logInfo("Received topic change not made for us?! %s", relevantPart);
680 				return;
681 			}
682 			eaten += parts.front.length + 1;
683 			parts.popFront;
684 			if (parts.empty)
685 			{
686 				logInfo("Received broken topic change?! %s", relevantPart);
687 				return;
688 			}
689 			string channel = parts.front;
690 			eaten += parts.front.length + 1;
691 			parts.popFront;
692 			if (parts.empty || !parts.front.length || parts.front[0] != ':')
693 			{
694 				logInfo("Malformed topic change: %s", relevantPart);
695 				return;
696 			}
697 			processTopic(TopicChange(channel, relevantPart[eaten + 1 .. $]));
698 		}
699 		else if (num == 403)
700 		{
701 			// << JOIN #mp_6515665
702 			// >> :cho.ppy.sh 403 WebFreak #mp_6515665 :No such channel #mp_6515665
703 			auto parts = relevantPart.splitter(' ');
704 			if (parts.empty || parts.front != username)
705 			{
706 				logInfo("Received 'no such channel' not made for us?! %s", relevantPart);
707 				return;
708 			}
709 			parts.popFront;
710 			if (parts.empty)
711 			{
712 				logInfo("Received broken 'no such channel'?! %s", relevantPart);
713 				return;
714 			}
715 			string channel = parts.front;
716 			disownChannel(channel, true);
717 			processMembership(MembershipEvent(channel, MembershipEvent.Type.notFound));
718 		}
719 		else
720 			logDebug("Got Numeric: %s %s", num, line);
721 	}
722 
723 	/// Sends a message to a username or channel (#channel).
724 	void sendMessage(string channel, in char[] message)
725 	{
726 		sendMessage(channel, message, doRatelimit);
727 	}
728 
729 	/// ditto
730 	void sendMessage(string channel, in char[] message, bool doRatelimit)
731 	{
732 		if (doRatelimit)
733 			ratelimit(channel, true);
734 		client.privMsg(channel, message.replace("\n", " "));
735 	}
736 
737 	/// Waits for multiple messages sent at once and returns them.
738 	/// Params:
739 	///   check = delegate to check if the message matches expectations (author, channel, etc)
740 	///   timeout = timeout to wait for first message
741 	///   totalTimeout = total time to spend starting waiting for messages
742 	///   inbetweenTimeout = timeout for a message after the first message. totalTimeout + inbetweenTimeout is the maximum amount of time this function runs.
743 	Message[] waitForMessageBunch(bool delegate(Message) check, Duration timeout,
744 			Duration totalTimeout = 5.seconds, Duration inbetweenTimeout = 300.msecs)
745 	{
746 		Message[] ret;
747 		try
748 		{
749 			StopWatch sw;
750 			sw.start();
751 			scope (exit)
752 				sw.stop();
753 			ret ~= waitForMessage(check, timeout);
754 			while (sw.peek < totalTimeout)
755 				ret ~= waitForMessage(check, inbetweenTimeout);
756 		}
757 		catch (InterruptException)
758 		{
759 		}
760 		return ret;
761 	}
762 
763 	/// Creates a new managed room with a title and returns it.
764 	/// Automatically gets room ID & game ID.
765 	/// Throws: Exception if there are too many rooms or if nothing got sent by bancho.
766 	/// Returns: the room if able to create.
767 	OsuRoom createRoom(string title)
768 	{
769 		sendMessage(banchoBotNick, "!mp make " ~ title);
770 		auto msg = this.waitForMessage(a => a.sender == banchoBotNick
771 				&& a.target == username && (a.message.endsWith(" " ~ title)
772 					|| a.message.startsWith("You cannot create any more")), 10.seconds).message;
773 		if (!msg.length)
774 			throw new Exception("Bancho didn't send anything valid");
775 		// You cannot create any more tournament matches. Please close any previous tournament matches you have open.
776 		if (msg.startsWith("You cannot create any more"))
777 			throw new Exception("Can't create any rooms anymore");
778 		// "Created the tournament match https://osu.ppy.sh/mp/40080950 bob"
779 		if (msg.startsWith("Created the tournament match "))
780 			msg = msg["Created the tournament match ".length .. $];
781 		msg = msg[0 .. $ - title.length - 1];
782 		if (msg.startsWith("https://osu.ppy.sh/mp/"))
783 			msg = msg["https://osu.ppy.sh/mp/".length .. $];
784 		msg = "#mp_" ~ msg;
785 		auto topic = this.waitForTopic(a => a.channel == msg
786 				&& a.topic.startsWith("multiplayer game #"), 500.msecs).topic;
787 		auto room = new OsuRoom(this, msg, topic["multiplayer game #".length .. $]);
788 		rooms ~= room;
789 		return room;
790 	}
791 
792 	/// Joins a room in IRC and creates the room object from it.
793 	/// Throws: InterruptException if bancho didn't respond or Exception if channel doesn't exist.
794 	/// Params:
795 	///     room = IRC Room name (starting with `#mp_`) where to send the messages in.
796 	///     game = optional string containing the osu://mp/ URL.
797 	OsuRoom fromUnmanaged(string room, string game = null)
798 	in
799 	{
800 		assert(room.startsWith("#mp_"));
801 	}
802 	do
803 	{
804 		joinChannel(room);
805 		auto type = this.waitForMembership(a => a.type.among!(MembershipEvent.Type.join,
806 				MembershipEvent.Type.notFound) && a.channel == room, 10.seconds).type;
807 		if (type == MembershipEvent.Type.notFound)
808 			throw new Exception("Channel " ~ room ~ " not found.");
809 		auto obj = new OsuRoom(this, room, game);
810 		rooms ~= obj;
811 		return obj;
812 	}
813 
814 	/// Sends a join command to a channel so messages can be sent in it.
815 	void joinChannel(string channel)
816 	{
817 		client.sendLine("JOIN " ~ channel);
818 	}
819 
820 	/// Removes a channel from the managed channels.
821 	void disownChannel(string channel, bool gotClosed = false)
822 	{
823 		foreach_reverse (i, room; rooms)
824 		{
825 			if (room.channel == channel)
826 			{
827 				rooms = rooms.remove(i);
828 				if (gotClosed)
829 					room.processClosed();
830 				break;
831 			}
832 		}
833 	}
834 
835 	/// internal function to remove a room from the managed rooms list
836 	void unmanageRoom(OsuRoom room)
837 	{
838 		rooms = rooms.remove!(a => a == room);
839 	}
840 
841 	/// Manually wait until you can send a message again in a given channel.
842 	/// Params:
843 	///    channel = The channel where a message would be sent (starting with #) or a username.
844 	///    put = if false, the time won't get written to the ratelimit and it will just be waited for it.
845 	void ratelimit(string channel, bool put)
846 	{
847 		ratelimiter.putWait(channel, put);
848 	}
849 }
850 
851 /*
852 >> :WebFreak!cho@ppy.sh JOIN :#mp_40121420
853 << WHO #mp_40121420
854 >> :BanchoBot!cho@cho.ppy.sh MODE #mp_40121420 +v WebFreak
855 >> :cho.ppy.sh 332 WebFreak #mp_40121420 :multiplayer game #24545
856 >> :cho.ppy.sh 333 WebFreak #mp_40121420 BanchoBot!BanchoBot@cho.ppy.sh 1518796852
857 >> :cho.ppy.sh 353 WebFreak = #mp_40121420 :@BanchoBot +WebFreak 
858 >> :cho.ppy.sh 366 WebFreak #mp_40121420 :End of /NAMES list.
859 >> :BanchoBot!cho@ppy.sh PRIVMSG WebFreak :Created the tournament match https://osu.ppy.sh/mp/40121420 bob
860 >> :cho.ppy.sh 324 WebFreak #mp_40121420 +nt
861 >> :cho.ppy.sh 329 WebFreak #mp_40121420 1518796852
862 >> :cho.ppy.sh 315 WebFreak #mp_40121420 :End of /WHO list.
863 << PRIVMSG #mp_40121420 :!mp close
864 >> :WebFreak!cho@ppy.sh PART :#mp_40121420
865 >> :BanchoBot!cho@ppy.sh PRIVMSG #mp_40121420 :Closed the match
866 */
867 
868 /*
869 <WebFreak> !mp invite WebFreak
870 <BanchoBot> Invited WebFreak to the room
871 <BanchoBot> WebFreak joined in slot 1.
872 <BanchoBot> WebFreak moved to slot 2
873 
874 <WebFreak> !mp host WebFreak
875 <BanchoBot> WebFreak became the host.
876 <BanchoBot> Changed match host to WebFreak
877 <BanchoBot> Beatmap changed to: bibuko - Reizouko Mitara Pudding ga Nai [Mythol's Pudding] (https://osu.ppy.sh/b/256839)
878 
879 <WebFreak> !mp mods FI
880 <BanchoBot> Enabled FadeIn, disabled FreeMod
881 
882 <WebFreak> !mp start
883 <BanchoBot> The match has started!
884 <BanchoBot> Started the match
885 <BanchoBot> WebFreak finished playing (Score: 487680, FAILED).
886 <BanchoBot> The match has finished!
887 
888 aborting (esc):
889 <BanchoBot> WebFreak finished playing (Score: 300, PASSED).
890 
891 <BanchoBot> WebFreak finished playing (Score: 113216, PASSED).
892 <BanchoBot> The match has finished!
893 
894 <BanchoBot> All players are ready
895 
896 <WebFreak> !mp start
897 <BanchoBot> The match has started!
898 <BanchoBot> Started the match
899 
900 <WebFreak> !mp abort
901 <BanchoBot> Aborted the match
902 
903 <BanchoBot> WebFreak moved to slot 3
904 <BanchoBot> WebFreak changed to Red
905 <BanchoBot> WebFreak changed to Blue
906 <BanchoBot> WebFreak moved to slot 1
907 
908 <BanchoBot> Host is changing map...
909 <BanchoBot> Beatmap changed to: Aitsuki Nakuru - Krewrap no uta [Easy] (https://osu.ppy.sh/b/1292635)
910 
911 <WebFreak> !mp settings
912 <BanchoBot> Room name: bob, History: https://osu.ppy.sh/mp/40081206
913 <BanchoBot> Beatmap: https://osu.ppy.sh/b/1292635 Aitsuki Nakuru - Krewrap no uta [Easy]
914 <BanchoBot> Team mode: HeadToHead, Win condition: Score
915 <BanchoBot> Active mods: Freemod
916 <BanchoBot> Players: 1
917 <BanchoBot> Slot 1  Not Ready https://osu.ppy.sh/u/1756786 WebFreak        [Host / Hidden]
918 
919 <WebFreak> !mp settings
920 <BanchoBot> Room name: bob, History: https://osu.ppy.sh/mp/40081206
921 <BanchoBot> Beatmap: https://osu.ppy.sh/b/1292635 Aitsuki Nakuru - Krewrap no uta [Easy]
922 <BanchoBot> Team mode: HeadToHead, Win condition: Score
923 <BanchoBot> Active mods: HalfTime, Freemod
924 <BanchoBot> Players: 1
925 <BanchoBot> Slot 1  Not Ready https://osu.ppy.sh/u/1756786 WebFreak        [Host / Hidden, HardRock, SuddenDeath]
926 
927 <WebFreak> !mp size 1
928 <BanchoBot> WebFreak left the game.
929 <BanchoBot> Changed match to size 1
930 <BanchoBot> WebFreak joined in slot 1.
931 */
932 
933 ///
934 enum TeamMode
935 {
936 	///
937 	HeadToHead,
938 	///
939 	TagCoop,
940 	///
941 	TeamVs,
942 	///
943 	TagTeamVs
944 }
945 
946 ///
947 enum ScoreMode
948 {
949 	///
950 	Score,
951 	///
952 	Accuracy,
953 	///
954 	Combo,
955 	///
956 	ScoreV2
957 }
958 
959 ///
960 enum Team
961 {
962 	/// default, used when mode is not TeamVs/TagTeamVs
963 	None,
964 	///
965 	Red,
966 	///
967 	Blue
968 }
969 
970 /// Represents a gamemode with integer mapping in the osu! api.
971 enum GameMode
972 {
973 	/// osu! standard mode
974 	osu,
975 	/// taiko mode
976 	taiko,
977 	/// catch the beat mode
978 	ctb,
979 	/// mania mode
980 	mania
981 }
982 
983 ///
984 enum Mod : string
985 {
986 	///
987 	Easy = "Easy",
988 	///
989 	NoFail = "NoFail",
990 	///
991 	HalfTime = "HalfTime",
992 	///
993 	HardRock = "HardRock",
994 	///
995 	SuddenDeath = "SuddenDeath",
996 	///
997 	DoubleTime = "DoubleTime",
998 	///
999 	Nightcore = "Nightcore",
1000 	///
1001 	Hidden = "Hidden",
1002 	///
1003 	FadeIn = "FadeIn",
1004 	///
1005 	Flashlight = "Flashlight",
1006 	///
1007 	Relax = "Relax",
1008 	///
1009 	Autopilot = "Relax2",
1010 	///
1011 	SpunOut = "SpunOut",
1012 	///
1013 	Key1 = "Key1",
1014 	///
1015 	Key2 = "Key2",
1016 	///
1017 	Key3 = "Key3",
1018 	///
1019 	Key4 = "Key4",
1020 	///
1021 	Key5 = "Key5",
1022 	///
1023 	Key6 = "Key6",
1024 	///
1025 	Key7 = "Key7",
1026 	///
1027 	Key8 = "Key8",
1028 	///
1029 	Key9 = "Key9",
1030 	///
1031 	KeyCoop = "KeyCoop",
1032 	///
1033 	ManiaRandom = "Random",
1034 	///
1035 	FreeMod = "FreeMod",
1036 }
1037 
1038 /// Generates the short form for a mod (eg Hidden -> HD), can be more than 2 characters
1039 string shortForm(Mod mod)
1040 {
1041 	//dfmt off
1042 	switch (mod)
1043 	{
1044 	case Mod.Easy: return "EZ";
1045 	case Mod.NoFail: return "NF";
1046 	case Mod.HalfTime: return "HT";
1047 	case Mod.HardRock: return "HR";
1048 	case Mod.SuddenDeath: return "SD";
1049 	case Mod.DoubleTime: return "DT";
1050 	case Mod.Nightcore: return "NC";
1051 	case Mod.Hidden: return "HD";
1052 	case Mod.FadeIn: return "FI";
1053 	case Mod.Flashlight: return "FL";
1054 	case Mod.Relax: return "RX";
1055 	case Mod.Autopilot: return "AP";
1056 	case Mod.SpunOut: return "SO";
1057 	case Mod.Key1: return "K1";
1058 	case Mod.Key2: return "K2";
1059 	case Mod.Key3: return "K3";
1060 	case Mod.Key4: return "K4";
1061 	case Mod.Key5: return "K5";
1062 	case Mod.Key6: return "K6";
1063 	case Mod.Key7: return "K7";
1064 	case Mod.Key8: return "K8";
1065 	case Mod.Key9: return "K9";
1066 	case Mod.KeyCoop: return "COOP";
1067 	case Mod.ManiaRandom: return "RN";
1068 	case Mod.FreeMod:
1069 	default: return mod;
1070 	}
1071 	//dfmt on
1072 }
1073 
1074 ///
1075 alias HighPriority = Flag!"highPriority";
1076 
1077 /// Represents a multiplayer lobby in osu!
1078 /// Automatically does ratelimiting by not sending more than a message every 2 seconds.
1079 ///
1080 /// All slot indices are 0 based.
1081 class OsuRoom // must be a class, don't change it
1082 {
1083 	/// Returned by !mp settings
1084 	struct Settings
1085 	{
1086 		/// Represents a player state in the settings result
1087 		struct Player
1088 		{
1089 			/// Player user information, may not be there except for name
1090 			string id, url, name;
1091 			///
1092 			bool ready;
1093 			///
1094 			bool playing;
1095 			///
1096 			bool noMap;
1097 			///
1098 			bool host;
1099 			/// If freemods is enabled this contains user specific mods
1100 			Mod[] mods;
1101 			///
1102 			Team team;
1103 
1104 			static Player userTeam(string name, Team team)
1105 			{
1106 				Player ret;
1107 				ret.name = name;
1108 				ret.team = team;
1109 				return ret;
1110 			}
1111 		}
1112 
1113 		/// Game name
1114 		string name;
1115 		/// URL to match history
1116 		string history;
1117 		/// Beatmap information
1118 		BeatmapInfo beatmap;
1119 		/// Global active mods or all mods if freemods is off, contains Mod.FreeMod if on
1120 		Mod[] activeMods;
1121 		/// Type of game (coop, tag team, etc.)
1122 		TeamMode teamMode;
1123 		/// Win condition (score, acc, combo, etc.)
1124 		ScoreMode winCondition;
1125 		/// Number of players in this match
1126 		int numPlayers;
1127 		/// All players for every slot (empty slots are Player.init)
1128 		Player[16] players;
1129 	}
1130 
1131 	private BanchoBot bot;
1132 	private string _channel, id;
1133 	private bool open;
1134 	private bool fatal;
1135 
1136 	/// Automatically managed state of player slots, empty slots are Player.init
1137 	Settings.Player[16] slots;
1138 	/// username as argument
1139 	Event!string onUserLeave;
1140 	/// username & team as argument
1141 	Event!(string, Team) onUserTeamChange;
1142 	/// username as argument
1143 	Event!string onUserHost;
1144 	/// username + slot (0 based) + team as argument
1145 	Event!(string, ubyte, Team) onUserJoin;
1146 	/// username + slot (0 based) as argument
1147 	Event!(string, ubyte) onUserMove;
1148 	/// emitted when all players are ready
1149 	Event!() onPlayersReady;
1150 	/// Match has started
1151 	Event!() onMatchStart;
1152 	/// Match has ended (all players finished)
1153 	Event!() onMatchEnd;
1154 	/// Host is changing beatmap
1155 	Event!() onBeatmapPending;
1156 	/// Host changed map
1157 	Event!BeatmapInfo onBeatmapChanged;
1158 	/// A message by anyone has been sent
1159 	Event!Message onMessage;
1160 	/// A timer finished
1161 	Event!() onCountdownFinished;
1162 	/// A countdown was aborted
1163 	Event!() onCountdownAborted;
1164 	/// The match was started but now aborted
1165 	Event!() onMatchAborted;
1166 	/// A user finished playing. username + score + passed
1167 	Event!(string, long, bool) onPlayerFinished;
1168 	/// The room has been closed
1169 	Event!() onClosed;
1170 
1171 	bool timerIsStart;
1172 	bool timerRunning;
1173 	bool inProgress;
1174 
1175 	private this(BanchoBot bot, string channel, string id)
1176 	{
1177 		assert(channel.startsWith("#mp_"));
1178 		this.bot = bot;
1179 		this._channel = channel;
1180 		this.id = id;
1181 		open = true;
1182 	}
1183 
1184 	ref Settings.Player slot(int index)
1185 	{
1186 		if (index < 0 || index >= 16)
1187 			throw new Exception("slot index out of bounds");
1188 		return slots[index];
1189 	}
1190 
1191 	bool hasPlayer(string name)
1192 	{
1193 		foreach (ref slot; slots)
1194 			if (slot.name == name)
1195 				return true;
1196 		return false;
1197 	}
1198 
1199 	ref Settings.Player playerByName(string name)
1200 	{
1201 		foreach (ref slot; slots)
1202 			if (slot.name == name)
1203 				return slot;
1204 		throw new Exception("player " ~ name ~ " not found!");
1205 	}
1206 
1207 	ref Settings.Player playerByName(string name, out size_t index)
1208 	{
1209 		foreach (i, ref slot; slots)
1210 			if (slot.name == name)
1211 			{
1212 				index = i;
1213 				return slot;
1214 			}
1215 		throw new Exception("player " ~ name ~ " not found!");
1216 	}
1217 
1218 	ubyte playerSlotByName(string name)
1219 	{
1220 		foreach (i, ref slot; slots)
1221 			if (slot.name == name)
1222 				return cast(ubyte) i;
1223 		throw new Exception("player " ~ name ~ " not found!");
1224 	}
1225 
1226 	/// Returns the channel name as on IRC
1227 	string channel() const @property
1228 	{
1229 		return _channel;
1230 	}
1231 
1232 	/// Returns the room ID as usable in the mp history URL or IRC joinable via #mp_ID
1233 	string room() const @property
1234 	{
1235 		return channel["#mp_".length .. $];
1236 	}
1237 
1238 	/// Returns the game ID as usable in osu://mp/ID urls
1239 	string mpid() const @property
1240 	{
1241 		return id;
1242 	}
1243 
1244 	/// Returns the BanchoBot instance used with this room.
1245 	BanchoBot bancho() @property
1246 	{
1247 		return bot;
1248 	}
1249 
1250 	/// Closes the room and calls processClosed.
1251 	void close()
1252 	{
1253 		if (!open)
1254 			return;
1255 		sendMessage("!mp close");
1256 		processClosed();
1257 	}
1258 
1259 	/// Adds a player as referee (can manage the room with !mp commands), also useful to allow room control to another bot.
1260 	/// See_Also: addRefs
1261 	void addRef(string player)
1262 	{
1263 		sendMessage("!mp addref " ~ player.fixUsername);
1264 	}
1265 
1266 	/// Removes a player from referees.
1267 	/// See_Also: removeRefs
1268 	void removeRef(string player)
1269 	{
1270 		sendMessage("!mp removeref " ~ player.fixUsername);
1271 	}
1272 
1273 	/// Adds players to referees.
1274 	/// See_Also: addRef
1275 	void addRefs(string[] players)
1276 	{
1277 		if (players.length)
1278 			sendMessage("!mp addref " ~ players.map!(a => a.fixUsername).join(", "));
1279 	}
1280 
1281 	/// Removes players from referees.
1282 	/// See_Also: removeRef
1283 	void removeRefs(string[] players)
1284 	{
1285 		if (players.length)
1286 			sendMessage("!mp removeref " ~ players.map!(a => a.fixUsername).join(", "));
1287 	}
1288 
1289 	/// Invites a player to the room
1290 	void invite(string player)
1291 	{
1292 		sendMessage("!mp invite " ~ player.fixUsername);
1293 	}
1294 
1295 	/// Kicks a player from the room
1296 	void kick(string player)
1297 	{
1298 		sendMessage("!mp kick " ~ player.fixUsername);
1299 	}
1300 
1301 	/// Moves a player to another slot
1302 	void move(string player, int slot)
1303 	{
1304 		sendMessage("!mp move " ~ player.fixUsername ~ " " ~ (slot + 1).to!string);
1305 	}
1306 
1307 	/// Gives host to a player
1308 	void host(string player) @property
1309 	{
1310 		sendMessage("!mp host " ~ player.fixUsername);
1311 	}
1312 
1313 	/// Makes nobody host (make it system/bog managed)
1314 	void clearhost()
1315 	{
1316 		sendMessage("!mp clearhost");
1317 	}
1318 
1319 	/// Property to lock slots (disallow changing slots & joining)
1320 	void locked(bool locked) @property
1321 	{
1322 		sendMessage(locked ? "!mp lock" : "!mp unlock");
1323 	}
1324 
1325 	/// Sets the match password (password will be visible to existing players)
1326 	void password(string pw) @property
1327 	{
1328 		sendMessage("!mp password " ~ pw);
1329 	}
1330 
1331 	/// Changes a user's team
1332 	void setTeam(string user, Team team)
1333 	{
1334 		sendMessage("!mp team " ~ user.fixUsername ~ " " ~ team.to!string);
1335 	}
1336 
1337 	/// Changes the slot limit of this lobby
1338 	void size(ubyte slots) @property
1339 	{
1340 		sendMessage("!mp size " ~ slots.to!string);
1341 	}
1342 
1343 	/// Sets up teammode, scoremode & lobby size
1344 	void set(TeamMode teammode, ScoreMode scoremode, ubyte size)
1345 	{
1346 		sendMessage("!mp set " ~ (cast(int) teammode)
1347 				.to!string ~ " " ~ (cast(int) scoremode).to!string ~ " " ~ size.to!string);
1348 	}
1349 
1350 	/// Changes the mods in this lobby (pass FreeMod first if you want FreeMod)
1351 	void mods(Mod[] mods) @property
1352 	{
1353 		sendMessage("!mp mods " ~ mods.map!(a => a.shortForm).join(" "));
1354 	}
1355 
1356 	/// Changes the map to a beatmap ID (b/ url)
1357 	void map(string id) @property
1358 	{
1359 		sendMessage("!mp map " ~ id);
1360 	}
1361 
1362 	/// Changes the map to a beatmap ID (b/ url) with gamemode
1363 	void mapAndMode(string id, GameMode mode) @property
1364 	{
1365 		sendMessage("!mp map " ~ id ~ " " ~ (cast(int) mode).to!string);
1366 	}
1367 
1368 	/// Sets a timer using !mp timer
1369 	void setTimer(Duration d)
1370 	{
1371 		if (d.total!"seconds" > 0)
1372 		{
1373 			sendMessage("!mp timer " ~ d.total!"seconds"
1374 					.to!string);
1375 			timerRunning = true;
1376 			timerIsStart = false;
1377 		}
1378 	}
1379 
1380 	/// Waits for a player to join the room & return the username
1381 	/// Throws: InterruptException if timeout triggers
1382 	string waitForJoin(Duration timeout)
1383 	{
1384 		auto l = bot.waitForMessage(a => a.target == channel && a.sender == banchoBotNick
1385 				&& a.message.canFind(" joined in slot "), timeout).message;
1386 		auto i = l.indexOf(" joined in slot ");
1387 		return l[0 .. i];
1388 	}
1389 
1390 	/// Waits for an existing timer/countdown to finish (wont start one)
1391 	/// Throws: InterruptException if timeout triggers
1392 	void waitForTimer(Duration timeout)
1393 	{
1394 		bot.waitForMessage(a => a.target == channel && a.sender == banchoBotNick
1395 				&& a.message == "Countdown finished", timeout);
1396 	}
1397 
1398 	/// Aborts any running countdown
1399 	void abortTimer()
1400 	{
1401 		sendMessage("!mp aborttimer");
1402 		timerRunning = false;
1403 	}
1404 
1405 	/// Aborts a running match
1406 	/// Returns: true if the match was running and got aborted or after a timeout of 3 seconds.
1407 	bool abortMatch()
1408 	{
1409 		sendMessage("!mp abort");
1410 		auto msg = bot.waitForMessage(a => a.target == channel && a.sender == banchoBotNick
1411 				&& (a.message == "The match is not in progress" || a.message == "Aborted the match"),
1412 				3.seconds);
1413 
1414 		return msg.message != "The match is not in progress";
1415 	}
1416 
1417 	/// Starts a match after a specified amount of seconds. If after is <= 0 the game will be started immediately.
1418 	/// The timeout can be canceled using abortTimer.
1419 	void start(Duration after = Duration.zero)
1420 	{
1421 		if (after <= Duration.zero)
1422 			sendMessage("!mp start");
1423 		else
1424 		{
1425 			timerRunning = true;
1426 			timerIsStart = true;
1427 			sendMessage("!mp start " ~ after.total!"seconds"
1428 					.to!string);
1429 		}
1430 	}
1431 
1432 	/// Manually wait until you can send a message again.
1433 	/// Params:
1434 	///    put = if false, the time won't get written to the ratelimit and it will just be waited for it.
1435 	void ratelimit(bool put)
1436 	{
1437 		bot.ratelimit(channel, put);
1438 	}
1439 
1440 	/// Sends a message with a 2 second ratelimit
1441 	/// Params:
1442 	///   message = raw message to send
1443 	void sendMessage(in char[] message)
1444 	{
1445 		if (!open)
1446 			throw new Exception("Attempted to send message in closed room");
1447 		ratelimit(true);
1448 		bot.sendMessage(channel, message, false);
1449 	}
1450 
1451 	/// Returns the current mp settings
1452 	Settings settings() @property
1453 	{
1454 		int step = 0;
1455 	Retry:
1456 		bot.fetchOldMessageLog(a => a.target == channel && a.sender == banchoBotNick
1457 				&& a.message.isSettingsMessage, false);
1458 		sendMessage("!mp settings");
1459 		auto msgs = bot.waitForMessageBunch(a => a.target == channel
1460 				&& a.sender == banchoBotNick && a.message.isSettingsMessage, 10.seconds,
1461 				10.seconds, 400.msecs);
1462 		if (!msgs.length)
1463 			return Settings.init;
1464 		Settings settings;
1465 		settings.numPlayers = -1;
1466 		int foundPlayers;
1467 		SettingsLoop: foreach (msg; msgs)
1468 		{
1469 			if (msg.message.startsWith("Room name: "))
1470 			{
1471 				// Room name: bob, History: https://osu.ppy.sh/mp/40123558
1472 				msg.message = msg.message["Room name: ".length .. $];
1473 				auto end = msg.message.indexOf(", History: ");
1474 				if (end != -1)
1475 				{
1476 					settings.name = msg.message[0 .. end];
1477 					settings.history = msg.message[end + ", History: ".length .. $];
1478 				}
1479 			}
1480 			else if (msg.message.startsWith("Beatmap: "))
1481 			{
1482 				// Beatmap: https://osu.ppy.sh/b/972293 Ayane - FaV -F*** and Vanguard- [Normal]
1483 				msg.message = msg.message["Beatmap: ".length .. $];
1484 				auto space = msg.message.indexOf(" ");
1485 				if (space != -1)
1486 				{
1487 					settings.beatmap.url = msg.message[0 .. space];
1488 					if (settings.beatmap.url.startsWith("https://osu.ppy.sh/b/"))
1489 						settings.beatmap.id = settings.beatmap.url["https://osu.ppy.sh/b/".length .. $];
1490 					else
1491 						settings.beatmap.id = "";
1492 					settings.beatmap.name = msg.message[space + 1 .. $];
1493 				}
1494 			}
1495 			else if (msg.message.startsWith("Team mode: "))
1496 			{
1497 				// Team mode: TeamVs, Win condition: ScoreV2
1498 				msg.message = msg.message["Team mode: ".length .. $];
1499 				auto comma = msg.message.indexOf(", Win condition: ");
1500 				if (comma != -1)
1501 				{
1502 					settings.teamMode = msg.message[0 .. comma].to!TeamMode;
1503 					settings.winCondition = msg.message[comma + ", Win condition: ".length .. $]
1504 						.to!ScoreMode;
1505 				}
1506 			}
1507 			else if (msg.message.startsWith("Active mods: "))
1508 			{
1509 				// Active mods: Hidden, DoubleTime
1510 				settings.activeMods = msg.message["Active mods: ".length .. $].splitter(", ")
1511 					.map!(a => cast(Mod) a).array;
1512 			}
1513 			else if (msg.message.startsWith("Players: "))
1514 			{
1515 				// Players: 1
1516 				settings.numPlayers = msg.message["Players: ".length .. $].to!int;
1517 			}
1518 			else if (msg.message.startsWith("Slot "))
1519 			{
1520 				foundPlayers++;
1521 				// Slot 1  Not Ready https://osu.ppy.sh/u/1756786 WebFreak        [Host / Team Blue / Hidden, HardRock]
1522 				// Slot 1  Ready     https://osu.ppy.sh/u/1756786 WebFreak        [Host / Team Blue / NoFail, Hidden, HardRock]
1523 				//"Slot 1  Not Ready https://osu.ppy.sh/u/1756786 WebFreak        "
1524 				if (msg.message.length < SettingsLineMinLength)
1525 					continue;
1526 				auto num = msg.message[5 .. 7].strip.to!int;
1527 				msg.message = msg.message.stripLeft;
1528 				if (num >= 1 && num <= 16)
1529 				{
1530 					auto index = num - 1;
1531 					settings.players[index].ready = msg.message[8 .. 17] == "Ready    ";
1532 					settings.players[index].noMap = msg.message[8 .. 17] == "No Map   ";
1533 					settings.players[index].url = msg.message[18 .. $];
1534 					auto space = settings.players[index].url.indexOf(' ');
1535 					if (space == -1)
1536 						continue;
1537 					auto rest = settings.players[index].url[space + 1 .. $];
1538 					settings.players[index].url.length = space;
1539 					settings.players[index].id = settings.players[index].url[settings.players[index].url.lastIndexOf(
1540 							'/') + 1 .. $];
1541 					auto bracket = rest.indexOf("[", 16);
1542 					if (bracket == -1)
1543 						settings.players[index].name = rest.stripRight;
1544 					else
1545 					{
1546 						settings.players[index].name = rest[0 .. bracket].stripRight;
1547 						auto extra = rest[bracket + 1 .. $];
1548 						if (extra.endsWith("]"))
1549 							extra.length--;
1550 						foreach (part; extra.splitter(" / "))
1551 						{
1552 							if (part == "Host")
1553 								settings.players[index].host = true;
1554 							else if (part.startsWith("Team "))
1555 								settings.players[index].team = part["Team ".length .. $].strip.to!Team;
1556 							else if (part == "None")
1557 								settings.players[index].mods = null;
1558 							else
1559 								settings.players[index].mods = part.splitter(", ").map!(a => cast(Mod) a).array;
1560 						}
1561 					}
1562 				}
1563 			}
1564 		}
1565 		if ((foundPlayers < settings.numPlayers || settings.numPlayers == -1) && ++step < 5)
1566 		{
1567 			msgs = bot.waitForMessageBunch(a => a.target == channel && a.sender == banchoBotNick
1568 					&& a.message.isSettingsMessage, 3.seconds, 3.seconds, 600.msecs);
1569 			if (msgs.length)
1570 				goto SettingsLoop;
1571 		}
1572 		if (foundPlayers && settings.numPlayers <= 0 && step < 5)
1573 			goto Retry;
1574 		if (settings.numPlayers == -1)
1575 			return settings;
1576 		slots = settings.players;
1577 		return settings;
1578 	}
1579 
1580 	/// Processes a user leave event & updates the state
1581 	void processLeave(string user)
1582 	{
1583 		try
1584 		{
1585 			playerByName(user) = Settings.Player.init;
1586 			runTask({ onUserLeave.emit(user); });
1587 		}
1588 		catch (Exception)
1589 		{
1590 		}
1591 	}
1592 
1593 	/// Processes a user team switch event & updates the state
1594 	void processTeam(string user, Team team)
1595 	{
1596 		try
1597 		{
1598 			playerByName(user).team = team;
1599 			runTask({ onUserTeamChange.emit(user, team); });
1600 		}
1601 		catch (Exception)
1602 		{
1603 		}
1604 	}
1605 
1606 	/// Processes a user host event & updates the state
1607 	void processHost(string user)
1608 	{
1609 		foreach (ref slot; slots)
1610 			slot.host = false;
1611 		try
1612 		{
1613 			playerByName(user).host = true;
1614 			runTask({ onUserHost.emit(user); });
1615 		}
1616 		catch (Exception)
1617 		{
1618 		}
1619 	}
1620 
1621 	/// Processes a user join event & updates the state
1622 	void processJoin(string user, ubyte slot, Team team)
1623 	{
1624 		this.slot(slot) = Settings.Player.userTeam(user, team);
1625 		runTask({ onUserJoin.emit(user, slot, team); });
1626 	}
1627 
1628 	/// Processes a user move event & updates the state
1629 	void processMove(string user, ubyte slot)
1630 	{
1631 		if (this.slot(slot) != Settings.Player.init)
1632 			throw new Exception("slot was occupied");
1633 		size_t old;
1634 		this.slot(slot) = playerByName(user, old);
1635 		this.slot(cast(int) old) = Settings.Player.init;
1636 		runTask({ onUserMove.emit(user, slot); });
1637 	}
1638 
1639 	/// Processes a room size change event & updates the state
1640 	void processSize(ubyte numSlots)
1641 	{
1642 		foreach (i; numSlots + 1 .. 16)
1643 			if (slots[i] != Settings.Player.init)
1644 			{
1645 				runTask((string user) { onUserLeave.emit(user); }, slots[i].name);
1646 				slots[i] = Settings.Player.init;
1647 			}
1648 	}
1649 
1650 	/// Processes a match end event & updates the state
1651 	void processMatchFinish()
1652 	{
1653 		foreach (ref slot; slots)
1654 			if (slot != Settings.Player.init)
1655 				slot.playing = false;
1656 		inProgress = false;
1657 		runTask({ onMatchEnd.emit(); });
1658 	}
1659 
1660 	/// Processes a user finish playing event & updates the state
1661 	void processFinishPlaying(string player, long score, bool pass)
1662 	{
1663 		playerByName(player).playing = false;
1664 		inProgress = false;
1665 		runTask({ onPlayerFinished.emit(player, score, pass); });
1666 	}
1667 
1668 	/// Processes a room closed event
1669 	void processClosed()
1670 	{
1671 		open = false;
1672 		bot.unmanageRoom(this);
1673 		runTask({ onClosed.emit(); });
1674 	}
1675 
1676 	/// Returns: false if a close has been processed or close() has been called.
1677 	bool isOpen() @property
1678 	{
1679 		return open;
1680 	}
1681 }
1682 
1683 bool isSettingsMessage(string msg)
1684 {
1685 	return msg.startsWith("Room name: ") || msg.startsWith("Beatmap: ")
1686 		|| msg.startsWith("Team mode: ") || msg.startsWith("Active mods: ")
1687 		|| msg.startsWith("Players: ") || (msg.startsWith("Slot ")
1688 				&& msg.length >= SettingsLineMinLength
1689 				&& (msg["Slot ".length .. $].front >= '0' && msg["Slot ".length .. $].front <= '9'));
1690 }
1691 
1692 ///
1693 unittest
1694 {
1695 	BanchoBot banchoConnection = new BanchoBot("WebFreak", "");
1696 	bool running = true;
1697 	auto botTask = runTask({
1698 		while (running)
1699 		{
1700 			banchoConnection.connect();
1701 			logDiagnostic("Got disconnected from bancho...");
1702 			sleep(2.seconds);
1703 		}
1704 	});
1705 	sleep(3.seconds);
1706 	auto users = ["WebFreak", "Node"];
1707 	OsuRoom room = banchoConnection.createRoom("bob");
1708 	runTask({
1709 		foreach (user; users)
1710 			room.invite(user);
1711 	});
1712 	runTask({
1713 		room.password = "123456";
1714 		room.size = 8;
1715 		room.mods = [Mod.Hidden, Mod.DoubleTime];
1716 		room.map = "1158325";
1717 	});
1718 	runTask({
1719 		int joined;
1720 		try
1721 		{
1722 			while (true)
1723 			{
1724 				string user = room.waitForJoin(30.seconds);
1725 				joined++;
1726 				room.sendMessage("yay welcome " ~ user ~ "!", HighPriority.yes);
1727 			}
1728 		}
1729 		catch (InterruptException)
1730 		{
1731 			if (joined == 0)
1732 			{
1733 				// forever alone
1734 				room.close();
1735 				return;
1736 			}
1737 		}
1738 		room.sendMessage("This is an automated test, this room will close in 10 seconds on timer");
1739 		room.setTimer(10.seconds);
1740 		try
1741 		{
1742 			room.waitForTimer(15.seconds);
1743 		}
1744 		catch (InterruptException)
1745 		{
1746 			room.sendMessage("Timer didn't trigger :(");
1747 			room.sendMessage("closing the room in 5s");
1748 			sleep(5.seconds);
1749 		}
1750 		room.close();
1751 	}).join();
1752 	running = false;
1753 	banchoConnection.disconnect();
1754 	botTask.join();
1755 }