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 	/// Event emitted on a private message to the bot.
324 	Event!Message onDirectMessage;
325 
326 	/// Prepares a bancho IRC connection with username & password (can be obtained from https://osu.ppy.sh/p/irc)
327 	this(string username, string password, string host = "irc.ppy.sh", ushort port = 6667)
328 	{
329 		if (!password.length)
330 			throw new Exception("Password can't be empty");
331 		this.username = username;
332 		this.password = password;
333 		this.host = host;
334 		this.port = port;
335 	}
336 
337 	/// Clears all logs, called by connect
338 	void clear()
339 	{
340 		clearMessageLog();
341 		clearTopicLog();
342 		clearQuitLog();
343 	}
344 
345 	/// 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.
346 	/// Cleans up on exit properly and is safe to be called again once returned.
347 	void connect()
348 	{
349 		clear();
350 
351 		client = connectTCP(host, port);
352 		client.banchoAuthenticate(username, password);
353 		try
354 		{
355 			while (client.connected)
356 			{
357 				if (!client.waitForData)
358 					break;
359 				char[] line = cast(char[]) client.readLine(1024, "\n");
360 				if (line.endsWith('\r'))
361 					line = line[0 .. $ - 1];
362 				logTrace("recv %s", line);
363 				auto parts = line.splitter(' ');
364 				size_t eaten = 0;
365 				auto user = parts.front;
366 				if (user == "PING")
367 				{
368 					line[1] = 'O'; // P[I]NG -> P[O]NG;
369 					client.sendLine(line);
370 					continue;
371 				}
372 				eaten += parts.front.length + 1;
373 				parts.popFront;
374 				auto cmd = parts.front;
375 				eaten += parts.front.length + 1;
376 				parts.popFront;
377 				if (isNumeric(cmd))
378 				{
379 					string lineDup = line.idup;
380 					runTask(&processNumeric, cmd.to!int, lineDup, lineDup[eaten .. $]);
381 				}
382 				else if (cmd == "QUIT")
383 					runTask(&processQuit, Quit(user.extractUsername.idup, line[eaten + 1 .. $].idup));
384 				else if (cmd == "PRIVMSG")
385 				{
386 					auto target = parts.front;
387 					eaten += parts.front.length + 1;
388 					parts.popFront;
389 					if (line[eaten] != ':')
390 						throw new Exception("Malformed message received: " ~ line.idup);
391 					runTask(&processMessage, Message(user.extractUsername.idup,
392 							target.idup, line[eaten + 1 .. $].idup));
393 				}
394 				else
395 					logDiagnostic("Unknown line %s", line.idup);
396 			}
397 		}
398 		catch (Exception e)
399 		{
400 			logError("Exception in IRC task: %s", e);
401 		}
402 		if (client.connected)
403 			client.banchoQuit();
404 		client.close();
405 	}
406 
407 	~this()
408 	{
409 		disconnect();
410 	}
411 
412 	/// Disconnects & closes the TCP socket.
413 	void disconnect()
414 	{
415 		if (client.connected)
416 		{
417 			client.banchoQuit();
418 			client.close();
419 		}
420 	}
421 
422 	/// Processes messages meant for mutliplayer rooms to update their state.
423 	/// called by mixin template
424 	void preProcessMessage(Message message)
425 	{
426 		foreach (room; rooms)
427 			if (room.open && message.target == room.channel)
428 				runTask((OsuRoom room, Message message) { room.onMessage.emit(message); }, room, message);
429 		if (!message.target.startsWith("#"))
430 			runTask((Message message) { onDirectMessage.emit(message); }, message);
431 		if (message.sender != banchoBotNick)
432 			return;
433 		foreach (room; rooms)
434 		{
435 			if (room.open && message.target == room.channel)
436 			{
437 				try
438 				{
439 					if (message.message == "All players are ready")
440 					{
441 						runTask((OsuRoom room) { room.onPlayersReady.emit(); }, room);
442 						foreach (ref slot; room.slots)
443 							if (slot != OsuRoom.Settings.Player.init)
444 								slot.ready = true;
445 						break;
446 					}
447 					if (message.message == "Countdown finished")
448 					{
449 						runTask((OsuRoom room) { room.onCountdownFinished.emit(); }, room);
450 						break;
451 					}
452 					if (message.message == "Host is changing map...")
453 					{
454 						runTask((OsuRoom room) { room.onBeatmapPending.emit(); }, room);
455 						break;
456 					}
457 					if (message.message == "The match has started!")
458 					{
459 						runTask((OsuRoom room) { room.onMatchStart.emit(); }, room);
460 						break;
461 					}
462 					if (message.message == "The match has finished!")
463 					{
464 						room.processMatchFinish();
465 						break;
466 					}
467 					if (message.message.startsWith("Beatmap changed to: "))
468 					{
469 						// Beatmap changed to: Ariabl'eyeS - Kegare Naki Bara Juuji (Short ver.) [Rose†kreuz] (https://osu.ppy.sh/b/1239875)
470 						room.onBeatmapChanged.emit(BeatmapInfo.parseChange(
471 								message.message["Beatmap changed to: ".length .. $]));
472 						break;
473 					}
474 					if (message.message.startsWith("Changed match to size "))
475 					{
476 						room.processSize(message.message["Changed match to size ".length .. $].strip.to!ubyte);
477 						break;
478 					}
479 					if (message.message.endsWith(" left the game."))
480 					{
481 						room.processLeave(message.message[0 .. $ - " left the game.".length]);
482 						break;
483 					}
484 					if (message.message.endsWith(" changed to Blue"))
485 					{
486 						room.processTeam(message.message[0 .. $ - " changed to Blue.".length], Team.Blue);
487 						break;
488 					}
489 					if (message.message.endsWith(" changed to Red"))
490 					{
491 						room.processTeam(message.message[0 .. $ - " changed to Red.".length], Team.Red);
492 						break;
493 					}
494 					if (message.message.endsWith(" became the host."))
495 					{
496 						room.processHost(message.message[0 .. $ - " became the host.".length]);
497 						break;
498 					}
499 					size_t index;
500 					if ((index = message.message.indexOf(" joined in slot ")) != -1)
501 					{
502 						if (message.message.endsWith("."))
503 							message.message.length--;
504 						string s = message.message[index + " joined in slot ".length .. $];
505 						// TODO: add team
506 						room.processJoin(message.message[0 .. index], cast(ubyte)(s.parse!ubyte - 1));
507 						break;
508 					}
509 					if ((index = message.message.indexOf(" moved to slot ")) != -1)
510 					{
511 						if (message.message.endsWith("."))
512 							message.message.length--;
513 						room.processMove(message.message[0 .. index],
514 								cast(ubyte)(message.message[index + " moved to slot ".length .. $].to!int - 1));
515 						break;
516 					}
517 					if ((index = message.message.indexOf(" finished playing (Score: ")) != -1)
518 					{
519 						string user = message.message[0 .. index];
520 						long score = message.message[index
521 							+ " finished playing (Score: ".length .. $ - ", PASSED).".length].to!long;
522 						bool pass = message.message.endsWith("PASSED).");
523 						room.processFinishPlaying(user, score, pass);
524 						break;
525 					}
526 					if (message.message == "Closed the match")
527 					{
528 						room.processClosed();
529 						break;
530 					}
531 					break;
532 				}
533 				catch (Exception e)
534 				{
535 					if (!room.fatal)
536 					{
537 						room.sendMessage(
538 								"An internal exception occurred: " ~ e.msg ~ " in "
539 								~ e.file.baseName ~ ":" ~ e.line.to!string);
540 						room.fatal = true;
541 						logError("%s", e);
542 					}
543 					break;
544 				}
545 			}
546 		}
547 	}
548 
549 	void processNumeric(int num, string line, string relevantPart)
550 	{
551 		// internal function processing numeric commands
552 		if (num == 332)
553 		{
554 			// :cho.ppy.sh 332 WebFreak #mp_40121420 :multiplayer game #24545
555 			auto parts = relevantPart.splitter(' ');
556 			size_t eaten;
557 			if (parts.empty || parts.front != username)
558 			{
559 				logInfo("Received topic change not made for us?!");
560 				return;
561 			}
562 			eaten += parts.front.length + 1;
563 			parts.popFront;
564 			if (parts.empty)
565 			{
566 				logInfo("Received topic change not made for us?!");
567 				return;
568 			}
569 			string channel = parts.front;
570 			eaten += parts.front.length + 1;
571 			parts.popFront;
572 			if (parts.empty || !parts.front.length || parts.front[0] != ':')
573 			{
574 				logInfo("Malformed topic change");
575 				return;
576 			}
577 			processTopic(TopicChange(channel, relevantPart[eaten + 1 .. $]));
578 		}
579 		else
580 			logDebug("Got Numeric: %s %s", num, line);
581 	}
582 
583 	/// Sends a message to a username or channel (#channel).
584 	void sendMessage(string channel, in char[] message)
585 	{
586 		client.privMsg(channel, message.replace("\n", " "));
587 	}
588 
589 	/// Waits for multiple messages sent at once and returns them.
590 	/// Params:
591 	///   check = delegate to check if the message matches expectations (author, channel, etc)
592 	///   timeout = timeout to wait for first message
593 	///   totalTimeout = total time to spend starting waiting for messages
594 	///   inbetweenTimeout = timeout for a message after the first message. totalTimeout + inbetweenTimeout is the maximum amount of time this function runs.
595 	Message[] waitForMessageBunch(bool delegate(Message) check, Duration timeout,
596 			Duration totalTimeout = 5.seconds, Duration inbetweenTimeout = 300.msecs)
597 	{
598 		Message[] ret;
599 		try
600 		{
601 			StopWatch sw;
602 			sw.start();
603 			scope (exit)
604 				sw.stop();
605 			ret ~= waitForMessage(check, timeout);
606 			while (sw.peek < totalTimeout)
607 				ret ~= waitForMessage(check, inbetweenTimeout);
608 		}
609 		catch (InterruptException)
610 		{
611 		}
612 		return ret;
613 	}
614 
615 	/// Creates a new managed room with a title and returns it.
616 	/// Automatically gets room ID & game ID.
617 	OsuRoom createRoom(string title)
618 	{
619 		sendMessage(banchoBotNick, "!mp make " ~ title);
620 		auto msg = this.waitForMessage(a => a.sender == banchoBotNick
621 				&& a.target == username && a.message.endsWith(" " ~ title), 10.seconds).message;
622 		if (!msg.length)
623 			return null;
624 		// "Created the tournament match https://osu.ppy.sh/mp/40080950 bob"
625 		if (msg.startsWith("Created the tournament match "))
626 			msg = msg["Created the tournament match ".length .. $];
627 		msg = msg[0 .. $ - title.length - 1];
628 		if (msg.startsWith("https://osu.ppy.sh/mp/"))
629 			msg = msg["https://osu.ppy.sh/mp/".length .. $];
630 		msg = "#mp_" ~ msg;
631 		auto topic = this.waitForTopic(a => a.channel == msg
632 				&& a.topic.startsWith("multiplayer game #"), 500.msecs).topic;
633 		auto room = new OsuRoom(this, msg, topic["multiplayer game #".length .. $]);
634 		rooms ~= room;
635 		return room;
636 	}
637 
638 	/// Joins a room in IRC and creates the room object from it.
639 	/// Params:
640 	///     room = IRC Room name (starting with `#mp_`) where to send the messages in.
641 	///     game = optional string containing the osu://mp/ URL.
642 	OsuRoom fromUnmanaged(string room, string game = null)
643 	in
644 	{
645 		assert(room.startsWith("#mp_"));
646 	}
647 	do
648 	{
649 		client.sendLine("JOIN " ~ room);
650 		auto obj = new OsuRoom(this, room, game);
651 		rooms ~= obj;
652 		return obj;
653 	}
654 
655 	/// internal function to remove a room from the managed rooms list
656 	void unmanageRoom(OsuRoom room)
657 	{
658 		rooms = rooms.remove!(a => a == room, SwapStrategy.unstable);
659 	}
660 }
661 
662 /*
663 >> :WebFreak!cho@ppy.sh JOIN :#mp_40121420
664 << WHO #mp_40121420
665 >> :BanchoBot!cho@cho.ppy.sh MODE #mp_40121420 +v WebFreak
666 >> :cho.ppy.sh 332 WebFreak #mp_40121420 :multiplayer game #24545
667 >> :cho.ppy.sh 333 WebFreak #mp_40121420 BanchoBot!BanchoBot@cho.ppy.sh 1518796852
668 >> :cho.ppy.sh 353 WebFreak = #mp_40121420 :@BanchoBot +WebFreak 
669 >> :cho.ppy.sh 366 WebFreak #mp_40121420 :End of /NAMES list.
670 >> :BanchoBot!cho@ppy.sh PRIVMSG WebFreak :Created the tournament match https://osu.ppy.sh/mp/40121420 bob
671 >> :cho.ppy.sh 324 WebFreak #mp_40121420 +nt
672 >> :cho.ppy.sh 329 WebFreak #mp_40121420 1518796852
673 >> :cho.ppy.sh 315 WebFreak #mp_40121420 :End of /WHO list.
674 << PRIVMSG #mp_40121420 :!mp close
675 >> :WebFreak!cho@ppy.sh PART :#mp_40121420
676 >> :BanchoBot!cho@ppy.sh PRIVMSG #mp_40121420 :Closed the match
677 */
678 
679 /*
680 <WebFreak> !mp invite WebFreak
681 <BanchoBot> Invited WebFreak to the room
682 <BanchoBot> WebFreak joined in slot 1.
683 <BanchoBot> WebFreak moved to slot 2
684 
685 <WebFreak> !mp host WebFreak
686 <BanchoBot> WebFreak became the host.
687 <BanchoBot> Changed match host to WebFreak
688 <BanchoBot> Beatmap changed to: bibuko - Reizouko Mitara Pudding ga Nai [Mythol's Pudding] (https://osu.ppy.sh/b/256839)
689 
690 <WebFreak> !mp mods FI
691 <BanchoBot> Enabled FadeIn, disabled FreeMod
692 
693 <WebFreak> !mp start
694 <BanchoBot> The match has started!
695 <BanchoBot> Started the match
696 <BanchoBot> WebFreak finished playing (Score: 487680, FAILED).
697 <BanchoBot> The match has finished!
698 
699 aborting (esc):
700 <BanchoBot> WebFreak finished playing (Score: 300, PASSED).
701 
702 <BanchoBot> WebFreak finished playing (Score: 113216, PASSED).
703 <BanchoBot> The match has finished!
704 
705 <BanchoBot> All players are ready
706 
707 <WebFreak> !mp start
708 <BanchoBot> The match has started!
709 <BanchoBot> Started the match
710 
711 <WebFreak> !mp abort
712 <BanchoBot> Aborted the match
713 
714 <BanchoBot> WebFreak moved to slot 3
715 <BanchoBot> WebFreak changed to Red
716 <BanchoBot> WebFreak changed to Blue
717 <BanchoBot> WebFreak moved to slot 1
718 
719 <BanchoBot> Host is changing map...
720 <BanchoBot> Beatmap changed to: Aitsuki Nakuru - Krewrap no uta [Easy] (https://osu.ppy.sh/b/1292635)
721 
722 <WebFreak> !mp settings
723 <BanchoBot> Room name: bob, History: https://osu.ppy.sh/mp/40081206
724 <BanchoBot> Beatmap: https://osu.ppy.sh/b/1292635 Aitsuki Nakuru - Krewrap no uta [Easy]
725 <BanchoBot> Team mode: HeadToHead, Win condition: Score
726 <BanchoBot> Active mods: Freemod
727 <BanchoBot> Players: 1
728 <BanchoBot> Slot 1  Not Ready https://osu.ppy.sh/u/1756786 WebFreak        [Host / Hidden]
729 
730 <WebFreak> !mp settings
731 <BanchoBot> Room name: bob, History: https://osu.ppy.sh/mp/40081206
732 <BanchoBot> Beatmap: https://osu.ppy.sh/b/1292635 Aitsuki Nakuru - Krewrap no uta [Easy]
733 <BanchoBot> Team mode: HeadToHead, Win condition: Score
734 <BanchoBot> Active mods: HalfTime, Freemod
735 <BanchoBot> Players: 1
736 <BanchoBot> Slot 1  Not Ready https://osu.ppy.sh/u/1756786 WebFreak        [Host / Hidden, HardRock, SuddenDeath]
737 
738 <WebFreak> !mp size 1
739 <BanchoBot> WebFreak left the game.
740 <BanchoBot> Changed match to size 1
741 <BanchoBot> WebFreak joined in slot 1.
742 */
743 
744 ///
745 enum TeamMode
746 {
747 	///
748 	HeadToHead,
749 	///
750 	TagCoop,
751 	///
752 	TeamVs,
753 	///
754 	TagTeamVs
755 }
756 
757 ///
758 enum ScoreMode
759 {
760 	///
761 	Score,
762 	///
763 	Accuracy,
764 	///
765 	Combo,
766 	///
767 	ScoreV2
768 }
769 
770 ///
771 enum Team
772 {
773 	/// default, used when mode is not TeamVs/TagTeamVs
774 	None,
775 	///
776 	Red,
777 	///
778 	Blue
779 }
780 
781 ///
782 enum Mod : string
783 {
784 	///
785 	Easy = "Easy",
786 	///
787 	NoFail = "NoFail",
788 	///
789 	HalfTime = "HalfTime",
790 	///
791 	HardRock = "HardRock",
792 	///
793 	SuddenDeath = "SuddenDeath",
794 	///
795 	DoubleTime = "DoubleTime",
796 	///
797 	Nightcore = "Nightcore",
798 	///
799 	Hidden = "Hidden",
800 	///
801 	FadeIn = "FadeIn",
802 	///
803 	Flashlight = "Flashlight",
804 	///
805 	Relax = "Relax",
806 	///
807 	Autopilot = "Relax2",
808 	///
809 	SpunOut = "SpunOut",
810 	///
811 	Key1 = "Key1",
812 	///
813 	Key2 = "Key2",
814 	///
815 	Key3 = "Key3",
816 	///
817 	Key4 = "Key4",
818 	///
819 	Key5 = "Key5",
820 	///
821 	Key6 = "Key6",
822 	///
823 	Key7 = "Key7",
824 	///
825 	Key8 = "Key8",
826 	///
827 	Key9 = "Key9",
828 	///
829 	KeyCoop = "KeyCoop",
830 	///
831 	ManiaRandom = "Random",
832 	///
833 	FreeMod = "FreeMod",
834 }
835 
836 /// Generates the short form for a mod (eg Hidden -> HD), can be more than 2 characters
837 string shortForm(Mod mod)
838 {
839 	//dfmt off
840 	switch (mod)
841 	{
842 	case Mod.Easy: return "EZ";
843 	case Mod.NoFail: return "NF";
844 	case Mod.HalfTime: return "HT";
845 	case Mod.HardRock: return "HR";
846 	case Mod.SuddenDeath: return "SD";
847 	case Mod.DoubleTime: return "DT";
848 	case Mod.Nightcore: return "NC";
849 	case Mod.Hidden: return "HD";
850 	case Mod.FadeIn: return "FI";
851 	case Mod.Flashlight: return "FL";
852 	case Mod.Relax: return "RX";
853 	case Mod.Autopilot: return "AP";
854 	case Mod.SpunOut: return "SO";
855 	case Mod.Key1: return "K1";
856 	case Mod.Key2: return "K2";
857 	case Mod.Key3: return "K3";
858 	case Mod.Key4: return "K4";
859 	case Mod.Key5: return "K5";
860 	case Mod.Key6: return "K6";
861 	case Mod.Key7: return "K7";
862 	case Mod.Key8: return "K8";
863 	case Mod.Key9: return "K9";
864 	case Mod.KeyCoop: return "COOP";
865 	case Mod.ManiaRandom: return "RN";
866 	case Mod.FreeMod:
867 	default: return mod;
868 	}
869 	//dfmt on
870 }
871 
872 ///
873 alias HighPriority = Flag!"highPriority";
874 
875 /// Represents a multiplayer lobby in osu!
876 /// Automatically does ratelimiting by not sending more than a message every 2 seconds.
877 ///
878 /// All slot indices are 0 based.
879 class OsuRoom // must be a class, don't change it
880 {
881 	/// Returned by !mp settings
882 	struct Settings
883 	{
884 		/// Represents a player state in the settings result
885 		struct Player
886 		{
887 			/// Player user information, may not be there except for name
888 			string id, url, name;
889 			///
890 			bool ready;
891 			///
892 			bool playing;
893 			///
894 			bool noMap;
895 			///
896 			bool host;
897 			/// If freemods is enabled this contains user specific mods
898 			Mod[] mods;
899 			///
900 			Team team;
901 		}
902 
903 		/// Game name
904 		string name;
905 		/// URL to match history
906 		string history;
907 		/// Beatmap information
908 		BeatmapInfo beatmap;
909 		/// Global active mods or all mods if freemods is off, contains Mod.FreeMod if on
910 		Mod[] activeMods;
911 		/// Type of game (coop, tag team, etc.)
912 		TeamMode teamMode;
913 		/// Win condition (score, acc, combo, etc.)
914 		ScoreMode winCondition;
915 		/// Number of players in this match
916 		int numPlayers;
917 		/// All players for every slot (empty slots are Player.init)
918 		Player[16] players;
919 	}
920 
921 	private BanchoBot bot;
922 	private string _channel, id;
923 	private bool open;
924 	private SysTime lastMessage;
925 	private bool fatal;
926 
927 	/// Automatically managed state of player slots, empty slots are Player.init
928 	Settings.Player[16] slots;
929 	/// username as argument
930 	Event!string onUserLeave;
931 	/// username & team as argument
932 	Event!(string, Team) onUserTeamChange;
933 	/// username as argument
934 	Event!string onUserHost;
935 	/// username + slot (0 based) as argument
936 	Event!(string, ubyte) onUserJoin;
937 	/// username + slot (0 based) as argument
938 	Event!(string, ubyte) onUserMove;
939 	/// emitted when all players are ready
940 	Event!() onPlayersReady;
941 	/// Match has started
942 	Event!() onMatchStart;
943 	/// Match has ended (all players finished)
944 	Event!() onMatchEnd;
945 	/// Host is changing beatmap
946 	Event!() onBeatmapPending;
947 	/// Host changed map
948 	Event!BeatmapInfo onBeatmapChanged;
949 	/// A message by anyone has been sent
950 	Event!Message onMessage;
951 	/// A timer finished
952 	Event!() onCountdownFinished;
953 	/// A user finished playing. username + score + passed
954 	Event!(string, long, bool) onPlayerFinished;
955 	/// The room has been closed
956 	Event!() onClosed;
957 
958 	private this(BanchoBot bot, string channel, string id)
959 	{
960 		assert(channel.startsWith("#mp_"));
961 		lastMessage = Clock.currTime(UTC());
962 		this.bot = bot;
963 		this._channel = channel;
964 		this.id = id;
965 		open = true;
966 	}
967 
968 	ref Settings.Player slot(int index)
969 	{
970 		if (index < 0 || index >= 16)
971 			throw new Exception("slot index out of bounds");
972 		return slots[index];
973 	}
974 
975 	bool hasPlayer(string name)
976 	{
977 		foreach (ref slot; slots)
978 			if (slot.name == name)
979 				return true;
980 		return false;
981 	}
982 
983 	ref Settings.Player playerByName(string name)
984 	{
985 		foreach (ref slot; slots)
986 			if (slot.name == name)
987 				return slot;
988 		throw new Exception("player " ~ name ~ " not found!");
989 	}
990 
991 	ref Settings.Player playerByName(string name, out size_t index)
992 	{
993 		foreach (i, ref slot; slots)
994 			if (slot.name == name)
995 			{
996 				index = i;
997 				return slot;
998 			}
999 		throw new Exception("player " ~ name ~ " not found!");
1000 	}
1001 
1002 	ubyte playerSlotByName(string name)
1003 	{
1004 		foreach (i, ref slot; slots)
1005 			if (slot.name == name)
1006 				return cast(ubyte) i;
1007 		throw new Exception("player " ~ name ~ " not found!");
1008 	}
1009 
1010 	/// Returns the channel name as on IRC
1011 	string channel() const @property
1012 	{
1013 		return _channel;
1014 	}
1015 
1016 	/// Returns the room ID as usable in the mp history URL or IRC joinable via #mp_ID
1017 	string room() const @property
1018 	{
1019 		return channel["#mp_".length .. $];
1020 	}
1021 
1022 	/// Returns the game ID as usable in osu://mp/ID urls
1023 	string mpid() const @property
1024 	{
1025 		return id;
1026 	}
1027 
1028 	/// Closes the room
1029 	void close()
1030 	{
1031 		if (!open)
1032 			return;
1033 		sendMessage("!mp close");
1034 		open = false;
1035 	}
1036 
1037 	/// Invites a player to the room
1038 	void invite(string player)
1039 	{
1040 		sendMessage("!mp invite " ~ player.fixUsername);
1041 	}
1042 
1043 	/// Kicks a player from the room
1044 	void kick(string player)
1045 	{
1046 		sendMessage("!mp kick " ~ player.fixUsername);
1047 	}
1048 
1049 	/// Moves a player to another slot
1050 	void move(string player, int slot)
1051 	{
1052 		sendMessage("!mp move " ~ player.fixUsername ~ " " ~ (slot + 1).to!string);
1053 	}
1054 
1055 	/// Gives host to a player
1056 	void host(string player) @property
1057 	{
1058 		sendMessage("!mp host " ~ player.fixUsername);
1059 	}
1060 
1061 	/// Makes nobody host (make it system/bog managed)
1062 	void clearhost()
1063 	{
1064 		sendMessage("!mp clearhost");
1065 	}
1066 
1067 	/// Property to lock slots (disallow changing slots & joining)
1068 	void locked(bool locked) @property
1069 	{
1070 		sendMessage(locked ? "!mp lock" : "!mp unlock");
1071 	}
1072 
1073 	/// Sets the match password (password will be visible to existing players)
1074 	void password(string pw) @property
1075 	{
1076 		sendMessage("!mp password " ~ pw);
1077 	}
1078 
1079 	/// Changes a user's team
1080 	void setTeam(string user, Team team)
1081 	{
1082 		sendMessage("!mp team " ~ user.fixUsername ~ " " ~ team.to!string);
1083 	}
1084 
1085 	/// Changes the slot limit of this lobby
1086 	void size(ubyte slots) @property
1087 	{
1088 		sendMessage("!mp size " ~ slots.to!string);
1089 	}
1090 
1091 	/// Sets up teammode, scoremode & lobby size
1092 	void set(TeamMode teammode, ScoreMode scoremode, ubyte size)
1093 	{
1094 		sendMessage("!mp set " ~ (cast(int) teammode)
1095 				.to!string ~ " " ~ (cast(int) scoremode).to!string ~ " " ~ size.to!string);
1096 	}
1097 
1098 	/// Changes the mods in this lobby (pass FreeMod first if you want FreeMod)
1099 	void mods(Mod[] mods) @property
1100 	{
1101 		sendMessage("!mp mods " ~ mods.map!(a => a.shortForm).join(" "));
1102 	}
1103 
1104 	/// Changes the map to a beatmap ID (b/ url)
1105 	void map(string id) @property
1106 	{
1107 		sendMessage("!mp map " ~ id);
1108 	}
1109 
1110 	/// Sets a timer using !mp timer
1111 	void setTimer(Duration d)
1112 	{
1113 		sendMessage("!mp timer " ~ d.total!"seconds".to!string);
1114 	}
1115 
1116 	/// Waits for a player to join the room & return the username
1117 	/// Throws: InterruptException if timeout triggers
1118 	string waitForJoin(Duration timeout)
1119 	{
1120 		auto l = bot.waitForMessage(a => a.target == channel && a.sender == banchoBotNick
1121 				&& a.message.canFind(" joined in slot "), timeout).message;
1122 		auto i = l.indexOf(" joined in slot ");
1123 		return l[0 .. i];
1124 	}
1125 
1126 	/// Waits for an existing timer/countdown to finish (wont start one)
1127 	/// Throws: InterruptException if timeout triggers
1128 	void waitForTimer(Duration timeout)
1129 	{
1130 		bot.waitForMessage(a => a.target == channel && a.sender == banchoBotNick
1131 				&& a.message == "Countdown finished", timeout);
1132 	}
1133 
1134 	/// Aborts any running countdown
1135 	void abortTimer()
1136 	{
1137 		sendMessage("!mp aborttimer");
1138 	}
1139 
1140 	/// Aborts a running match
1141 	void abortMatch()
1142 	{
1143 		sendMessage("!mp abort");
1144 	}
1145 
1146 	/// Starts a match after a specified amount of seconds. If after is <= 0 the game will be started immediately.
1147 	/// The timeout can be canceled using abortTimer.
1148 	void start(Duration after = Duration.zero)
1149 	{
1150 		if (after <= Duration.zero)
1151 			sendMessage("!mp start");
1152 		else
1153 			sendMessage("!mp start " ~ after.total!"seconds".to!string);
1154 	}
1155 
1156 	/// Manually wait until you can send a message again
1157 	void ratelimit(HighPriority highPriority = HighPriority.no)
1158 	{
1159 		auto now = Clock.currTime(UTC());
1160 		auto len = highPriority ? 1200.msecs : 2.seconds;
1161 		while (now - lastMessage < len)
1162 		{
1163 			sleep(len - (now - lastMessage));
1164 			now = Clock.currTime(UTC());
1165 		}
1166 		lastMessage = now;
1167 	}
1168 
1169 	/// Sends a message with a 2 second ratelimit
1170 	/// Params:
1171 	///   message = raw message to send
1172 	///   highPriority = if yes, already send after a 1.2 second ratelimit (before others)
1173 	void sendMessage(in char[] message, HighPriority highPriority = HighPriority.no)
1174 	{
1175 		if (!open)
1176 			throw new Exception("Attempted to send message in closed room");
1177 		ratelimit(highPriority);
1178 		bot.sendMessage(channel, message);
1179 	}
1180 
1181 	/// Returns the current mp settings
1182 	Settings settings() @property
1183 	{
1184 		int step = 0;
1185 	Retry:
1186 		bot.fetchOldMessageLog(a => a.target == channel && a.sender == banchoBotNick
1187 				&& a.message.isSettingsMessage, false);
1188 		sendMessage("!mp settings");
1189 		auto msgs = bot.waitForMessageBunch(a => a.target == channel
1190 				&& a.sender == banchoBotNick && a.message.isSettingsMessage, 10.seconds,
1191 				10.seconds, 400.msecs);
1192 		if (!msgs.length)
1193 			return Settings.init;
1194 		Settings settings;
1195 		settings.numPlayers = -1;
1196 		int foundPlayers;
1197 		SettingsLoop: foreach (msg; msgs)
1198 		{
1199 			if (msg.message.startsWith("Room name: "))
1200 			{
1201 				// Room name: bob, History: https://osu.ppy.sh/mp/40123558
1202 				msg.message = msg.message["Room name: ".length .. $];
1203 				auto end = msg.message.indexOf(", History: ");
1204 				if (end != -1)
1205 				{
1206 					settings.name = msg.message[0 .. end];
1207 					settings.history = msg.message[end + ", History: ".length .. $];
1208 				}
1209 			}
1210 			else if (msg.message.startsWith("Beatmap: "))
1211 			{
1212 				// Beatmap: https://osu.ppy.sh/b/972293 Ayane - FaV -F*** and Vanguard- [Normal]
1213 				msg.message = msg.message["Beatmap: ".length .. $];
1214 				auto space = msg.message.indexOf(" ");
1215 				if (space != -1)
1216 				{
1217 					settings.beatmap.url = msg.message[0 .. space];
1218 					if (settings.beatmap.url.startsWith("https://osu.ppy.sh/b/"))
1219 						settings.beatmap.id = settings.beatmap.url["https://osu.ppy.sh/b/".length .. $];
1220 					else
1221 						settings.beatmap.id = "";
1222 					settings.beatmap.name = msg.message[space + 1 .. $];
1223 				}
1224 			}
1225 			else if (msg.message.startsWith("Team mode: "))
1226 			{
1227 				// Team mode: TeamVs, Win condition: ScoreV2
1228 				msg.message = msg.message["Team mode: ".length .. $];
1229 				auto comma = msg.message.indexOf(", Win condition: ");
1230 				if (comma != -1)
1231 				{
1232 					settings.teamMode = msg.message[0 .. comma].to!TeamMode;
1233 					settings.winCondition = msg.message[comma + ", Win condition: ".length .. $]
1234 						.to!ScoreMode;
1235 				}
1236 			}
1237 			else if (msg.message.startsWith("Active mods: "))
1238 			{
1239 				// Active mods: Hidden, DoubleTime
1240 				settings.activeMods = msg.message["Active mods: ".length .. $].splitter(", ")
1241 					.map!(a => cast(Mod) a).array;
1242 			}
1243 			else if (msg.message.startsWith("Players: "))
1244 			{
1245 				// Players: 1
1246 				settings.numPlayers = msg.message["Players: ".length .. $].to!int;
1247 			}
1248 			else if (msg.message.startsWith("Slot "))
1249 			{
1250 				foundPlayers++;
1251 				// Slot 1  Not Ready https://osu.ppy.sh/u/1756786 WebFreak        [Host / Team Blue / Hidden, HardRock]
1252 				// Slot 1  Ready     https://osu.ppy.sh/u/1756786 WebFreak        [Host / Team Blue / NoFail, Hidden, HardRock]
1253 				//"Slot 1  Not Ready https://osu.ppy.sh/u/1756786 WebFreak        "
1254 				if (msg.message.length < 63)
1255 					continue;
1256 				auto num = msg.message[5 .. 7].strip.to!int;
1257 				msg.message = msg.message.stripLeft;
1258 				if (num >= 1 && num <= 16)
1259 				{
1260 					auto index = num - 1;
1261 					settings.players[index].ready = msg.message[8 .. 17] == "Ready    ";
1262 					settings.players[index].noMap = msg.message[8 .. 17] == "No Map   ";
1263 					settings.players[index].url = msg.message[18 .. $];
1264 					auto space = settings.players[index].url.indexOf(' ');
1265 					if (space == -1)
1266 						continue;
1267 					auto rest = settings.players[index].url[space + 1 .. $];
1268 					settings.players[index].url.length = space;
1269 					settings.players[index].id = settings.players[index].url[settings.players[index].url.lastIndexOf(
1270 							'/') + 1 .. $];
1271 					auto bracket = rest.indexOf("[", 16);
1272 					if (bracket == -1)
1273 						settings.players[index].name = rest.stripRight;
1274 					else
1275 					{
1276 						settings.players[index].name = rest[0 .. bracket].stripRight;
1277 						auto extra = rest[bracket + 1 .. $];
1278 						if (extra.endsWith("]"))
1279 							extra.length--;
1280 						foreach (part; extra.splitter(" / "))
1281 						{
1282 							if (part == "Host")
1283 								settings.players[index].host = true;
1284 							else if (part.startsWith("Team "))
1285 								settings.players[index].team = part["Team ".length .. $].strip.to!Team;
1286 							else
1287 								settings.players[index].mods = part.splitter(", ").map!(a => cast(Mod) a).array;
1288 						}
1289 					}
1290 				}
1291 			}
1292 		}
1293 		if ((foundPlayers < settings.numPlayers || settings.numPlayers == -1) && ++step < 5)
1294 		{
1295 			msgs = bot.waitForMessageBunch(a => a.target == channel && a.sender == banchoBotNick
1296 					&& a.message.isSettingsMessage, 3.seconds, 3.seconds, 600.msecs);
1297 			if (msgs.length)
1298 				goto SettingsLoop;
1299 		}
1300 		if (foundPlayers && settings.numPlayers <= 0 && step < 5)
1301 			goto Retry;
1302 		if (settings.numPlayers == -1)
1303 			return settings;
1304 		slots = settings.players;
1305 		return settings;
1306 	}
1307 
1308 	/// Processes a user leave event & updates the state
1309 	void processLeave(string user)
1310 	{
1311 		try
1312 		{
1313 			playerByName(user) = Settings.Player.init;
1314 			runTask({ onUserLeave.emit(user); });
1315 		}
1316 		catch (Exception)
1317 		{
1318 		}
1319 	}
1320 
1321 	/// Processes a user team switch event & updates the state
1322 	void processTeam(string user, Team team)
1323 	{
1324 		try
1325 		{
1326 			playerByName(user).team = team;
1327 			runTask({ onUserTeamChange.emit(user, team); });
1328 		}
1329 		catch (Exception)
1330 		{
1331 		}
1332 	}
1333 
1334 	/// Processes a user host event & updates the state
1335 	void processHost(string user)
1336 	{
1337 		foreach (ref slot; slots)
1338 			slot.host = false;
1339 		try
1340 		{
1341 			playerByName(user).host = true;
1342 			runTask({ onUserHost.emit(user); });
1343 		}
1344 		catch (Exception)
1345 		{
1346 		}
1347 	}
1348 
1349 	/// Processes a user join event & updates the state
1350 	void processJoin(string user, ubyte slot)
1351 	{
1352 		this.slot(slot) = Settings.Player(null, null, user);
1353 		runTask({ onUserJoin.emit(user, slot); });
1354 	}
1355 
1356 	/// Processes a user move event & updates the state
1357 	void processMove(string user, ubyte slot)
1358 	{
1359 		if (this.slot(slot) != Settings.Player.init)
1360 			throw new Exception("slot was occupied");
1361 		size_t old;
1362 		this.slot(slot) = playerByName(user, old);
1363 		this.slot(cast(int) old) = Settings.Player.init;
1364 		runTask({ onUserMove.emit(user, slot); });
1365 	}
1366 
1367 	/// Processes a room size change event & updates the state
1368 	void processSize(ubyte numSlots)
1369 	{
1370 		foreach (i; numSlots + 1 .. 16)
1371 			if (slots[i] != Settings.Player.init)
1372 			{
1373 				runTask((string user) { onUserLeave.emit(user); }, slots[i].name);
1374 				slots[i] = Settings.Player.init;
1375 			}
1376 	}
1377 
1378 	/// Processes a match end event & updates the state
1379 	void processMatchFinish()
1380 	{
1381 		foreach (ref slot; slots)
1382 			if (slot != Settings.Player.init)
1383 				slot.playing = false;
1384 		runTask({ onMatchEnd.emit(); });
1385 	}
1386 
1387 	/// Processes a user finish playing event & updates the state
1388 	void processFinishPlaying(string player, long score, bool pass)
1389 	{
1390 		playerByName(player).playing = false;
1391 		runTask({ onPlayerFinished.emit(player, score, pass); });
1392 	}
1393 
1394 	/// Processes a room closed event
1395 	void processClosed()
1396 	{
1397 		open = false;
1398 		bot.unmanageRoom(this);
1399 		runTask({ onClosed.emit(); });
1400 	}
1401 }
1402 
1403 bool isSettingsMessage(string msg)
1404 {
1405 	return msg.startsWith("Room name: ") || msg.startsWith("Beatmap: ")
1406 		|| msg.startsWith("Team mode: ") || msg.startsWith("Active mods: ")
1407 		|| msg.startsWith("Players: ") || (msg.startsWith("Slot ")
1408 				&& msg.length >= 63 && (msg["Slot ".length .. $].front >= '0'
1409 					&& msg["Slot ".length .. $].front <= '9'));
1410 }
1411 
1412 ///
1413 unittest
1414 {
1415 	BanchoBot banchoConnection = new BanchoBot("WebFreak", "");
1416 	bool running = true;
1417 	auto botTask = runTask({
1418 		while (running)
1419 		{
1420 			banchoConnection.connect();
1421 			logDiagnostic("Got disconnected from bancho...");
1422 			sleep(2.seconds);
1423 		}
1424 	});
1425 	sleep(3.seconds);
1426 	auto users = ["WebFreak", "Node"];
1427 	OsuRoom room = banchoConnection.createRoom("bob");
1428 	runTask({
1429 		foreach (user; users)
1430 			room.invite(user);
1431 	});
1432 	runTask({
1433 		room.password = "123456";
1434 		room.size = 8;
1435 		room.mods = [Mod.Hidden, Mod.DoubleTime];
1436 		room.map = "1158325";
1437 	});
1438 	runTask({
1439 		int joined;
1440 		try
1441 		{
1442 			while (true)
1443 			{
1444 				string user = room.waitForJoin(30.seconds);
1445 				joined++;
1446 				room.sendMessage("yay welcome " ~ user ~ "!", HighPriority.yes);
1447 			}
1448 		}
1449 		catch (InterruptException)
1450 		{
1451 			if (joined == 0)
1452 			{
1453 				// forever alone
1454 				room.close();
1455 				return;
1456 			}
1457 		}
1458 		room.sendMessage("This is an automated test, this room will close in 10 seconds on timer");
1459 		room.setTimer(10.seconds);
1460 		try
1461 		{
1462 			room.waitForTimer(15.seconds);
1463 		}
1464 		catch (InterruptException)
1465 		{
1466 			room.sendMessage("Timer didn't trigger :(");
1467 			room.sendMessage("closing the room in 5s");
1468 			sleep(5.seconds);
1469 		}
1470 		room.close();
1471 	}).join();
1472 	running = false;
1473 	banchoConnection.disconnect();
1474 	botTask.join();
1475 }