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