const net = require("net"); const EventEmitter = require("events").EventEmitter; const CacheMap = require("./CacheMap"); const BanchoUser = require("./BanchoUser"); const BanchoChannel = require("./BanchoChannel"); const BanchoMultiplayerChannel = require("./BanchoMultiplayerChannel"); const ConnectStates = require("./Enums/ConnectStates"); const IrcCommands = require("./Enums/IrcCommands"); const Nodesu = require("nodesu"); const RateLimiterMemory = require("rate-limiter-flexible").RateLimiterMemory; const RateLimiterQueue = require("rate-limiter-flexible").RateLimiterQueue; const PrivateMessage = require("./PrivateMessage"); const ChannelMessage = require("./ChannelMessage"); const ignoredSplits = [ "312", // Whois server info (useless on Bancho) "333", // Time when topic was set "366", // End of NAMES reply "372", // MOTD "375", // MOTD Begin "376", // MOTD End ]; /** * Client to connect to Bancho over its IRC gateway * @property {nodesu.Client} osuApi Populated with a Nodesu client, if api key is passed to the constructor * @extends {EventEmitter} */ class BanchoClient extends EventEmitter { /** * @constructor * @param {BanchoClientOptions} options */ constructor(optionsOrUsername = {}, password, host = "irc.ppy.sh", port = 6667, apiKey, limiterTimespan, limiterPrivate, limiterPublic) { super(); if(optionsOrUsername != null && typeof optionsOrUsername == "string" && password != null && typeof password == "string") { process.emitWarning("You're using the deprecated way of constructing a BanchoClient object! Please refer to the documentation for the up-to-date constructor. This constructor will be removed in Release 1.0.0."); if(limiterTimespan || limiterPrivate || limiterPublic) process.emitWarning("Rate-limiter support has been removed from the deprecated constructor in v0.10. This warning will disappear in v1.0.0."); this.username = optionsOrUsername; this.password = password; this.host = host; this.port = port; if(apiKey) this.osuApi = new Nodesu.Client(apiKey, { parseData: true }); this.rateLimiter = new RateLimiterQueue(new RateLimiterMemory({ points: limiterPrivate, duration: limiterTimespan / 1000 })); } else if(typeof optionsOrUsername == "object") { /** * Options for a BanchoClient. * @typedef {object} BanchoClientOptions * @prop {string} username Username of the user to connect to Bancho * @prop {string} password IRC Password of the user to connect to Bancho (see https://osu.ppy.sh/p/irc) * @prop {string} [host="irc.ppy.sh"] Custom IRC host (for proxy-ing through a firewall for example) * @prop {number} [port=6667] Custom IRC port * @prop {string} [apiKey] osu! API key for API requests (see https://osu.ppy.sh/p/api). WARNING: Multiplayer lobbies won't work without an API key! * @prop {RateLimiter} [rateLimiter] Instance of RateLimiter from the `limiter` npm module for outgoing Bancho messages. Default is safe for normal users in private messages (PM and #multiplayer channels), bots are not supposed to send public messages. Can be disabled by setting to `null`. * @prop {boolean} [botAccount=false] Apply bot account rate-limits to the default RateLimiter instance if true (see https://osu.ppy.sh/wiki/en/Bot_account). * @prop {number} [gamemode=null] Gamemode id to fetch users with. Defaults to null */ const options = { host: "irc.ppy.sh", port: 6667, limiterTimespan: 25000, limiterPrivate: 18, limiterPublic: undefined, botAccount: false, gamemode: null, }; Object.assign(options, optionsOrUsername); if(options.rateLimiter && options.botAccount) process.emitWarning("You have set both a custom `rateLimiter` and `botAccount`. Your custom `rateLimiter` will take precedence. Remove `botAccount` to suppress this warning (no side-effect)."); if(options.rateLimiter && (optionsOrUsername.limiterTimespan || optionsOrUsername.limiterPrivate)) process.emitWarning("The `limiterTimespan` and `limiterPrivate` options have been deprecated in v0.10 and the custom `rateLimiter` you specified will take precedence. This warning will be removed in v1.0.0."); if(options.rateLimiter === undefined) { if(optionsOrUsername.limiterPublic) process.emitWarning("Public rate-limiter has been removed in v0.10. Bots are not supposed to send messages in public channels, only in PMs and #multiplayer. This warning will disappear in v1.0.0."); if(optionsOrUsername.limiterTimespan || optionsOrUsername.limiterPrivate) { process.emitWarning("The `limiterTimespan` and `limiterPrivate` options have been deprecated in v0.10. Support for these and this warning will be removed in v1.0.0."); if(options.botAccount) process.emitWarning("You have set both deprecated custom rate limits and `botAccount`. The default bot accounts limits will take precedence. This warning will be removed in v1.0.0."); } const limiterPrivate = options.botAccount ? 298 : options.limiterPrivate; const limiterTimespan = options.botAccount ? 62500 : options.limiterTimespan; options.rateLimiter = new RateLimiterQueue(new RateLimiterMemory({ points: limiterPrivate, duration: limiterTimespan / 1000 })); } this.username = options.username; this.password = options.password; this.host = options.host; this.port = options.port; if(options.apiKey) { this.osuApi = new Nodesu.Client(options.apiKey, { parseData: true }); this.gamemode = options.gamemode; } this.rateLimiter = options.rateLimiter; } this.setMaxListeners(50); if(!this.username || !this.password) throw new Error("You gotta gimme an username and a password to connect to Bancho dumbass"); this.client = null; this.connectState = ConnectStates.Disconnected; this.reconnect = true; this.reconnectTimeout = null; this.connectCallback = null; this.joinCallbacks = []; this.partCallbacks = []; this.users = new CacheMap(); this.usersById = new CacheMap(); this.channels = {}; this.messagesQueue = []; this.lobbyCreationQueue = []; // Register our own error listener so there are no uncaught exceptions // for exceptions WE throw for the end-user that don't need to be caught (such as timeouts) this.on("error", function() { }); this.ignoreClose = false; // TODO: find something better than this solution. it works and is 100% reliable though! also eventually try with process.nextTick instead of setTimeout() } async processMessagesQueue() { while(this.messagesQueue[0]) { const { message, resolve, reject } = this.messagesQueue[0]; try { if(!this.isConnected()) throw new Error("Currently disconnected!"); if(this.rateLimiter) await this.rateLimiter.removeTokens(1); if(!this.isConnected()) throw new Error("Currently disconnected!"); let name = null; if(message.recipient instanceof BanchoUser) name = message.recipient.ircUsername; else if(message.recipient instanceof BanchoChannel) name = message.recipient.name; else throw new Error("Recipient isn't a BanchoUser or BanchoChannel!"); name = name.replace(/ /g, "_").split("\n")[0].substring(0, 28); const content = message.message.split("\n")[0]; this.send(`PRIVMSG ${name} :${content}`, false); if(message.recipient instanceof BanchoUser) this.emit("PM", new PrivateMessage(this.getSelf(), content, true, message.recipient)); else if(message.recipient instanceof BanchoChannel) this.emit("CM", new ChannelMessage(this.getSelf(), content, true, message.recipient)); resolve(); } catch(e) { reject(e); } finally { this.messagesQueue.shift(); } } } /** * Before connecting, (re)defines the socket and all callbacks, because the client might get destroyed. * @fires BanchoClient#error * @private */ initSocket() { this.client = (new net.Socket()).setTimeout(60000); this.client.on("error", (err) => { /** * An error has occured on the socket. * @event BanchoClient#error * @type {Error} */ this.emit("error", err); this.onClose(err); }); this.client.on("close", () => { if(!this.ignoreClose) this.onClose(new Error("Connection closed")); }); this.client.on("timeout", () => { const err = new Error("Timeout reached"); this.ignoreClose = true; this.client.destroy(); this.emit("error", err); this.onClose(err); setTimeout(() => this.ignoreClose = false, 1); // close event is apparently not fired immediately after calling destroy... }); let unparsedData = ""; this.client.on("data", (data) => { data = data.toString().replace(/\r/g, ""); // Sometimes, Bancho sends \r, and sometimes it doesn't. unparsedData += data; var index; while((index = unparsedData.indexOf("\n")) != -1) { var command = unparsedData.substring(0, index); unparsedData = unparsedData.substring(index + 1); // 1 is the length of \n, it being 1 special character and not 2. this.handleIrcCommand(command); } }); } /** * Send raw data over IRC. * @param {string} data * @param {boolean} throwIfDisconnected Throws an Error if we're disconnected * @private */ send(data, throwIfDisconnected = true) { if(this.connectState == ConnectStates.Connected || this.connectState == ConnectStates.Connecting) this.client.write(data + "\r\n"); else if(throwIfDisconnected) throw new Error("Currently disconnected!"); } /** * Update connection state * @param {ConnectState} newConnectState * @param {Error} err Error to emit with the new state * @fires BanchoClient#connected * @fires BanchoClient#disconnected * @fires BanchoClient#state * @private */ updateState(newConnectState, err) { if(newConnectState == this.connectState) return; if(newConnectState != ConnectStates.Disconnected && newConnectState != ConnectStates.Reconnecting && newConnectState != ConnectStates.Connecting && newConnectState != ConnectStates.Connected) throw new Error("Invalid connect state!"); this.connectState = newConnectState; if(this.isConnected()) /** * Connected to Bancho! * @event BanchoClient#connected */ this.emit("connected"); if(this.isDisconnected()) /** * Disconnected from Bancho! * @event BanchoClient#disconnected * @type {Error} */ this.emit("disconnected", err); /** * ConnectState has updated! Emits with an error if any. * @event BanchoClient#state * @type {Error} */ this.emit("state", this.connectState, err); } /** * Executed when connection is expectedly or not closed * @param {Error} err * @private */ onClose(err) { // Every currently joined channel should be considered left. for(const channel of Object.values(this.channels)) if(channel.joined) IrcCommands.PART.emit(this, this.getSelf(), channel); if(this.connectState == ConnectStates.Disconnected) return; if(!this.reconnect) return this.updateState(ConnectStates.Disconnected, err); this.updateState(ConnectStates.Reconnecting, err); if(this.reconnectTimeout) clearTimeout(this.reconnectTimeout); this.reconnectTimeout = setTimeout(() => { if(this.reconnect) { const oldCallback = this.connectCallback; this.connect(); this.connectCallback = oldCallback; } this.reconnectTimeout = null; }, 5000); } /** * Handle incoming IRC commands from Bancho * @param {string} command * @private */ handleIrcCommand(command) { const splits = command.split(" "); if(ignoredSplits.indexOf(splits[1]) != -1) return; if(splits[0] == "PING") { splits.shift(); this.send("PONG "+splits.join(" ")); } else if(IrcCommands[splits[1]]) (IrcCommands[splits[1]]).handleCommand(this, splits[1], splits); /*else console.log("Unknown command", command);*/ } /** * Get a BanchoUser instance for the specified username * * @param {string} username * @returns {BanchoUser} */ getUser(username) { username = username.replace(/ /g, "_").split("\n")[0].substring(0, 28); let user = this.users.get(username.toLowerCase()); if(!user) { user = new BanchoUser(this, username); this.users.set(username.toLowerCase(), user); } return user; } /** * Get a BanchoUser representing ourself */ getSelf() { return this.getUser(this.username); } /** * Get a BanchoUser instance for the specified user id * * @async * @param {number} userid * @returns {Promise<BanchoUser>} */ getUserById(userid) { return new Promise((resolve, reject) => { if(isNaN(userid)) reject(new Error("id needs to be a number!")); let user = this.usersById.get(userid); if(user) resolve(user); else this.osuApi.user.get(userid, this.gamemode, null, Nodesu.LookupType.id).then((apiUser) => { user = this.getUser(apiUser.username); user.updateFromAPI(apiUser); resolve(user); }, reject); }); } /** * Get a BanchoChannel instance for the specified name * * @param {string} channelName * @returns {BanchoChannel|BanchoMultiplayerChannel} */ getChannel(channelName) { if(channelName.indexOf("#") != 0 || channelName.indexOf(",") != -1 || channelName.indexOf("") != -1) throw new Error("Invalid channel name!"); let channel = this.channels[channelName]; if(!channel) { channel = (channelName.indexOf("#mp_") == 0 && this.osuApi != null) ? new BanchoMultiplayerChannel(this, channelName) : new BanchoChannel(this, channelName); this.channels[channelName] = channel; } return channel; } /** * Creates a multiplayer lobby and return its channel. * * @async * @param {string} name Lobby name * @param {boolean} [privateLobby] Mark as private * @returns {BanchoMultiplayerChannel} */ createLobby(name, privateLobby = false) { return new Promise((resolve, reject) => { if(this.osuApi == null) throw new Error("bancho.js needs an API key for full multiplayer lobbies support!"); if(!name || !name.trim()) throw new Error("Empty name!"); name = name.trim(); this.lobbyCreationQueue.push({ name, privateLobby, resolve, reject, }); if(this.lobbyCreationQueue.length === 1) this.processLobbyCreationQueue(); }); } async processLobbyCreationQueue() { while(this.lobbyCreationQueue[0]) { const { name, privateLobby, resolve, reject } = this.lobbyCreationQueue[0]; try { if(!this.isConnected()) throw new Error("Currently disconnected!"); // eslint-disable-next-line no-async-promise-executor resolve(await new Promise(async (resolve, reject) => { const BanchoBot = this.getUser("BanchoBot"); const command = !privateLobby ? "make" : "makeprivate"; await BanchoBot.sendMessage("!mp "+command+" "+name); setTimeout(() => { reject(new Error("Multiplayer lobby creation timeout has been reached!")); }, 10000); const matchCreatedRegex = /Created the tournament match https:\/\/osu\.ppy\.sh\/mp\/(\d+) (.+)/; const listener = (msg) => { const m = matchCreatedRegex.exec(msg.message); if(!m) return; BanchoBot.removeListener("message", listener); const channel = this.getChannel("#mp_"+Number(m[1])); if(!channel.lobby) return reject(new Error("Not a multiplayer channel?! This shouldn't happen...")); resolve(channel); }; BanchoBot.on("message", listener); })); } catch(e) { reject(e); } finally { this.lobbyCreationQueue.shift(); } } } /** * Connects to Bancho, rejects an Error if connection fails * * @method * @async * @return {Promise<null, Error>} */ connect() { return new Promise((resolve, reject) => { if(this.connectState == ConnectStates.Connected || this.connectState == ConnectStates.Connecting) return reject(new Error("Already connected/connecting")); this.connectCallback = (err) => { if(err) return reject(err); resolve(); }; this.updateState(ConnectStates.Connecting); this.reconnect = true; this.initSocket(); this.client.connect(this.port, this.host, () => { this.send("PASS "+this.password); this.send("USER "+this.username+" 0 * :"+this.username); this.send("NICK "+this.username); }); }); } /** * Disconnects from Bancho * * @method */ disconnect() { if(this.connectState == ConnectStates.Disconnected) return; if(this.isConnected()) this.send("QUIT"); else if(this.connectState == ConnectStates.Connecting) this.client.destroy(); if(this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } this.updateState(ConnectStates.Disconnected); setTimeout(() => this.ignoreClose = false, 1); // close event is apparently not fired immediately after calling destroy... } } /** * When we've just connected, execute the connect callback * * @param {any} arg Arg to pass to the callback * @param {boolean} [erase=true] Erase the existing callback * @param {boolean} [throwIfNonexistant=false] Throw an error if the callback doesn't exist * @private */ callConnectCallback(arg, erase = true, throwIfNonexistant = false) { if(this.connectCallback != null) { this.connectCallback(arg); if(erase) this.connectCallback = null; } else if(throwIfNonexistant) throw new Error("Inexistant connect callback!"); } /** * Returns the current connection state. * * @returns {Symbol} Current connection state. See ConnectStates */ getConnectState() { return this.connectState; } /** * Returns true if the connectState is Connected, otherwise false. * * @returns {boolean} */ isConnected() { return (this.connectState == ConnectStates.Connected); } /** * Returns true if the connectState is Disconnected, otherwise false. * * @returns {boolean} */ isDisconnected() { return (this.connectState == ConnectStates.Disconnected); } } module.exports = BanchoClient;