From a9a39a1d93d322634f45b787df1371f5a4ca18f2 Mon Sep 17 00:00:00 2001 From: Roman Bekkiev Date: Sat, 27 Nov 2021 16:28:40 +0200 Subject: [PATCH] reply with message on /sendMessage (fix #19) --- bin/start.js | 2 +- src/routes/bot/sendMessage.ts | 4 +- src/telegramServer.ts | 94 +++++++++++++++++++++++++++-------- src/test/generic.test.ts | 61 +++++++++++++++++++++-- 4 files changed, 134 insertions(+), 27 deletions(-) diff --git a/bin/start.js b/bin/start.js index 1ed412c..8847bda 100644 --- a/bin/start.js +++ b/bin/start.js @@ -1,6 +1,6 @@ 'use strict'; -const TelegramServer = require('../lib/index'); +const {default: TelegramServer} = require('../lib/index'); // eslint-disable-next-line import/no-unresolved const config = require('../config/config.json'); diff --git a/src/routes/bot/sendMessage.ts b/src/routes/bot/sendMessage.ts index 5a56faa..3140c4d 100644 --- a/src/routes/bot/sendMessage.ts +++ b/src/routes/bot/sendMessage.ts @@ -4,8 +4,8 @@ import type { Route } from '../route'; export const sendMessage: Route = (app, telegramServer) => { handle(app, '/bot:token/sendMessage', (req, res, _next) => { const botToken = req.params.token; - telegramServer.addBotMessage(req.body, botToken); - const data = { ok: true, result: null }; + const result = telegramServer.addBotMessage(req.body, botToken); + const data = { ok: true, result }; res.sendResult(data); }); }; diff --git a/src/telegramServer.ts b/src/telegramServer.ts index 91bacc9..24f999f 100644 --- a/src/telegramServer.ts +++ b/src/telegramServer.ts @@ -2,11 +2,13 @@ import assert from 'assert'; import request from 'axios'; import debugTest from 'debug'; import EventEmitter from 'events'; -import type { Express } from 'express'; +import type { ErrorRequestHandler, Express } from 'express'; import express from 'express'; import http from 'http'; import shutdown from 'http-shutdown'; -import type { MessageEntity, Params } from 'typegram'; +import type { + InlineKeyboardMarkup, Message, MessageEntity, Params, +} from 'typegram'; import { requestLogger } from './modules/requestLogger'; import { sendResult } from './modules/sendResult'; import type { @@ -36,14 +38,14 @@ interface StoredUpdate { isRead: boolean; } -type BotIncommingMessage = Params<'sendMessage', never>[0] & { - // TODO parse reply_markup to its actual type, see https://git.io/J1kiM - reply_markup?: string; -}; +type BotIncommingMessage = Params<'sendMessage', never>[0]; + +type BotEditTextIncommingMessage = Params<'editMessageText', never>[0]; -type BotEditTextIncommingMessage = Params<'editMessageText', never>[0] & { - reply_markup?: string; -}; +type RawIncommingMessage = { + reply_markup?: string | object; + entities?:string | object +} export interface StoredBotUpdate extends StoredUpdate { message: BotIncommingMessage; @@ -135,13 +137,15 @@ export class TelegramServer extends EventEmitter { this.webServer.use((_req, res) => { res.sendError(new Error('Route not found')); }); - // Catch express bodyParser error, like http://stackoverflow.com/questions/15819337/catch-express-bodyparser-error - // TODO check if signature with `error` first actually still works - // eslint-disable-next-line @typescript-eslint/no-explicit-any - this.webServer.use((error, _req, res: any) => { + /** + * Catch uncought errors e.g. express bodyParser error + * @see https://expressjs.com/en/guide/error-handling.html#the-default-error-handler + */ + const globalErrorHandler: ErrorRequestHandler = (error, _req, res, _next) => { debugServer(`Error: ${error}`); res.sendError(new Error(`Something went wrong. ${error}`)); - }); + }; + this.webServer.use(globalErrorHandler); } static normalizeConfig(config: Partial) { @@ -161,9 +165,10 @@ export class TelegramServer extends EventEmitter { return new TelegramClient(this.config.apiURL, botToken, options); } - addBotMessage(message: BotIncommingMessage, botToken: string) { + addBotMessage(rawMessage: BotIncommingMessage, botToken: string) { const d = new Date(); const millis = d.getTime(); + const message = TelegramServer.normalizeMessage(rawMessage); const add = { time: millis, botToken, @@ -173,12 +178,33 @@ export class TelegramServer extends EventEmitter { isRead: false, }; this.storage.botMessages.push(add); + + // only InlineKeyboardMarkup is allowed in response + let inlineMarkup: InlineKeyboardMarkup | undefined; + if (message.reply_markup && 'inline_keyboard' in message.reply_markup) { + inlineMarkup = message.reply_markup; + } + const msg: Message.TextMessage = { + ...message, + reply_markup: inlineMarkup, + message_id: this.messageId, + date: add.time, + text: message.text, + chat: { + id: Number(message.chat_id), + first_name: 'Bot', + type: 'private', + }, + }; + this.messageId++; this.updateId++; this.emit('AddedBotMessage'); + return msg; } - editMessageText(message: BotEditTextIncommingMessage) { + editMessageText(rawMessage: BotEditTextIncommingMessage) { + const message = TelegramServer.normalizeMessage(rawMessage); const update = this.storage.botMessages.find( (u) =>( String(u.messageId) === String(message.message_id) @@ -191,6 +217,12 @@ export class TelegramServer extends EventEmitter { } } + /** + * @FIXME + * (node:103570) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 + * EditedMessageText listeners added to [TelegramServer]. Use emitter.setMaxListeners() to + * increase limit (Use `node --trace-warnings ...` to show where the warning was created) + */ async waitBotEdits() { return new Promise((resolve) => { this.on('EditedMessageText', () => resolve()); @@ -264,7 +296,7 @@ export class TelegramServer extends EventEmitter { const resp = await request({ url: webhook.url, method: 'POST', - data: this.formatUpdate(update), + data: TelegramServer.formatUpdate(update), }); if (resp.status > 204) { debugServer( @@ -343,7 +375,7 @@ export class TelegramServer extends EventEmitter { return messages.map((update) => { // eslint-disable-next-line no-param-reassign update.isRead = true; - return this.formatUpdate(update); + return TelegramServer.formatUpdate(update); }); } @@ -445,8 +477,7 @@ export class TelegramServer extends EventEmitter { return true; } - // eslint-disable-next-line class-methods-use-this - private formatUpdate(update: StoredClientUpdate) { + private static formatUpdate(update: StoredClientUpdate) { if ('callbackQuery' in update) { return { update_id: update.updateId, @@ -470,4 +501,27 @@ export class TelegramServer extends EventEmitter { }, }; } + + /** + * Telegram API docs say that `reply_markup` and `entities` must JSON serialized string + * however e.g. Telegraf sends it as an object and the real Telegram API works just fine + * with that, so aparently those fields are _sometimes_ a JSON serialized strings. + * For testing purposes it is easier to have everything uniformely parsed, thuse we parse them. + * @see https://git.io/J1kiM for more info on that topic. + * @param message incomming message that can have JSON-serialized strings + * @returns the same message but with reply_markdown & entities parsed + */ + private static normalizeMessage (message: T) { + if ('reply_markup' in message) { + // eslint-disable-next-line no-param-reassign + message.reply_markup = typeof message.reply_markup === 'string' + ? JSON.parse(message.reply_markup) : message.reply_markup; + } + if ('entities' in message) { + // eslint-disable-next-line no-param-reassign + message.entities = typeof message.entities === 'string' + ? JSON.parse(message.entities) : message.entities; + } + return message; + } } diff --git a/src/test/generic.test.ts b/src/test/generic.test.ts index 9b51b95..c3da3d0 100644 --- a/src/test/generic.test.ts +++ b/src/test/generic.test.ts @@ -3,6 +3,7 @@ import debug from 'debug'; import { assert } from 'chai'; import TelegramBot from 'node-telegram-bot-api'; +import type { InlineKeyboardMarkup, KeyboardButton, ReplyKeyboardMarkup } from 'typegram'; import { getServerAndClient, assertEventuallyTrue, delay } from './utils'; import { TelegramBotEx, @@ -11,9 +12,23 @@ import { CallbackQBot, Logger, } from './testBots'; +import type { StoredBotUpdate } from '../telegramServer'; const debugTest = debug('TelegramServer:test'); +function isReplyKeyboard(markup: object | undefined): markup is ReplyKeyboardMarkup { + return markup !== undefined && 'keyboard' in markup; +} +function isInlineKeyboard(markup: object | undefined): markup is InlineKeyboardMarkup { + return markup !== undefined && 'inline_keyboard' in markup; +} +function isCommonButton(btn: unknown): btn is KeyboardButton.CommonButton { + return typeof btn === 'object' && btn !== null && 'text' in btn; +} +function isBotUpdate(upd: object | undefined): upd is StoredBotUpdate { + return upd !== undefined && 'message' in upd; +} + describe('Telegram Server', () => { const token = 'sampleToken'; @@ -91,6 +106,38 @@ describe('Telegram Server', () => { await server.stop(); }); + it('should message in response to /sendMessage', (done) => { + getServerAndClient(token).then(({ server, client }) => { + const botOptions = { polling: true, baseApiUrl: server.config.apiURL }; + const bot = new TelegramBot(token, botOptions); + bot.onText(/\/start/, async (msg) => { + const chatId = msg.chat.id; + if (!chatId) return; + const reply = await bot.sendMessage(chatId, 'ololo #azaza', { + reply_to_message_id: msg.message_id, + reply_markup: { + inline_keyboard: [[{text: 'foo', callback_data: 'bar'}]], + }, + }); + const update = server.getUpdatesHistory(token).find((upd) => reply.message_id === upd.messageId); + if (!isBotUpdate(update)) { + assert.fail('Cannot find bot update with messageId porvided in response'); + } + assert.equal(update.message.text, reply.text); + if (!isInlineKeyboard(update.message.reply_markup)) { + assert.fail('Wrong keyboard type in stored update'); + } + assert.deepEqual(reply.reply_markup, update.message.reply_markup!); + + await server.stop(); + await bot.stopPolling(); + done(); + }); + + return client.sendMessage(client.makeMessage('/start')); + }).catch((err) => assert.fail(err)); + }); + it('should fully implement user-bot interaction', async () => { const { server, client } = await getServerAndClient(token); let message = client.makeMessage('/start'); @@ -107,8 +154,11 @@ describe('Telegram Server', () => { updates.result.length, 'Updates queue should contain one message!', ); - const { keyboard } = JSON.parse(updates.result[0].message.reply_markup!); - message = client.makeMessage(keyboard[0][0].text); + const markup = updates.result[0].message.reply_markup!; + if (!isReplyKeyboard(markup) || !isCommonButton(markup.keyboard[0][0])) { + throw new Error('No keyboard in update'); + } + message = client.makeMessage(markup.keyboard[0][0].text); await client.sendMessage(message); const updates2 = await client.getUpdates(); Logger.serverUpdate(updates2.result); @@ -161,8 +211,11 @@ describe('Telegram Server', () => { updates.result.length, 'Updates queue should contain one message!', ); - const { keyboard } = JSON.parse(updates.result[0].message.reply_markup!); - message = client.makeMessage(keyboard[0][0].text); + const markup = updates.result[0].message.reply_markup!; + if (!isReplyKeyboard(markup) || !isCommonButton(markup.keyboard[0][0])) { + throw new Error('No keyboard in update'); + } + message = client.makeMessage(markup.keyboard[0][0].text); await client.sendMessage(message); const updates2 = await client.getUpdates(); Logger.serverUpdate(updates2.result);