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 }