diff --git a/.eslintrc.yaml b/.eslintrc.yaml index a1d5ca8..c2d21ea 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -1,34 +1,23 @@ root: true parser: babel-eslint extends: - - '@atomix' + # - '@atomix' - standard env: - es6: true node: true - browser: true parserOptions: sourceType: module ecmaVersion: 2018 ecmaFeatures: globalReturn: false - modules: true + modules: false experimentalObjectRestSpread: true -globals: - BigInt: true rules: max-len: off - import/no-cycle: off - no-param-reassign: off - prettier/prettier: off prefer-rest-params: off prefer-destructuring: off - no-underscore-dangle: off - unicorn/filename-case: off unicorn/prefer-spread: off - class-methods-use-this: off function-paren-newline: off - import/no-default-export: off curly: - error comma-dangle: diff --git a/knexfile.js b/knexfile.js index f9c4aa6..096f5bd 100644 --- a/knexfile.js +++ b/knexfile.js @@ -1,4 +1,4 @@ -require('dotenv').load() +require('dotenv').config() const { DB_CLIENT, diff --git a/package.json b/package.json index 13663de..d6e7885 100644 --- a/package.json +++ b/package.json @@ -15,22 +15,22 @@ }, "license": "MIT", "dependencies": { - "chess": "^0.2.8", - "dotenv": "^5.0.1", - "knex": "^0.14.6", + "chess": "^0.4.1", + "dotenv": "^7.0.0", + "knex": "^0.16.5", "module-alias": "^2.2.0", - "pg": "^7.8.1", - "telegraf": "^3.27.1" + "pg": "^7.10.0", + "telegraf": "^3.29.0" }, "devDependencies": { - "@atomix/eslint-config": "^7.0.0-next.1", + "babel-eslint": "^10.0.1", "eslint": "^5.16.0", "eslint-config-standard": "^12.0.0", + "eslint-plugin-import": "^2.17.2", "eslint-plugin-node": "^8.0.1", "eslint-plugin-promise": "^4.1.1", "eslint-plugin-standard": "^4.0.0", - "nodemon": "^1.18.10", - "prettier": "^1.17.0", + "nodemon": "^1.18.11", "sqlite3": "^4.0.6" }, "_moduleAliases": { diff --git a/src/handlers/index.js b/src/handlers/index.js index 9d90316..fb09f67 100644 --- a/src/handlers/index.js +++ b/src/handlers/index.js @@ -7,7 +7,9 @@ const startHandler = require('./start') const actionsHandler = require('./actions') const messageHandler = require('./message') const optionsHandler = require('./options') -const inlineHandler = require('./inline') +const inlineJoinHandler = require('./inlineJoin') +const inlineMoveHandler = require('./inlineMove') +const inlineQueryHandler = require('./inlineQuery') module.exports = { newHandler, @@ -19,5 +21,7 @@ module.exports = { actionsHandler, messageHandler, optionsHandler, - inlineHandler, + inlineJoinHandler, + inlineMoveHandler, + inlineQueryHandler, } diff --git a/src/handlers/inline/index.js b/src/handlers/inline/index.js deleted file mode 100644 index 70fe43c..0000000 --- a/src/handlers/inline/index.js +++ /dev/null @@ -1,70 +0,0 @@ -const chess = require('chess') - -const { board } = require('@/keyboards') -const { debug, unescapeUser, escapeUser } = require('@/helpers') - -const gameClient = chess.create({ PGN: true }) -const status = gameClient.getStatus() - -module.exports = () => [ - 'inline_query', - async (ctx) => { - debug(ctx.update) - - let user = await ctx.db('users') - .where('id', ctx.update.inline_query.from.id) - .first() - - if (user) { - user = unescapeUser(user) - - if (JSON.stringify(user) !== JSON.stringify(ctx.update.inline_query.from)) { - await ctx.db('users') - .where('id', user.id) - .update(escapeUser(ctx.update.inline_query.from)) - } - } else { - const users = await ctx.db('users') - .insert(escapeUser(ctx.update.inline_query.from)) - .returning(Object.keys(ctx.update.inline_query.from)) - - user = unescapeUser(users[0]) - } - - await ctx.answerInlineQuery([ - { - id: 1, - type: 'photo', - thumb_url: 'https://crossword.live/img/w.png', - photo_url: 'https://crossword.live/img/w.png', - title: 'Play with white pieces', - input_message_content: { - message_text: `Black (top): ? -White (bottom): ${user.first_name}`, - }, - ...board(status.board.squares, true, [{ - text: 'Join the game', - callback_data: `join::w::${user.id}`, - }]), - }, - { - id: 2, - type: 'photo', - thumb_url: 'https://crossword.live/img/b.png', - photo_url: 'https://crossword.live/img/b.png', - title: 'Play with black pieces', - input_message_content: { - message_text: `White (top): ? -Black (bottom): ${user.first_name}`, - }, - ...board(status.board.squares, false, [{ - text: 'Join the game', - callback_data: `join::b::${user.id}`, - }]), - }, - ], { - is_personal: true, - cache_time: 0, - }) - }, -] diff --git a/src/handlers/inlineJoin/index.js b/src/handlers/inlineJoin/index.js new file mode 100644 index 0000000..a478398 --- /dev/null +++ b/src/handlers/inlineJoin/index.js @@ -0,0 +1,53 @@ +const chess = require('chess') + +const { board } = require('@/keyboards') +const { debug, escapeUser, unescapeUser } = require('@/helpers') + +module.exports = () => [ + /^join::([wb])::(\d+)/, + async (ctx) => { + debug(ctx.update) + const userId = Number(ctx.match[2]) + const iAmWhite = () => ctx.match[1] !== 'w' + + if (ctx.from.id === userId) { + return ctx.answerCbQuery('You can\'t join yourself!') + } + + let enemy = await ctx.db('users').where('id', userId).first().catch(debug) + + if (enemy) { + enemy = unescapeUser(enemy) + } else { + enemy = ctx.tg.getChatMember( + ctx.callbackQuery.chat_instance, + userId + ) + await ctx.db('users').insert(escapeUser(enemy)).catch(debug) + } + + const [gameId] = await ctx.db('games').returning('id').insert({ + user_w: !iAmWhite() ? enemy.id : ctx.from.id, + user_b: !iAmWhite() ? ctx.from.id : enemy.id, + inline_id: ctx.update.callback_query.inline_message_id, + }).catch(debug) + + ctx.session.gameId = gameId + + const gameClient = chess.create({ PGN: true }) + const status = gameClient.getStatus() + + await ctx.editMessageText( + iAmWhite() + ? `Black (top): ${enemy.first_name} +White (bottom): ${ctx.from.first_name} +White's turn` + : `Black (top): ${ctx.from.first_name} +White (bottom): ${enemy.first_name} +White's turn`, + board(status.board.squares, true) + ) + + return ctx.answerCbQuery('Now play!') + }, +] diff --git a/src/handlers/inlineMove/index.js b/src/handlers/inlineMove/index.js new file mode 100644 index 0000000..6c5ea9d --- /dev/null +++ b/src/handlers/inlineMove/index.js @@ -0,0 +1,153 @@ +const chess = require('chess') + +const { board } = require('@/keyboards') +const { debug, escapeUser, unescapeUser } = require('@/helpers') + +const isWhiteTurn = (moves) => !(moves.length % 2) +const isWhiteUser = (game, ctx) => Number(game.user_w) === ctx.from.id +const isBlackUser = (game, ctx) => Number(game.user_b) === ctx.from.id + +const statusMessage = ({ isCheck, isCheckmate, isRepetition }) => ` +${isCheck ? '|CHECK|' : ''} +${isCheckmate ? '|CHECKMATE|' : ''} +${isRepetition ? '|REPETITION|' : ''}` + +const topMessage = (moves, player, enemy) => isWhiteTurn(moves) + ? `White (top): ${player.first_name} +Black (bottom): ${enemy.first_name} +Black's turn` + : `Black (top): ${player.first_name} +White (bottom): ${enemy.first_name} +White's turn` + +const isReady = (game) => !!(game.user_w && game.user_b) + +module.exports = () => [ + /^([a-h])([1-8])$/, + async (ctx) => { + const gameState = await ctx.db('games') + .where('id', ctx.session.gameId) + .first() + + debug(gameState) + + if (!isReady(gameState)) { + return ctx.answerCbQuery('Join the game to move pieces!') + } + + if (![Number(gameState.user_w), Number(gameState.user_b)] + .includes(ctx.from.id)) { + return ctx.answerCbQuery('This board is full, please start a new one.') + } + + const movesState = await ctx.db('moves') + .where('game_id', gameState.id) + .orderBy('created_at', 'asc') + .select() + + if ((isWhiteTurn(movesState) && isBlackUser(gameState, ctx)) || + (!isWhiteTurn(movesState) && isWhiteUser(gameState, ctx))) { + return ctx.answerCbQuery('Wait, please. Now is not your turn.') + } + + const gameClient = chess.create({ PGN: true }) + + movesState.forEach(({ move }) => { + try { + gameClient.move(move) + } catch (error) { + debug(error) + } + }) + + let moves = [] + let status = gameClient.getStatus() + const square = status.board.squares + .find(({ file, rank }) => file === ctx.match[1] && rank === Number(ctx.match[2])) + + if (!ctx.session.moves) { + if ( + !square || + !square.piece || + (square.piece.side.name === 'black' && isWhiteTurn(movesState)) || + (square.piece.side.name === 'white' && !isWhiteTurn(movesState)) + ) { + return ctx.answerCbQuery() + } + + moves = Object.keys(status.notatedMoves) + .filter((key) => status.notatedMoves[key].src === square) + .map((key) => ({ ...status.notatedMoves[key], key })) + + await ctx.editMessageReplyMarkup(board( + status.board.squares.map((sqr) => { + const move = moves + .find((({ file, rank }) => ({ dest }) => dest.file === file && + dest.rank === rank)(sqr)) + + return move ? { ...sqr, destination: move } : sqr + }), + isWhiteTurn(movesState) + ).reply_markup) + .catch(debug) + + ctx.session.moves = moves + ctx.session.selected = square + } else { + const moving = ctx.session.moves + .find(({ dest: { file, rank } }) => file === square.file && rank === square.rank) + + if (moving && !movesState.find(({ move }) => move === moving.key)) { + try { + gameClient.move(moving.key) + } catch (error) { + debug(error) + } + + status = gameClient.getStatus() + + await ctx.db('moves').insert({ + game_id: ctx.session.gameId, + move: moving.key, + }) + .catch(debug) + } + + ctx.session.moves = null + ctx.session.selected = null + + let enemy = await ctx.db('users') + .where('id', isWhiteTurn(movesState) + ? Number(gameState.user_w) + : Number(gameState.user_b)) + .first() + .catch(debug) + + if (enemy) { + enemy = unescapeUser(enemy) + } else { + enemy = ctx.tg.getChatMember( + ctx.callbackQuery.chat_instance, + isWhiteTurn(movesState) + ? Number(gameState.user_w) + : Number(gameState.user_b) + ) + await ctx.db('users').insert(escapeUser(enemy)).catch(debug) + } + + debug(enemy) + + await ctx.editMessageText( + topMessage( + movesState, + ctx.update.callback_query.from, + enemy + ) + statusMessage(status), + board(status.board.squares, !isWhiteTurn(movesState)) + ) + .catch(debug) + } + + return ctx.answerCbQuery() + }, +] diff --git a/src/handlers/inlineQuery/index.js b/src/handlers/inlineQuery/index.js new file mode 100644 index 0000000..e1b0918 --- /dev/null +++ b/src/handlers/inlineQuery/index.js @@ -0,0 +1,67 @@ +const chess = require('chess') + +const { board } = require('@/keyboards') +const { debug, unescapeUser, escapeUser } = require('@/helpers') + +const gameClient = chess.create({ PGN: true }) +const status = gameClient.getStatus() + +module.exports = () => async (ctx) => { + debug(ctx.update) + + let user = await ctx.db('users') + .where('id', Number(ctx.from.id)) + .first() + + if (user) { + user = unescapeUser(user) + + if (JSON.stringify(user) !== JSON.stringify(ctx.from)) { + await ctx.db('users') + .where('id', Number(user.id)) + .update(escapeUser(ctx.from)) + .catch(debug) + } + } else { + const users = await ctx.db('users') + .insert(escapeUser(ctx.from)) + .returning(Object.keys(ctx.from)) + .catch(debug) + + user = unescapeUser(users[0]) + } + + await ctx.answerInlineQuery([ + { + id: 1, + type: 'sticker', + sticker_file_id: 'CAADAgADNAADX5T2DgeepFdKYLnKAg', + input_message_content: { + message_text: `Black (top): ? +White (bottom): ${user.first_name} +Waiting for a black side`, + }, + ...board(status.board.squares, true, [{ + text: 'Join the game', + callback_data: `join::w::${user.id}`, + }]), + }, + { + id: 2, + type: 'sticker', + sticker_file_id: 'CAADAgADMwADX5T2DqhR9w5HSpCZAg', + input_message_content: { + message_text: `White (top): ? +Black (bottom): ${user.first_name} +Waiting for a white side`, + }, + ...board(status.board.squares, false, [{ + text: 'Join the game', + callback_data: `join::b::${user.id}`, + }]), + }, + ], { + is_personal: true, + cache_time: 0, + }) +} diff --git a/src/helpers/index.js b/src/helpers/index.js index 37948d1..e75112c 100644 --- a/src/helpers/index.js +++ b/src/helpers/index.js @@ -4,10 +4,12 @@ const emodji = require('./emodji') module.exports = { debug, emodji, + unescapeUser: (user) => Object.keys(user).reduce((acc, key) => { acc[key] = typeof user[key] === 'string' ? unescape(user[key]) : user[key] return acc }, {}), + escapeUser: (user) => Object.keys(user).reduce((acc, key) => { acc[key] = typeof user[key] === 'string' ? escape(user[key]) : user[key] return acc diff --git a/src/index.js b/src/index.js index 5af708d..de11cbe 100644 --- a/src/index.js +++ b/src/index.js @@ -1,14 +1,20 @@ -require('dotenv').load() +require('dotenv').config() require('module-alias/register') const knex = require('knex') const Telegraf = require('telegraf') const Stage = require('telegraf/stage') -const { debug, unescapeUser } = require('@/helpers') const { gameScene } = require('@/scenes') const knexConfig = require('@/../knexfile') -const { inlineHandler, loadHandler, joinHandler, newHandler } = require('@/handlers') +const { + // newHandler, + // joinHandler, + // loadHandler, + inlineJoinHandler, + inlineMoveHandler, + inlineQueryHandler, +} = require('@/handlers') const { session } = Telegraf const { @@ -21,40 +27,18 @@ const bot = new Telegraf(BOT_TOKEN, { username: BOT_NAME }) bot.context.db = knex(knexConfig) -bot.use(session()) +bot.use(session({ + getSessionKey: (ctx) => (ctx.update.callback_query && ctx.update.callback_query.inline_message_id) || + (ctx.from && ctx.chat && `${ctx.from.id}:${ctx.chat.id}`), +})) bot.use(stage.middleware()) -bot.on(...inlineHandler()) -bot.start(...loadHandler()) -bot.action(...newHandler()) -bot.action(...joinHandler()) +// bot.start(...loadHandler()) +// bot.action(...newHandler()) +// bot.action(...joinHandler()) -bot.action(/^join::(\w)::(\d+)/, async (ctx) => { - debug(ctx.update) - const userId = Number(ctx.match[2]) - const iAmWhite = () => ctx.match[1] !== 'w' - - if (ctx.from.id === userId) { - return ctx.answerCbQuery('You can\'t join yourself!') - } - - const enemy = await ctx.db('users').where('id', userId).first() - - await ctx.db('games').returning('id').insert({ - user_w: !iAmWhite() ? enemy.id : ctx.from.id, - user_b: !iAmWhite() ? ctx.from.id : enemy.id, - inline_id: ctx.update.callback_query.inline_message_id, - }).catch(debug) - - await ctx.editMessageText( - !iAmWhite() - ? `Black (top): ${ctx.from.first_name} -White (bottom): ${unescapeUser(enemy).first_name}` - : `Black (top): ${unescapeUser(enemy).first_name} -White (bottom): ${ctx.from.first_name}` - ) - - return ctx.answerCbQuery() -}) +bot.action(...inlineJoinHandler()) +bot.action(...inlineMoveHandler()) +bot.on('inline_query', inlineQueryHandler()) bot.startPolling() diff --git a/src/keyboards/board/index.js b/src/keyboards/board/index.js index def6d39..323956a 100644 --- a/src/keyboards/board/index.js +++ b/src/keyboards/board/index.js @@ -6,7 +6,8 @@ const { Markup } = Telegraf module.exports = (board, isWhite, actions) => { const horizontal = 'abcdefgh'.split('') - const vertical = Array.from({ length: 8 }).map((item, idx) => idx + 1).reverse() + const vertical = Array.from({ length: 8 }) + .map((item, idx) => idx + 1).reverse() const boardMarkup = vertical.map((row) => horizontal.map((col) => { const square = board @@ -29,10 +30,13 @@ module.exports = (board, isWhite, actions) => { : { text: unescape('%u0020'), callback_data: `${col}${row}` } })) - return Markup.inlineKeyboard([ - ...(isWhite - ? boardMarkup - : boardMarkup.map((row) => row.reverse()).reverse()), - actions, - ]).extra() + const keyboard = isWhite + ? boardMarkup + : boardMarkup.map((row) => row.reverse()).reverse() + + if (actions) { + keyboard.push(actions) + } + + return Markup.inlineKeyboard(keyboard).extra() }