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 }