'use strict'; const config = require('config'); const { setTimeout: delay } = require('timers/promises'); const bhttp = require('bhttp'); const WebSocket = require('ws'); const fs = require('fs').promises; const logger = require('simple-node-logger').createSimpleLogger(); const { argv } = require('yargs'); const instance = process.env.NODE_APP_INSTANCE || 'main'; const points = {}; logger.setLevel(argv.level || 'info'); async function auth() { const httpSession = bhttp.session(); const username = config.uniqueUsername ? `${config.user.username}-${new Date().getTime().toString().slice(-5)}` : config.user.username; const res = await httpSession.post(`${config.api}/session`, { ...config.user, username, }, { encodeJSON: true, }); if (res.statusCode !== 200) { throw new Error(`Failed to authenticate: ${res.body.toString()}`); } logger.info(`Authenticated as '${username}' with ${config.api}`); return { user: res.body, httpSession, sessionCookie: res.headers['set-cookie'][0], }; } async function getWsId(httpSession) { const res = await httpSession.get(`${config.api}/socket`); if (res.statusCode !== 200) { throw new Error(`Failed to retrieve WebSocket ID: ${res.body.toString()}`); } return res.body; } async function setPoints(defaultKey, user, value, { mode = 'add', key }) { const gameKey = key || defaultKey; if (!user) { logger.warn(`Failed to set ${gameKey} points for missing user`); return; } const userKey = `${user.id}:${user.username}`; if (!points[gameKey]) { points[gameKey] = {}; } if (mode === 'add') { points[gameKey][userKey] = (points[gameKey][userKey] || 0) + value; } if (mode === 'set') { points[gameKey][userKey] = value; } await fs.writeFile(`./points-${instance}.json`, JSON.stringify(points, null, 4)); } function getPoints(game, { user, room, command }) { const userPoints = (points[command] || points[game.key])?.[`${user.id}:${user.username}`]; game.sendMessage(`You have scored **${userPoints || 0}** points in ${game.name}, @${user.username}`, room.id); } function getLeaderboard(game, { user, room, command }) { const leaderboard = points[command] || points[game.key]; if (!leaderboard || Object.keys(leaderboard).length === 0) { game.sendMessage(`No points scored in ${game.name} yet!`, room.id); return; } const curatedLeaderboard = Object.entries(leaderboard) .sort(([, scoreA], [, scoreB]) => scoreB - scoreA) .map(([userKey, score]) => { const username = userKey.split(':')[1]; return `**${username === user.username ? '@' : ''}${username}** at **${score}** points`; }) .slice(-10) .join(', '); game.sendMessage(`The top ${Math.min(Object.keys(leaderboard).length, 10)} *${game.name}* players are: ${curatedLeaderboard}`, room.id); } function onConnect(data, bot) { bot.socket.transmit('joinRooms', { rooms: config.channels }); } function onRooms({ rooms, users }, bot) { logger.info(`Joined ${rooms.map((room) => room.name).join(', ')}`); /* eslint-disable no-param-reassign */ bot.rooms = rooms.reduce((acc, room) => ({ ...acc, [room.id]: room }), {}); bot.users = { ...bot.users, ...users }; /* eslint-enable no-param-reassign */ rooms.forEach((room) => { bot.socket.transmit('message', { roomId: room.id, body: `Hi, I am ${config.user.username}, your game host!`, style: config.style, }); }); } /* eslint-disable no-param-reassign */ function onJoin(data, bot) { if (bot.rooms[data.roomId] && !bot.rooms[data.roomId].userIds?.includes(data.user.id)) { bot.users[data.user.id] = data.user; bot.rooms[data.roomId].userIds.push(data.user.id); } } function onLeave(data, bot) { if (bot.rooms[data.roomId]) { bot.rooms[data.roomId].userIds = bot.rooms[data.roomId].userIds.filter((userId) => userId !== data.userId); } } function onMessage(message, bot, games) { const [, command, subcommand] = message.body?.match(new RegExp(`^${config.prefix}(\\w+)(?:\\:(\\w+))?`)) || []; const user = bot.users[message.userId]; const room = bot.rooms[message.roomId]; if (['leaderboard', 'lead', 'leader', 'leaders', 'scoreboard', 'best'].includes(subcommand) && games[command]) { getLeaderboard(games[command], { user, room, command }); return; } if (['points', 'score'].includes(subcommand) && games[command]) { getPoints(games[command], { user, room, command }); return; } if (command) { const args = message.body.split(/\s+/).slice(1); const game = games[command]; if (game && game.onCommand) { if (user) { user.points = points[game.key]?.[`${user.id}:${user.username}`] || 0; } game.onCommand(args, { ...game, command, subcommand, bot, message, user, room, points: points[game.key] || {}, logger, }); } } Object.values(games).forEach((game) => game.onMessage?.(message, { ...game, bot, message, user, room, logger, })); } const messageHandlers = { connect: onConnect, rooms: onRooms, message: onMessage, join: onJoin, leave: onLeave, }; async function initPoints() { try { const pointsFile = await fs.readFile(`./points-${instance}.json`, 'utf-8'); Object.assign(points, JSON.parse(pointsFile)); } catch (error) { if (error.code === 'ENOENT') { logger.info('Creating new points file'); await fs.writeFile(`./points-${instance}.json`, '{}'); initPoints(); } } } function getGames(bot) { const games = config.games.reduce((acc, key) => { const game = require(`./games/${key.game || key}`); // eslint-disable-line global-require, import/no-dynamic-require const sendMessage = (body, roomId) => { bot.socket.transmit('message', { roomId, body: `[${game.name || key}] ${body}`, style: config.style, }); }; const setGamePoints = (userId, score, options) => setPoints(key, userId, score, options); const curatedGame = { ...game, ...(key.game && key), name: game.name || key, key, sendMessage, setPoints: setGamePoints, }; if (game.onStart) { game.onStart({ ...curatedGame, bot }); } return { ...acc, [key]: curatedGame, ...game.commands?.reduce((commandAcc, command) => ({ ...commandAcc, [command]: curatedGame }), {}), }; }, {}); return games; } function handleError(error, socket, domain, data) { logger.error(`${domain} '${JSON.stringify(data)}' triggered error: ${error.message}`); if (data?.roomId) { socket.transmit('message', { body: ':zap::robot::zap: Many fragments! Some large, some small.', type: 'message', roomId: data.roomId, }); } } async function connect(wsCreds, sessionCookie, bot, games) { const socket = { ws: { readyState: 0 } }; socket.connect = () => { logger.info(`Attempting to connect to ${config.socket}`); socket.ws = new WebSocket(`${config.socket}?${new URLSearchParams({ v: wsCreds.wsId, t: wsCreds.timestamp }).toString()}`, [], { headers: { cookie: sessionCookie, }, }); socket.ws.on('message', async (msg) => { const [domain, data] = JSON.parse(msg); logger.debug(`Received ${domain}: ${JSON.stringify(data)}`); if (messageHandlers[domain]) { try { await messageHandlers[domain](data, bot, games); } catch (error) { handleError(error, socket, domain, data); } } }); socket.ws.on('close', async (info) => { logger.error(`WebSocket closed, reconnecting in ${config.reconnectDelay} seconds: ${info}`); await delay(config.reconnectDelay * 1000); socket.connect(); }); socket.ws.on('error', async (error) => { logger.error(`WebSocket error: ${error.message}`); }); logger.info(`Connected to ${config.socket}`); }; socket.transmit = (domain, data) => { socket.ws.send(JSON.stringify([domain, data])); }; socket.connect(); return socket; } async function init() { const { user, httpSession, sessionCookie } = await auth(); const wsCreds = await getWsId(httpSession); const bot = { user, httpSession, rooms: [], users: [], }; const games = getGames(bot); bot.socket = await connect(wsCreds, sessionCookie, bot, games); await initPoints(); } init();