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;