bancho.js 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import EventEmitter from 'events';
  2. import {Socket} from 'net';
  3. import Config from './util/config.js';
  4. import {capture_sentry_exception} from './util/helpers.js';
  5. class BanchoClient extends EventEmitter {
  6. constructor() {
  7. super();
  8. this.connected = false;
  9. this._whois_requests = [];
  10. this._outgoing_messages = [];
  11. this._buffer = '';
  12. this._socket = null;
  13. this._writer = null;
  14. this._whoare = [];
  15. // Lobbies that we JOIN'd, not necessarily fully initialized
  16. this._lobbies = [];
  17. // Fully initialized lobbies where the bot is running actual logic
  18. this.joined_lobbies = [];
  19. }
  20. connect() {
  21. this._socket = new Socket();
  22. // Note sure if this is correct when IRC PING exists
  23. this._socket.setKeepAlive(true);
  24. this._socket.setTimeout(10000, () => {
  25. this._socket.emit('error', new Error('Timed out.'));
  26. });
  27. this._socket.on('error', (err) => {
  28. console.error('[IRC] Connection error:', err);
  29. capture_sentry_exception(err);
  30. this._socket.destroy();
  31. });
  32. this._socket.on('close', () => {
  33. console.info('[IRC] Connection closed. Cleaning up.');
  34. while (this._outgoing_messages.length > 0) {
  35. const obj = this._outgoing_messages.shift();
  36. obj.callback();
  37. }
  38. this._buffer = '';
  39. clearInterval(this._writer);
  40. this._writer = null;
  41. this.connected = false;
  42. this.emit('disconnect');
  43. });
  44. console.info('[IRC] Connecting...');
  45. this._socket.connect({
  46. host: 'irc.ppy.sh',
  47. port: 6667,
  48. }, () => {
  49. console.info('[IRC] Connected.');
  50. this._send(`PASS ${Config.osu_irc_password}`);
  51. this._send(`USER ${Config.osu_username} 0 * :${Config.osu_username}`);
  52. this._send(`NICK ${Config.osu_username}`);
  53. this._writer = setInterval(() => {
  54. const obj = this._outgoing_messages.shift();
  55. if (obj) {
  56. this._send(obj.message);
  57. obj.callback();
  58. }
  59. }, Config.MILLISECONDS_BETWEEN_SENDS);
  60. });
  61. return new Promise(async (resolve, reject) => {
  62. const {BanchoLobby} = await import('./lobby.js');
  63. this._socket.on('data', (data) => {
  64. data = data.toString().replace(/\r/g, '');
  65. this._buffer += data;
  66. const lines = this._buffer.split('\n');
  67. for (let i = 0; i < lines.length - 1; i++) {
  68. const parts = lines[i].split(' ');
  69. if (parts[1] != 'QUIT') {
  70. console.debug('< ' + lines[i]);
  71. }
  72. if (parts[0] == 'PING') {
  73. parts.shift();
  74. this._send('PONG ' + parts.join(' '));
  75. continue;
  76. }
  77. if (parts[1] == '001') {
  78. console.info('[IRC] Successfully logged in.');
  79. this.connected = true;
  80. resolve();
  81. continue;
  82. }
  83. if (parts[1] == '311') {
  84. // parts[3]: target username
  85. // parts[4]: user profile url
  86. const user_id = parseInt(parts[4].substring(parts[4].lastIndexOf('/') + 1), 10);
  87. if (parts[3] in this._whois_requests) {
  88. this._whoare[parts[3]] = user_id;
  89. this._whois_requests[parts[3]].resolve(user_id);
  90. delete this._whois_requests[parts[3]];
  91. }
  92. continue;
  93. }
  94. if (parts[1] == '401') {
  95. const target = parts[3];
  96. parts.splice(0, 4);
  97. if (target in this._whois_requests) {
  98. this._whois_requests[target].reject(parts.join(' ').substring(1));
  99. delete this._whois_requests[target];
  100. }
  101. continue;
  102. }
  103. // These channel-specific errors can be sent to a channel, even if
  104. // you haven't joined it or been forced to join it. :)
  105. const error_codes = ['461', '403', '405', '475', '474', '471', '473'];
  106. if (error_codes.includes(parts[1])) {
  107. const channel = parts[3];
  108. parts.splice(0, 4);
  109. this.emit(
  110. 'lobbyJoined', {
  111. channel: channel,
  112. lobby: null,
  113. },
  114. new Error(parts.join(' ').substring(1)),
  115. );
  116. continue;
  117. }
  118. if (parts[1] == '464') {
  119. console.error('[IRC] Invalid username/password. See: https://osu.ppy.sh/p/irc');
  120. parts.shift(); parts.shift();
  121. reject(new Error(parts.join(' ').substring(1)));
  122. continue;
  123. }
  124. // Bancho can push JOIN commands anytime, and we need to handle those appropriately.
  125. if (parts[1] == 'JOIN') {
  126. const channel = parts[2].substring(1);
  127. const lobby = new BanchoLobby(channel);
  128. this._lobbies.push(lobby);
  129. continue;
  130. }
  131. if (parts[1] == 'PRIVMSG' && parts[2] == Config.osu_username) {
  132. const full_source = parts.shift();
  133. parts.splice(0, 2);
  134. let source = null;
  135. if (full_source.indexOf('!') != -1) {
  136. source = full_source.substring(1, full_source.indexOf('!'));
  137. }
  138. const message = parts.join(' ').substring(1);
  139. this.emit('pm', {
  140. from: source,
  141. message: message,
  142. });
  143. continue;
  144. }
  145. // Unhandled line: pass it to all JOIN'd lobbies
  146. for (const lobby of this._lobbies) {
  147. lobby.handle_line(lines[i]);
  148. }
  149. }
  150. this._buffer = lines.pop();
  151. });
  152. });
  153. }
  154. _send(raw_message) {
  155. // Make sure users can't send commands by injecting '\r\n'
  156. raw_message = raw_message.split('\r')[0];
  157. raw_message = raw_message.split('\n')[0];
  158. if (this._socket && this._socket.readyState == 'open') {
  159. if (raw_message.indexOf('PASS ') == 0) {
  160. console.debug('> PASS *********');
  161. } else {
  162. console.debug('> ' + raw_message);
  163. }
  164. this._socket.write(raw_message + '\r\n');
  165. }
  166. }
  167. // The process for making lobbies goes like this:
  168. //
  169. // 1. You send !mp make <lobby title>
  170. // 2. Bancho makes you JOIN a new lobby
  171. // 3. Bancho sends you info about that lobby
  172. // 4. Bancho finally tells you the title and ID of the lobby
  173. //
  174. // Because of this, we automatically join lobbies that Bancho "pushes" onto
  175. // us, and wait for step 4 to resolve that auto-joined lobby.
  176. make(lobby_title) {
  177. return new Promise((resolve, reject) => {
  178. let nb_owned_lobbies = 0;
  179. for (const lobby of this._lobbies) {
  180. if (lobby.data.creator == Config.osu_username) {
  181. nb_owned_lobbies++;
  182. }
  183. }
  184. if (nb_owned_lobbies >= Config.max_lobbies) {
  185. return reject(new Error('Cannot create any more matches.'));
  186. }
  187. const room_created_listener = async (msg) => {
  188. const room_created_regex = /Created the tournament match https:\/\/osu\.ppy\.sh\/mp\/(\d+) (.+)/;
  189. if (msg.from == 'BanchoBot') {
  190. if (msg.message.indexOf('You cannot create any more tournament matches.') == 0) {
  191. this.off('pm', room_created_listener);
  192. return reject(new Error('Cannot create any more matches.'));
  193. }
  194. const m = room_created_regex.exec(msg.message);
  195. if (m && m[2] == lobby_title) {
  196. this.off('pm', room_created_listener);
  197. for (const lobby of this._lobbies) {
  198. if (lobby.id == m[1]) {
  199. console.log(`Created lobby "${lobby_title}".`);
  200. return resolve(lobby);
  201. }
  202. }
  203. // Should not be reachable, as long as Bancho sends the commands
  204. // in the right order (which it should).
  205. return reject(new Error('Lobby was created but not joined. (ask the devs to check the logs)'));
  206. }
  207. }
  208. };
  209. this.on('pm', room_created_listener);
  210. this.privmsg('BanchoBot', `!mp make ${lobby_title}`);
  211. });
  212. }
  213. join(channel) {
  214. return new Promise((resolve, reject) => {
  215. for (const lobby of this._lobbies) {
  216. if (lobby.channel == channel) {
  217. return reject(new Error('Lobby already joined'));
  218. }
  219. }
  220. const join_listener = (evt, err) => {
  221. if (evt.channel == channel) {
  222. this.off('lobbyJoined', join_listener);
  223. if (err) {
  224. return reject(err);
  225. } else {
  226. return resolve(evt.lobby);
  227. }
  228. }
  229. };
  230. this.on('lobbyJoined', join_listener);
  231. this._send('JOIN ' + channel);
  232. });
  233. }
  234. privmsg(destination, content) {
  235. // In a recent Bancho update, PMing users with spaces in their username
  236. // resulted in sometimes PMing the wrong user. To get around this, we
  237. // always replace spaces with underscores.
  238. destination = destination.replaceAll(' ', '_');
  239. return new Promise((resolve, reject) => {
  240. if (!this._socket || this._socket.readyState != 'open') {
  241. return resolve();
  242. }
  243. this._outgoing_messages.push({
  244. message: `PRIVMSG ${destination} :${content}`,
  245. callback: resolve,
  246. });
  247. });
  248. }
  249. // (async) Get a user ID from an IRC username.
  250. whois(irc_username) {
  251. return new Promise((resolve, reject) => {
  252. if (irc_username.indexOf(' ') != -1) {
  253. reject(new Error('Cannot WHOIS a username that contains spaces.'));
  254. return;
  255. }
  256. setTimeout(() => reject(new Error('Timeout out on whois request')), 5000);
  257. if (irc_username in this._whoare) {
  258. return resolve(this._whoare[irc_username]);
  259. }
  260. this._whois_requests[irc_username] = {resolve, reject};
  261. this._send('WHOIS ' + irc_username);
  262. });
  263. }
  264. }
  265. const bancho = new BanchoClient();
  266. export default bancho;