├── .eslintignore ├── jsconfig.json ├── docs ├── media │ ├── Logo.png │ ├── Social banner.png │ ├── Social banner.psd │ ├── interactive-examples │ │ └── inlineQuery.png │ └── Logo.svg ├── examples │ ├── quick-start.js │ ├── basic │ │ ├── echo.js │ │ └── keyboard.js │ └── advanced │ │ └── i18next-internalization │ │ ├── i18n.js │ │ └── index.js └── CHANGELOG.md ├── .commitlintrc ├── .husky ├── pre-commit └── commit-msg ├── .prettierrc.json ├── .remarkrc.json ├── .travis.yml ├── .c8rc.json ├── scripts ├── templates │ ├── exception.ejs │ └── exception.test.ejs └── generate-exceptions.js ├── src ├── types │ ├── context.js │ ├── markup.js │ └── composer.js ├── scenes │ ├── index.js │ ├── wizard │ │ ├── context.js │ │ └── index.js │ ├── base.js │ ├── context.js │ └── stage.js ├── core │ ├── helpers │ │ ├── compact.js │ │ └── utils.js │ ├── network │ │ ├── multipart-stream.js │ │ ├── webhook.js │ │ └── client.js │ ├── error.js │ ├── replicators.js │ ├── exeptionsList.js │ └── exceptions.js ├── index.js ├── router.js ├── session.js └── extra.js ├── .editorconfig ├── test ├── multipart-stream.js ├── utils.js ├── scenes.js ├── extra.js ├── markup.js └── opengram.js ├── .gitignore ├── LICENSE ├── .eslintrc ├── .github └── workflows │ └── ci.yml ├── .jsdoc.json ├── package.json ├── README.md ├── CODE_OF_CONDUCT.md └── CONTRIBUTING.md /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src/**/*.js" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /docs/media/Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpengramJS/opengram/HEAD/docs/media/Logo.png -------------------------------------------------------------------------------- /.commitlintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run precommit 5 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit "${1}" 5 | -------------------------------------------------------------------------------- /docs/media/Social banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpengramJS/opengram/HEAD/docs/media/Social banner.png -------------------------------------------------------------------------------- /docs/media/Social banner.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpengramJS/opengram/HEAD/docs/media/Social banner.psd -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "insertPragma": true, 3 | "requirePragma": true, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.remarkrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "remark-preset-lint-consistent", 4 | "remark-preset-lint-recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /docs/media/interactive-examples/inlineQuery.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpengramJS/opengram/HEAD/docs/media/interactive-examples/inlineQuery.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 8 4 | - 9 5 | - 10 6 | - 11 7 | - 12 8 | script: 9 | - npm run lint 10 | - npm test 11 | -------------------------------------------------------------------------------- /.c8rc.json: -------------------------------------------------------------------------------- 1 | { 2 | "all": true, 3 | "exclude": [ 4 | "{coverage,docs,test,src/types}/**" 5 | ], 6 | "reporter": [ 7 | "html", 8 | "lcov" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /scripts/templates/exception.ejs: -------------------------------------------------------------------------------- 1 | /** 2 | @memberOf Exceptions 3 | <% if (inherits) %><%= ' @extends ' + inherits %> 4 | */ 5 | class <%= name %> <% if (inherits) %><%= 'extends ' + inherits %> {} 6 | -------------------------------------------------------------------------------- /src/types/context.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {object} ContextOptions 3 | * @property {boolean} channelMode Enable / Disable channel mode, you can find more information about this 4 | * in Opengram constructor options type 5 | */ 6 | -------------------------------------------------------------------------------- /src/scenes/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @namespace Scenes 3 | */ 4 | 5 | const BaseScene = require('./base') 6 | const WizardScene = require('./wizard') 7 | const Stage = require('./stage') 8 | 9 | /** 10 | * @type {{ 11 | * WizardScene: Scenes.WizardScene, 12 | * BaseScene: Scenes.BaseScene, 13 | * Stage: Scenes.Stage 14 | * }} 15 | */ 16 | module.exports = { 17 | BaseScene, 18 | WizardScene, 19 | Stage 20 | } 21 | -------------------------------------------------------------------------------- /scripts/templates/exception.test.ejs: -------------------------------------------------------------------------------- 1 | test('should match "<%-match%>"', t => { 2 | const dummyError = createError(exceptionsHTTPCodesReverse.<%-inherits%>, '<%-match%>') 3 | 4 | const exceptionType = matchExceptionType(dummyError) 5 | const err = new Exceptions.<%-name%>() 6 | 7 | t.is(err instanceof TelegramError, true) 8 | t.is(err instanceof Exceptions.<%-inherits%>, true) 9 | t.is(exceptionType, '<%-name%>') 10 | }) 11 | 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | ij_javascript_spaces_within_object_literal_braces = true 12 | ij_javascript_enforce_trailing_comma = remove 13 | ij_javascript_space_before_function_left_parenth = true 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | 18 | [*.txt] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /test/multipart-stream.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const MultipartStream = require('../src/core/network/multipart-stream') 3 | 4 | const Readable = require('stream').Readable 5 | const s = new Readable() 6 | 7 | test('should return true for stream', t => { 8 | t.is(MultipartStream.isStream(s), true) 9 | }) 10 | 11 | test('should return false for other', t => { 12 | t.is(MultipartStream.isStream([]), false) 13 | t.is(MultipartStream.isStream(''), false) 14 | t.is(MultipartStream.isStream({}), false) 15 | t.is(MultipartStream.isStream(1), false) 16 | }) 17 | -------------------------------------------------------------------------------- /src/core/helpers/compact.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Filtering properties in object with `undefined` values 3 | * 4 | * @param {object} options Object for filtering 5 | * @private 6 | * @return {object} 7 | */ 8 | function compactOptions (options) { 9 | if (!options) { 10 | return options 11 | } 12 | 13 | const keys = Object.keys(options) 14 | const compactKeys = keys.filter((key) => options[key] !== undefined) 15 | const compactEntries = compactKeys.map((key) => [key, options[key]]) 16 | return Object.fromEntries(compactEntries) 17 | } 18 | 19 | module.exports = { compactOptions } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | .env 35 | .vscode 36 | .idea 37 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | const { Opengram } = require('../src') 2 | 3 | /** 4 | * Creates mock bot instance 5 | * 6 | * @param {string|undefined} [token] Bot tocken 7 | * @param {OpengramOptions} [options] Options 8 | * @return {Opengram} 9 | */ 10 | function createBot (token = '5593542136:AAHtE-hMEkXsNjvN4ncDWKEBvA-q7ZzHTgI', options) { 11 | const bot = new Opengram(token, options) 12 | bot.username = 'bot' 13 | return bot 14 | } 15 | 16 | function createError (code, message) { 17 | const codes = { 18 | 400: 'Bad Request' 19 | } 20 | const err = new Error(`${codes[code]}: ${message}`) 21 | err.error_code = code 22 | err.description = message 23 | return err 24 | } 25 | 26 | module.exports = { createBot, createError } 27 | -------------------------------------------------------------------------------- /docs/examples/quick-start.js: -------------------------------------------------------------------------------- 1 | const { Opengram, isTelegramError } = require('opengram') 2 | 3 | const bot = new Opengram(process.env.BOT_TOKEN) // <-- put your bot token here (https://t.me/BotFather) 4 | 5 | // Register handlers 6 | bot.start((ctx) => ctx.reply('Welcome')) 7 | bot.help((ctx) => ctx.reply('Send me a sticker')) 8 | bot.on('sticker', (ctx) => ctx.reply('👍')) 9 | bot.hears('hi', (ctx) => ctx.reply('Hey there')) 10 | 11 | bot.catch((error, ctx) => { 12 | if (isTelegramError(error)) { 13 | console.error(error, ctx) // Print error and context 14 | return 15 | } 16 | throw error 17 | }) 18 | 19 | bot.launch() // Start bot using polling 20 | 21 | // Enable graceful stop 22 | process.once('SIGINT', () => bot.stop()) 23 | process.once('SIGTERM', () => bot.stop()) 24 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const Opengram = require('./opengram') 2 | const Composer = require('./composer') 3 | const Telegram = require('./telegram') 4 | const { OpengramContext: Context } = require('./context') 5 | const { TelegramError, isTelegramError } = require('./core/error') 6 | const { Exceptions } = require('./core/exceptions') 7 | const { Markup } = require('./markup') 8 | const Extra = require('./extra') 9 | const Router = require('./router') 10 | const { BaseScene, WizardScene, Stage } = require('./scenes') 11 | const session = require('./session') 12 | 13 | module.exports = { 14 | default: Opengram, 15 | Opengram, 16 | Composer, 17 | Context, 18 | TelegramError, 19 | isTelegramError, 20 | Extra, 21 | Exceptions, 22 | Markup, 23 | Router, 24 | Telegram, 25 | BaseScene, 26 | WizardScene, 27 | Stage, 28 | Scenes: { BaseScene, WizardScene, Stage }, 29 | session 30 | } 31 | -------------------------------------------------------------------------------- /docs/examples/basic/echo.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { Opengram, isTelegramError } = require('opengram') 4 | 5 | if (process.env.BOT_TOKEN === undefined) { 6 | throw new TypeError('BOT_TOKEN must be provided!') 7 | } 8 | 9 | // Create Opengram instance with BOT TOKEN given by http://t.me/BotFather 10 | const bot = new Opengram(process.env.BOT_TOKEN) 11 | 12 | // Add handler for text messages 13 | bot.on('text', async ctx => { 14 | await ctx.reply(ctx.message.text) 15 | }) 16 | 17 | // Register error handler, for preventing bot crashes 18 | bot.catch((error, ctx) => { 19 | if (isTelegramError(error)) { 20 | console.error(error, ctx) // Print error and context 21 | return 22 | } 23 | throw error 24 | }) 25 | 26 | // Start bot using long-polling 27 | bot.launch() 28 | .then(() => console.log('Bot started')) 29 | 30 | // Enable graceful stop 31 | process.once('SIGINT', () => bot.stop()) 32 | process.once('SIGTERM', () => bot.stop()) 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2019 Vitaly Domnikov 4 | Copyright (c) 2022-2023 Maksim Tsialehin 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/types/markup.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Keyboard Builder wrapping function used to divide the keyboard into columns. 3 | * 4 | * @name KeyboardWrap 5 | * @function 6 | * @param {string} btn Current button object 7 | * @param {number} index Current button index 8 | * @param {number} currentRow Current row 9 | * @return {boolean} 10 | * @example 11 | * Markup.keyboard(['one', 'two', 'three', 'four'], { 12 | * wrap: (btn, index, currentRow) => index % 2 !== 0 13 | * }) 14 | */ 15 | 16 | /** 17 | * @typedef {object} KeyboardOptions 18 | * @property {number} [columns=1] Count of keyboard columns 19 | * @property {KeyboardWrap} [wrap] Warp function 20 | */ 21 | 22 | /** 23 | * @typedef {object} InlineKeyboardOptions 24 | * @property {number} [columns] Count of keyboard columns, by default equals to `buttons.length` 25 | * @property {KeyboardWrap} [wrap] Warp function 26 | */ 27 | 28 | /** 29 | * @typedef {object} LoginButtonOptions 30 | * @property {string} [forward_text] New text of the button in forwarded messages. 31 | * @property {string} [bot_username] Username of a bot, which will be used for user authorization. 32 | * @property {boolean} [request_write_access] Pass True to request the permission for your bot to send messages to the user. 33 | */ 34 | -------------------------------------------------------------------------------- /src/core/network/multipart-stream.js: -------------------------------------------------------------------------------- 1 | const stream = require('stream') 2 | const { SandwichStream } = require('sandwich-stream') 3 | const CRNL = '\r\n' 4 | 5 | class MultipartStream extends SandwichStream { 6 | constructor (boundary) { 7 | super({ 8 | head: `--${boundary}${CRNL}`, 9 | tail: `${CRNL}--${boundary}--`, 10 | separator: `${CRNL}--${boundary}${CRNL}` 11 | }) 12 | } 13 | 14 | addPart (part) { 15 | part = part ?? {} 16 | const partStream = new stream.PassThrough() 17 | if (part.headers) { 18 | for (const [key, header] of Object.entries(part.headers)) { 19 | partStream.write(`${key}:${header}${CRNL}`) 20 | } 21 | } 22 | partStream.write(CRNL) 23 | if (MultipartStream.isStream(part.body)) { 24 | part.body.pipe(partStream) 25 | } else { 26 | partStream.end(part.body) 27 | } 28 | this.add(partStream) 29 | } 30 | 31 | /** 32 | * Checks is given object stream 33 | * 34 | * @param {Stream} stream Stream object 35 | * @return {boolean} 36 | */ 37 | static isStream (stream) { 38 | return stream !== null && 39 | typeof stream === 'object' && 40 | typeof stream.pipe === 'function' 41 | } 42 | } 43 | 44 | module.exports = MultipartStream 45 | -------------------------------------------------------------------------------- /src/scenes/wizard/context.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class 3 | * @memberof Scenes 4 | */ 5 | class WizardContext { 6 | /** 7 | * @param {OpengramContext} ctx Context 8 | * @param {Middleware[]} steps Steps 9 | */ 10 | constructor (ctx, steps) { 11 | this.ctx = ctx 12 | this.steps = steps 13 | this.state = ctx.scene.state 14 | this.cursor = ctx.scene.session.cursor ?? 0 15 | } 16 | 17 | /** 18 | * Getter returns current step handler 19 | * 20 | * @return {Middleware|false} 21 | */ 22 | get step () { 23 | return this.cursor >= 0 && this.steps[this.cursor] 24 | } 25 | 26 | /** 27 | * Selects step of wizard 28 | * 29 | * @param {number} index Step index, starting from `0` (zero) 30 | * @return {WizardContext} 31 | */ 32 | selectStep (index) { 33 | this.cursor = index 34 | this.ctx.scene.session.cursor = index 35 | return this 36 | } 37 | 38 | /** 39 | * Increments step of wizard 40 | * 41 | * @return {WizardContext} 42 | */ 43 | next () { 44 | return this.selectStep(this.cursor + 1) 45 | } 46 | 47 | /** 48 | * Decrements step of wizard 49 | * 50 | * @return {WizardContext} 51 | */ 52 | back () { 53 | return this.selectStep(this.cursor - 1) 54 | } 55 | } 56 | 57 | module.exports = WizardContext 58 | -------------------------------------------------------------------------------- /src/core/network/webhook.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('opengram:webhook') 2 | const { timingSafeEqual } = require('../helpers/utils') 3 | 4 | module.exports = function (config, updateHandler, errorHandler) { 5 | return async (req, res, next) => { 6 | debug('Incoming request', req.method, req.url) 7 | const secretHeader = req.headers['x-telegram-bot-api-secret-token'] 8 | 9 | if ( 10 | req.method !== 'POST' || ( 11 | (config.secret !== undefined && config.path === req.url && !timingSafeEqual(config.secret, secretHeader)) || 12 | !timingSafeEqual(config.path, req.url) 13 | ) 14 | ) { 15 | if (typeof next === 'function') { 16 | return next() 17 | } 18 | res.statusCode = 403 19 | return res.end() 20 | } 21 | 22 | /** @type {Update} */ 23 | let update 24 | 25 | if (req.body != null) { 26 | update = req.body 27 | await updateHandler(update, res) 28 | return 29 | } 30 | 31 | let body = '' 32 | for await (const chunk of req) { 33 | body += String(chunk) 34 | } 35 | 36 | try { 37 | update = JSON.parse(body) 38 | } catch (error) { 39 | res.writeHead(415) 40 | res.end() 41 | return errorHandler(error) 42 | } 43 | 44 | await updateHandler(update, res) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["standard"], 3 | "plugins": ["ava", "jsdoc"], 4 | "rules": { 5 | "jsdoc/check-line-alignment": 1, 6 | "jsdoc/check-indentation": 0, 7 | "jsdoc/check-param-names": 1, 8 | "jsdoc/check-property-names": 1, 9 | "jsdoc/check-syntax": 1, 10 | "jsdoc/check-tag-names": 0, 11 | "jsdoc/check-types": 1, 12 | "jsdoc/check-values": 1, 13 | "jsdoc/empty-tags": 1, 14 | "jsdoc/implements-on-classes": 1, 15 | "jsdoc/newline-after-description": 1, 16 | "jsdoc/no-bad-blocks": 1, 17 | "jsdoc/no-defaults": 0, 18 | "jsdoc/no-missing-syntax": 0, 19 | "jsdoc/no-types": 0, 20 | "jsdoc/no-undefined-types": 0, 21 | "jsdoc/require-description-complete-sentence": 0, 22 | "jsdoc/require-hyphen-before-param-description": 0, 23 | "jsdoc/require-param": 1, 24 | "jsdoc/require-param-description": 1, 25 | "jsdoc/require-param-name": 1, 26 | "jsdoc/require-param-type": 1, 27 | "jsdoc/require-property": 0, 28 | "jsdoc/require-property-description": 1, 29 | "jsdoc/require-property-name": 1, 30 | "jsdoc/require-property-type": 1, 31 | "jsdoc/require-returns": 0, 32 | "jsdoc/require-returns-check": 1, 33 | "jsdoc/require-returns-description": 1, 34 | "jsdoc/require-returns-type": 1, 35 | "jsdoc/require-throws": 1, 36 | "jsdoc/require-yields": 1, 37 | "jsdoc/require-yields-check": 1, 38 | "jsdoc/tag-lines": 1, 39 | "jsdoc/valid-types": 1 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | {} 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: 19 | - 16 20 | - 18 21 | steps: 22 | - uses: actions/checkout@v2 23 | with: 24 | persist-credentials: false 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v2 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | - uses: actions/cache@v2 30 | id: cache 31 | with: 32 | path: node_modules/ 33 | key: ${{ runner.os }}-node${{ matrix.node-version }}-${{ hashFiles('package*.json') }} 34 | - run: npm ci --ignore-scripts 35 | if: steps.cache.outputs.cache-hit != 'true' 36 | - run: npm test 37 | - run: npm run lint 38 | - run: npm run remark 39 | - run: npm run cover 40 | - uses: codecov/codecov-action@v3 41 | with: 42 | files: coverage/lcov.info 43 | token: ${{ secrets.CODECOV_TOKEN }} 44 | name: ${{ matrix.os }}/${{ matrix.node-version }} 45 | -------------------------------------------------------------------------------- /.jsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "include": [ 4 | "src", 5 | "README.md", 6 | "package.json" 7 | ] 8 | }, 9 | "templates": { 10 | "default": { 11 | "outputSourceFiles": true 12 | } 13 | }, 14 | "plugins": [ 15 | "plugins/markdown" 16 | ], 17 | "markdown": { 18 | "idInHeadings": true 19 | }, 20 | "opts": { 21 | "encoding": "utf8", 22 | "readme": "./README.md", 23 | "destination": "docs/", 24 | "recurse": true, 25 | "verbose": true, 26 | "template": "./node_modules/clean-jsdoc-theme", 27 | "theme_opts": { 28 | "title": "
\"Opengram\"

Opengram

", 29 | "theme": "dark", 30 | "meta": [ 31 | { 32 | "name": "description", 33 | "content": "Telegraf.js fork based on 3.38 with new features and fixes " 34 | } 35 | ], 36 | "menu": [ 37 | { 38 | "title": "Github", 39 | "id": "github", 40 | "link": "https://github.com/OpengramJS/opengram" 41 | }, 42 | { 43 | "title": "", 44 | "id": "npm", 45 | "link": "https://npmjs.org/package/opengram" 46 | } 47 | ] 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/core/error.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This class represents errors that are thrown by Opengram because the Telegram Bot API responded with an error. 3 | * Instances of this class hold the information that the Telegram backend returned. 4 | * If this error is thrown, Opengram could successfully communicate with the Telegram Bot API servers, 5 | * however, an error code was returned for the respective method call. 6 | * 7 | * You can check is error belongs to {@link TelegramError} by using {@link isTelegramError} 8 | * 9 | * 10 | * Also, **Opengram** provides exception's classes for most Bots API errors, you can use it to discriminate exceptions. 11 | * 12 | * Example: 13 | * ```js 14 | * const { Exceptions, TelegramError } = require('opengram') 15 | * // ... 16 | * // error = 400: Bad Request: message is too long 17 | * console.log(err instanceof TelegramError) // true 18 | * console.log(err instanceof Exceptions.BadRequest) // true 19 | * console.log(err instanceof Exceptions.MessageIsTooLong) // true 20 | * ``` 21 | * 22 | * You can find available exceptions classes here - {@link Exceptions} 23 | * 24 | * @extends Error 25 | */ 26 | class TelegramError extends Error { 27 | constructor (payload = {}, on) { 28 | super(`${payload.error_code}: ${payload.description}`) 29 | this.code = payload.error_code 30 | this.response = payload 31 | this.description = payload.description 32 | this.parameters = payload.parameters || {} 33 | this.on = on || {} 34 | } 35 | } 36 | 37 | /** 38 | * Checks if the error is a {@link TelegramError} 39 | * 40 | * @param {object} err Error object 41 | * @return {boolean} 42 | */ 43 | function isTelegramError (err) { 44 | return err instanceof TelegramError 45 | } 46 | 47 | module.exports = { TelegramError, isTelegramError } 48 | -------------------------------------------------------------------------------- /docs/examples/advanced/i18next-internalization/i18n.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Middleware factory function 3 | * 4 | * @param {object} i18next I18next instance 5 | * @return {Middleware} 6 | */ 7 | function i18n (i18next) { 8 | async function i18nextMiddleware (ctx, next) { 9 | // Getting the locale from the session or from the user object (client locale) 10 | const userLocale = ctx.session?.locale ?? ctx.from.language_code 11 | 12 | // Clone i18next instance 13 | const i18n = i18next.cloneInstance({ initImmediate: false, lng: userLocale }) 14 | 15 | // Registering event for support i18next.changeLanguage() 16 | i18n.on('languageChanged', lng => { 17 | ctx.session.locale = lng 18 | }) 19 | 20 | // Adds i18next to context 21 | ctx.i18n = i18n 22 | await next() 23 | } 24 | 25 | return i18nextMiddleware 26 | } 27 | 28 | /** 29 | * Trigger function factory for match strings in current user locale dynamically 30 | * 31 | * @param {string} resourceKey I18next key 32 | * @param {object} [templateData] Data for I18next template interpolation 33 | * @return {Trigger} 34 | */ 35 | function match (resourceKey, templateData) { 36 | /** 37 | * @param {string} text Text of message 38 | * @param {OpengramContext} ctx Update context 39 | * @return {Array|null} 40 | */ 41 | function trigger (text, ctx) { 42 | // Create trigger function 43 | return (text && ctx?.i18n && text === ctx.i18n.t(resourceKey, templateData)) ? [text] : null 44 | } 45 | 46 | return trigger 47 | } 48 | 49 | /** 50 | * Create middleware which replies with i18next string by key 51 | * 52 | * @param {string} resourceKey I18next key 53 | * @param {ExtraSendMessage|Extra} [extra] Other params for `ctx.reply` 54 | * @return {Function} 55 | */ 56 | function reply (resourceKey, extra) { 57 | return async function (ctx) { 58 | return ctx.reply(ctx.i18n.t(resourceKey), extra) 59 | } 60 | } 61 | 62 | module.exports = { i18n, match, reply } 63 | -------------------------------------------------------------------------------- /src/scenes/wizard/index.js: -------------------------------------------------------------------------------- 1 | const Composer = require('../../composer') 2 | const WizardContext = require('./context') 3 | const BaseScene = require('../base') 4 | const { compose, unwrap } = Composer 5 | 6 | /** 7 | * @class 8 | * @memberOf Scenes 9 | * @extends BaseScene 10 | */ 11 | class WizardScene extends BaseScene { 12 | /** 13 | * Wizard scene constructor 14 | * 15 | * @constructor 16 | * @param {string} id Wizard name, used for entering 17 | * @param {Middleware} optionsOrStep First step 18 | * @param {Middleware} steps Steps middlewares 19 | *//** 20 | * Wizard scene constructor 21 | * 22 | * @constructor 23 | * @param {string} id Wizard name, used for entering 24 | * @param {object} optionsOrStep Options 25 | * @param {Middleware} steps Steps middlewares 26 | */ 27 | constructor (id, optionsOrStep, ...steps) { 28 | let tOptions 29 | let tSteps 30 | 31 | // Make options optional 32 | if (typeof optionsOrStep === 'function' || 'middleware' in optionsOrStep) { 33 | tOptions = undefined 34 | tSteps = [optionsOrStep, ...steps] 35 | } else { 36 | tOptions = optionsOrStep 37 | tSteps = steps 38 | } 39 | super(id, tOptions) 40 | this.steps = tSteps 41 | } 42 | 43 | /** 44 | * Returns enter handler composed with wizard middleware 45 | * 46 | * @private 47 | * @return {Middleware} 48 | */ 49 | enterMiddleware () { 50 | return Composer.compose([this.enterHandler, this.middleware()]) 51 | } 52 | 53 | /** 54 | * Returns the middleware to embed 55 | * 56 | * @return {Middleware} 57 | */ 58 | middleware () { 59 | return compose([ 60 | (ctx, next) => { 61 | ctx.wizard = new WizardContext(ctx, this.steps) 62 | return next() 63 | }, 64 | super.middleware(), 65 | (ctx, next) => { 66 | if (!ctx.wizard.step) { 67 | ctx.wizard.selectStep(0) 68 | return ctx.scene.leave() 69 | } 70 | return unwrap(ctx.wizard.step)(ctx, next) 71 | } 72 | ]) 73 | } 74 | } 75 | 76 | module.exports = WizardScene 77 | -------------------------------------------------------------------------------- /src/types/composer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Function} MiddlewareFn 3 | */ 4 | 5 | /** 6 | * @typedef {MiddlewareFn|object} Middleware 7 | */ 8 | 9 | /** 10 | * @typedef { 11 | "callback_query" | "channel_post" | "chosen_inline_result" | "edited_channel_post" | "edited_message" | 12 | "inline_query" | "shipping_query" | "pre_checkout_query" | "message" | "poll" | "poll_answer" | 13 | "my_chat_member" | "chat_member" | "chat_join_request" | "message_reaction" 14 | } UpdateType 15 | */ 16 | 17 | /** 18 | * @typedef { 19 | "voice" | "video_note" | "video" | "animation" | "venue" | "text" | "supergroup_chat_created" | 20 | "successful_payment" | "sticker" | "pinned_message" | "photo" | "new_chat_title" | "new_chat_photo" | 21 | "new_chat_members" | "migrate_to_chat_id" | "migrate_from_chat_id" | "location" | "left_chat_member" | "invoice" | 22 | "group_chat_created" | "game" | "dice" | "document" | "delete_chat_photo" | "contact" | "channel_chat_created" | 23 | "audio" | "connected_website" | "passport_data" | "poll" | "forward" | "message_auto_delete_timer_changed" | 24 | "video_chat_started" | "video_chat_ended" | "video_chat_participants_invited" | "video_chat_scheduled" | 25 | "web_app_data" | "forum_topic_created" | "forum_topic_closed" | "forum_topic_reopened" | "user_shared" | 26 | "chat_shared" 27 | } UpdateSubtype 28 | */ 29 | 30 | /** 31 | * @typedef {Function} PredicateFn 32 | * @param {OpengramContext} context Update context 33 | * @return {boolean|Promise} 34 | */ 35 | 36 | /** 37 | * @callback TriggerPredicateFn 38 | * @param {string} value Received value 39 | * @param {OpengramContext} context Update context 40 | * @return {*} 41 | */ 42 | 43 | /** 44 | * @callback EntityPredicateFn 45 | * @param {MessageEntity} entityObject Entity object 46 | * @param {string} entityText Entity text 47 | * @param {OpengramContext} context Update context 48 | * @return {boolean} 49 | */ 50 | 51 | /** 52 | * @typedef {EntityPredicateFn|MessageEntityType|Array} EntityPredicate 53 | */ 54 | 55 | /** 56 | * @typedef {RegExp|string|TriggerPredicateFn} Trigger 57 | */ 58 | -------------------------------------------------------------------------------- /test/scenes.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const { session, Scenes } = require('../') 3 | const { createBot } = require('./utils') 4 | 5 | const BaseTextMessage = { 6 | chat: { id: 1 }, 7 | from: { id: 1 }, 8 | text: 'foo' 9 | } 10 | 11 | test('should execute enter middleware in scene', async t => { 12 | await t.notThrowsAsync(new Promise(resolve => { 13 | const bot = createBot() 14 | const scene = new Scenes.BaseScene('hello') 15 | scene.enter(resolve) 16 | const stage = new Scenes.Stage([scene]) 17 | stage.use(ctx => ctx.scene.enter('hello')) 18 | bot.use(session()) 19 | bot.use(stage) 20 | bot.handleUpdate({ message: BaseTextMessage }) 21 | })) 22 | }) 23 | 24 | test('should execute enter middleware in wizard scene', async t => { 25 | await t.notThrowsAsync(new Promise(resolve => { 26 | const bot = createBot() 27 | const scene = new Scenes.WizardScene('hello', []) 28 | scene.enter(resolve) 29 | const stage = new Scenes.Stage([scene]) 30 | stage.use(ctx => ctx.scene.enter('hello')) 31 | bot.use(session()) 32 | bot.use(stage) 33 | return bot.handleUpdate({ message: BaseTextMessage }) 34 | })) 35 | }) 36 | 37 | test('should execute first step in wizard scene on enter', async t => { 38 | await t.notThrowsAsync(new Promise(resolve => { 39 | const bot = createBot() 40 | const scene = new Scenes.WizardScene( 41 | 'hello', 42 | resolve 43 | ) 44 | const stage = new Scenes.Stage([scene]) 45 | stage.use(ctx => ctx.scene.enter('hello')) 46 | bot.use(session()) 47 | bot.use(stage) 48 | bot.handleUpdate({ message: BaseTextMessage }) 49 | })) 50 | }) 51 | 52 | test('should execute both enter middleware and first step in wizard scene on enter', async t => { 53 | t.plan(2) 54 | const bot = createBot() 55 | const scene = new Scenes.WizardScene( 56 | 'hello', 57 | ctx => { 58 | t.pass() 59 | } 60 | ) 61 | scene.enter((ctx, next) => { 62 | t.pass() 63 | return next() 64 | }) 65 | const stage = new Scenes.Stage([scene]) 66 | stage.use(ctx => ctx.scene.enter('hello')) 67 | bot.use(session()) 68 | bot.use(stage) 69 | await bot.handleUpdate({ message: BaseTextMessage }) 70 | }) 71 | -------------------------------------------------------------------------------- /docs/examples/advanced/i18next-internalization/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { Opengram, Extra } = require('opengram') 4 | // Middleware file you created previously 5 | const { i18n, match } = require('./i18n') 6 | const i18next = require('i18next') 7 | 8 | const bot = new Opengram(process.env.BOT_TOKEN) 9 | 10 | async function startBot () { 11 | // Init i18next 12 | await i18next.init({ 13 | lng: 'ru', // Default language 14 | fallbackLng: 'ru', // The language whose file will be used if the key is not present in the current user language 15 | debug: true, // Enable i18next debug logs 16 | // An objects with locale resources, where the key is its name and the object is its content 17 | // Supports nested keys. 18 | // These objects can be moved to separate files. 19 | resources: { 20 | en: { 21 | translation: { // Required by i18next 22 | hello: 'hello world', 23 | keyboard: { 24 | button: 'Button name' 25 | }, 26 | messageOnPress: 'You pressed the button ({{ buttonName }})' // You can also send some data (Interpolation) 27 | } 28 | }, 29 | ru: { 30 | translation: { // Required by i18next 31 | hello: 'Привет мир', 32 | keyboard: { 33 | button: 'Имя кнопки' 34 | }, 35 | messageOnPress: 'Вы нажали кнопку ({{ buttonName }})' // You can also send some data (Interpolation) 36 | } 37 | } 38 | } 39 | }) 40 | 41 | // Register i18next middleware 42 | bot.use( 43 | i18n(i18next) 44 | ) 45 | 46 | bot.start(ctx => { 47 | // Build keyboard 48 | const keyboard = Extra.markup( 49 | m => m.keyboard([ 50 | m.button( 51 | ctx.i18n.t('keyboard.button') // Create button with text 'Button name' 52 | ) 53 | ]).resize() 54 | ) 55 | 56 | // Reply with localized message and keyboard 57 | return ctx.reply( 58 | ctx.i18n.t('hello'), 59 | keyboard 60 | ) 61 | }) 62 | 63 | // Matches text by user locale (for ru "Имя кнопки" and for en "Button name") 64 | bot.hears(match('keyboard.button'), ctx => { 65 | return ctx.reply( 66 | ctx.i18n.t('messageOnPress', { buttonName: ctx.match[0] }) 67 | ) 68 | }) 69 | 70 | return bot.launch() 71 | .then(() => console.log('Bot started successfully')) 72 | } 73 | 74 | startBot() 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opengram", 3 | "version": "0.5.0", 4 | "description": "Telegram Bot Library based on Telegraf 3.x", 5 | "license": "MIT", 6 | "author": "Vitaly Domnikov ", 7 | "homepage": "https://github.com/OpengramJS/opengram#readme", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+ssh://git@github.com:OpengramJS/opengram.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/OpengramJS/opengram/issues" 14 | }, 15 | "main": "src/index.js", 16 | "files": [ 17 | "src/core/**/*.js", 18 | "src/scenes/**/*.js", 19 | "src/types/*.js", 20 | "src/*.js", 21 | "*.json" 22 | ], 23 | "scripts": { 24 | "lint": "eslint .", 25 | "test": "ava", 26 | "cover": "c8 npm test", 27 | "generate-exceptions": "node ./scripts/generate-exceptions.js", 28 | "precommit": "npm run lint && npm test && npm run remark", 29 | "remark": "npx remark README.md", 30 | "docs": "jsdoc --configure .jsdoc.json", 31 | "json-docs": "jsdoc --template ./node_modules/jsdoc-json --destination docs/docs.json --recurse src", 32 | "prepare": "husky install" 33 | }, 34 | "type": "commonjs", 35 | "engines": { 36 | "node": ">=16" 37 | }, 38 | "ava": { 39 | "files": [ 40 | "test/*", 41 | "!test/utils.js" 42 | ] 43 | }, 44 | "dependencies": { 45 | "debug": "^4.3.4", 46 | "node-fetch": "^2.7.0", 47 | "sandwich-stream": "^2.0.2" 48 | }, 49 | "devDependencies": { 50 | "@commitlint/cli": "^17.7.1", 51 | "@commitlint/config-conventional": "^17.7.0", 52 | "@jsdoc/eslint-config": "^1.1.11", 53 | "ava": "^4.3.3", 54 | "clean-jsdoc-theme": "^4.2.13", 55 | "eslint": "^8.36.0", 56 | "eslint-config-standard": "^17.1.0", 57 | "eslint-plugin-ava": "^13.2.0", 58 | "eslint-plugin-import": "^2.28.1", 59 | "eslint-plugin-jsdoc": "^39.9.1", 60 | "eslint-plugin-n": "^15.6.1", 61 | "eslint-plugin-node": "^11.1.0", 62 | "eslint-plugin-promise": "^6.1.1", 63 | "eslint-plugin-standard": "^5.0.0", 64 | "husky": "^8.0.3", 65 | "c8": "^7.14.0", 66 | "jsdoc": "^4.0.2", 67 | "jsdoc-json": "^2.0.2", 68 | "prettier": "^3.0.2", 69 | "remark-cli": "^11.0.0", 70 | "remark-preset-lint-consistent": "^5.1.2", 71 | "remark-preset-lint-recommended": "^6.1.3", 72 | "ejs": "^3.1.9" 73 | }, 74 | "keywords": [ 75 | "opengram", 76 | "telegraf", 77 | "telegram", 78 | "telegram bot api", 79 | "bot", 80 | "botapi", 81 | "bot framework" 82 | ], 83 | "volta": { 84 | "node": "16.20.2" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /test/extra.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const { Extra, Markup } = require('../') 3 | 4 | test('should generate default options from contructor', t => { 5 | const extra = { ...new Extra({ parse_mode: 'LaTeX' }) } 6 | t.deepEqual(extra, { parse_mode: 'LaTeX' }) 7 | }) 8 | 9 | test('should generate default options', t => { 10 | const extra = { ...Extra.load({ parse_mode: 'LaTeX' }) } 11 | t.deepEqual(extra, { parse_mode: 'LaTeX' }) 12 | }) 13 | 14 | test('should generate inReplyTo options', t => { 15 | const extra = { ...Extra.inReplyTo(42) } 16 | t.deepEqual(extra, { reply_to_message_id: 42 }) 17 | }) 18 | 19 | test('should generate HTML options', t => { 20 | const extra = { ...Extra.HTML() } 21 | t.deepEqual(extra, { parse_mode: 'HTML' }) 22 | }) 23 | 24 | test('should generate Markdown options', t => { 25 | const extra = { ...Extra.markdown() } 26 | t.deepEqual(extra, { parse_mode: 'Markdown' }) 27 | }) 28 | 29 | test('should generate MarkdownV2 options', t => { 30 | const extra = { ...Extra.markdownV2() } 31 | t.deepEqual(extra, { parse_mode: 'MarkdownV2' }) 32 | }) 33 | 34 | test('should generate notifications options', t => { 35 | const extra = { ...Extra.notifications(false) } 36 | t.deepEqual(extra, { disable_notification: true }) 37 | }) 38 | 39 | test('should generate web preview options', t => { 40 | const extra = { ...Extra.webPreview(false) } 41 | t.deepEqual(extra, { disable_web_page_preview: true }) 42 | }) 43 | 44 | test('should generate markup options', t => { 45 | const extra = { ...Extra.markup(Markup.removeKeyboard()) } 46 | t.deepEqual(extra, { reply_markup: { remove_keyboard: true } }) 47 | }) 48 | 49 | test('should generate markup options in functional style', t => { 50 | const extra = { ...Extra.markdown().markup((markup) => markup.removeKeyboard()) } 51 | t.deepEqual(extra, { parse_mode: 'Markdown', reply_markup: { remove_keyboard: true } }) 52 | }) 53 | 54 | test('should generate caption options', t => { 55 | const extra = { ...Extra.markdown().caption('text') } 56 | t.deepEqual(extra, { parse_mode: 'Markdown', caption: 'text' }) 57 | }) 58 | 59 | test('should generate caption options from static method', t => { 60 | const extra = { ...Extra.caption('text') } 61 | t.deepEqual(extra, { caption: 'text' }) 62 | }) 63 | 64 | test('should generate entities from static method', t => { 65 | const extra = { ...Extra.entities([{ offset: 0, length: 4, type: 'code' }]) } 66 | t.deepEqual(extra, { entities: [{ offset: 0, length: 4, type: 'code' }] }) 67 | }) 68 | 69 | test('should generate caption entities from static method', t => { 70 | const extra = { ...Extra.captionEntities([{ offset: 0, length: 4, type: 'code' }]) } 71 | t.deepEqual(extra, { caption_entities: [{ offset: 0, length: 4, type: 'code' }] }) 72 | }) 73 | -------------------------------------------------------------------------------- /src/scenes/base.js: -------------------------------------------------------------------------------- 1 | const Composer = require('../composer') 2 | const { compose } = Composer 3 | 4 | /** 5 | * BaseScene class 6 | * 7 | * **Basic example:** 8 | * 9 | * ```js 10 | * const { Scenes: { BaseScene }, Stage, session, Opengram } = require('opengram') 11 | * 12 | * // Scene 13 | * const firstScene = new BaseScene('FIRST_SCENE') 14 | * 15 | * // Enter handler called when user enters to scene 16 | * firstScene.enter(async ctx => { 17 | * await ctx.reply('Hi') 18 | * }) 19 | * 20 | * // Enter handler called when user leave scene or TTL expired 21 | * firstScene.leave(async ctx => { 22 | * await ctx.reply('Bye') 23 | * }) 24 | * 25 | * // You can use all Composer methods like action, hears, on, filter, etc. 26 | * // Scenes handlers just middlewares and can use next function 27 | * firstScene.hears('hi', async ctx => { 28 | * await ctx.reply('Hello') 29 | * }) 30 | * 31 | * firstScene.hears('/cancel', async ctx => { 32 | * await ctx.scene.leave() 33 | * }) 34 | * 35 | * // if message not contains "hi" and "/cancel" 36 | * firstScene.on('message', async ctx => { 37 | * await ctx.replyWithMarkdown('Send `hi` or /cancel') 38 | * }) 39 | * 40 | * const bot = new Opengram(process.env.BOT_TOKEN) 41 | * 42 | * // Create Stage instance, register scenes (scenes MUST be registered before bot starts) 43 | * const stage = new Stage([firstScene], { 44 | * // Defines scenes TTL, after expires leave handler called & user removed from scene 45 | * ttl: 10 46 | * }) 47 | * 48 | * // Register session middleware (Stage uses session for saving state, sessions MUST be registered before Stage) 49 | * bot.use(session()) 50 | * bot.use(stage) // Register stage middleware 51 | * bot.command('myscene', ctx => ctx.scene.enter('FIRST_SCENE')) 52 | * bot.on('message', ctx => ctx.reply('Try /myscene')) 53 | * 54 | * bot.launch() 55 | * ``` 56 | * 57 | * @class 58 | * @memberof Scenes 59 | * @extends Composer 60 | */ 61 | 62 | class BaseScene extends Composer { 63 | constructor (id, options) { 64 | const opts = { 65 | handlers: [], 66 | enterHandlers: [], 67 | leaveHandlers: [], 68 | ...options 69 | } 70 | super(...opts.handlers) 71 | /** @type {string} **/ 72 | this.id = id 73 | this.options = opts 74 | this.enterHandler = compose(opts.enterHandlers) 75 | this.leaveHandler = compose(opts.leaveHandlers) 76 | } 77 | 78 | set ttl (value) { 79 | this.options.ttl = value 80 | } 81 | 82 | get ttl () { 83 | return this.options.ttl 84 | } 85 | 86 | /** 87 | * Registers enter handler(s) for scene 88 | * 89 | * @param {Middleware} fns Middleware(s) to register 90 | * @return {BaseScene} 91 | */ 92 | enter (...fns) { 93 | this.enterHandler = compose([this.enterHandler, ...fns]) 94 | return this 95 | } 96 | 97 | /** 98 | * Registers leave handler(s) for scene 99 | * 100 | * @param {Middleware} fns Middleware(s) to register 101 | * @return {BaseScene} 102 | */ 103 | leave (...fns) { 104 | this.leaveHandler = compose([this.leaveHandler, ...fns]) 105 | return this 106 | } 107 | 108 | /** 109 | * Returns enter handler composed with `enter` middlewares 110 | * 111 | * @private 112 | * @return {Middleware} 113 | */ 114 | enterMiddleware () { 115 | return this.enterHandler 116 | } 117 | 118 | /** 119 | * Returns enter handler composed with `leave` middlewares 120 | * 121 | * @private 122 | * @return {Middleware} 123 | */ 124 | leaveMiddleware () { 125 | return this.leaveHandler 126 | } 127 | } 128 | 129 | module.exports = BaseScene 130 | -------------------------------------------------------------------------------- /scripts/generate-exceptions.js: -------------------------------------------------------------------------------- 1 | const ejs = require('ejs') 2 | const fs = require('fs') 3 | const path = require('path') 4 | const prettier = require('prettier') 5 | const { exceptionsList } = require('../src/core/exeptionsList') 6 | 7 | function escapeQuotes (str) { 8 | return str.replace(/['']/g, '\\\'') 9 | } 10 | async function renderException (name, inherits) { 11 | return await ejs.renderFile(path.join(__dirname, 'templates', 'exception.ejs'), { 12 | inherits: escapeQuotes(inherits), 13 | name: escapeQuotes(name) 14 | }) 15 | } 16 | 17 | async function renderExceptionTest (name, inherits, match) { 18 | return await ejs.renderFile(path.join(__dirname, 'templates', 'exception.test.ejs'), { 19 | inherits: escapeQuotes(inherits), 20 | name: escapeQuotes(name), 21 | match: escapeQuotes(match) 22 | }) 23 | } 24 | 25 | let result = 'const { TelegramError } = require(\'./error\')\n' 26 | result += '/**\n' + 27 | ' @namespace Exceptions\n' + 28 | ' @description This namespace contains exception classes\n' + 29 | ' */\n' 30 | let resultTest = 'const test = require(\'ava\')\n' 31 | resultTest += 'const { createError } = require(\'./utils\')\n' 32 | resultTest += 'const { Exceptions } = require(\'../src/core/exceptions\')\n' 33 | resultTest += 'const { TelegramError } = require(\'../src/core/error\')\n' 34 | resultTest += 'const { matchExceptionType, exceptionsHTTPCodesReverse } = require(\'../src/core/exeptionsList\')\n\n' 35 | 36 | function checkIsLowercase (str) { 37 | return str === str.toLowerCase() 38 | } 39 | 40 | async function main () { 41 | const exceptionsExports = new Set() 42 | 43 | for (const baseError in exceptionsList) { 44 | for (const exceptionType in exceptionsList[baseError]) { 45 | result += await renderException(exceptionType, baseError) 46 | exceptionsExports.add(exceptionType) 47 | for (const exception in exceptionsList[baseError][exceptionType]) { 48 | const { match } = exceptionsList[baseError][exceptionType][exception] 49 | if (!checkIsLowercase(match)) { 50 | throw new Error(`${baseError}::${exceptionType}::${exception} contains uppercase symbols`) 51 | } 52 | 53 | result += await renderException(exception, exceptionType) 54 | resultTest += await renderExceptionTest(exception, exceptionType, match) 55 | exceptionsExports.add(exception) 56 | } 57 | } 58 | } 59 | result += '\n\n' 60 | result += `module.exports = { 61 | Exceptions: { 62 | ${[...exceptionsExports].join(',')} 63 | } 64 | }` 65 | result += '\n' 66 | resultTest += '\n' 67 | 68 | fs.writeFileSync(path.join(__dirname, '..', 'test', 'exceptions.js'), prettier.format(resultTest, { 69 | arrowParens: 'always', 70 | bracketSameLine: true, 71 | bracketSpacing: true, 72 | embeddedLanguageFormatting: 'auto', 73 | endOfLine: 'lf', 74 | htmlWhitespaceSensitivity: 'css', 75 | insertPragma: false, 76 | jsxSingleQuote: false, 77 | printWidth: 80, 78 | proseWrap: 'preserve', 79 | quoteProps: 'as-needed', 80 | requirePragma: false, 81 | semi: false, 82 | singleQuote: true, 83 | tabWidth: 2, 84 | trailingComma: 'none', 85 | useTabs: false, 86 | vueIndentScriptAndStyle: false 87 | })) 88 | 89 | fs.writeFileSync(path.join(__dirname, '..', 'src', 'core', 'exceptions.js'), prettier.format(result, { 90 | arrowParens: 'always', 91 | bracketSameLine: true, 92 | bracketSpacing: true, 93 | embeddedLanguageFormatting: 'auto', 94 | endOfLine: 'lf', 95 | htmlWhitespaceSensitivity: 'css', 96 | insertPragma: false, 97 | jsxSingleQuote: false, 98 | printWidth: 80, 99 | proseWrap: 'preserve', 100 | quoteProps: 'as-needed', 101 | requirePragma: false, 102 | semi: false, 103 | singleQuote: true, 104 | tabWidth: 2, 105 | trailingComma: 'none', 106 | useTabs: false, 107 | vueIndentScriptAndStyle: false 108 | })) 109 | } 110 | 111 | main() 112 | -------------------------------------------------------------------------------- /src/router.js: -------------------------------------------------------------------------------- 1 | const { compose, lazy, passThru } = require('./composer') 2 | 3 | /** 4 | * {@link Router} is used to direct the flow of update. It accepts as arguments a routing function and, optionally, 5 | * a [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) 6 | * with predefined routes and handlers. Routing function accepts {@link OpengramContext context} 7 | * and return object with route property set to String value. Handler for a specific route is just another middleware. 8 | * 9 | * As `Router` object is itself a middleware, routers can be nested, e.g., `router1.on('yo', router2)`. 10 | * Thus, they allow for very deep, well-structured and flexible logic of updates processing. 11 | * Possible use-cases include multilevel menus, setting different levels of access for bot users and much, much more 12 | * 13 | * ```js 14 | * const { Router } = require('opengram') 15 | * 16 | * // Can be any function that returns { route: String } 17 | * function routeFn(ctx) { 18 | * return { route: ctx.updateType } 19 | * } 20 | * 21 | * const router = new Router(routeFn) 22 | * 23 | * // Registering 'callback_query' route 24 | * const middlewareCb = function (ctx, next) { ... } 25 | * router.on('callback_query', middlewareCb) 26 | * 27 | * // Registering 'message' route 28 | * const middlewareMessage = new Composer(...) 29 | * router.on('message', middlewareMessage) 30 | * 31 | * // Setting handler for routes that are not registered 32 | * const middlewareDefault = someOtherRouter 33 | * router.otherwise(middlewareDefault). 34 | * ``` 35 | */ 36 | class Router { 37 | /** 38 | * Constructs a router with a routing function and optionally some 39 | * preinstalled middlewares. 40 | * 41 | * Note that you can always install more middlewares on the router by calling {@link Router#on}. 42 | * 43 | * @param {Function} routeFn A routing function that decides which middleware to run 44 | * @param {Map} [routeHandlers] Map object with middlewares 45 | */ 46 | constructor (routeFn, routeHandlers = new Map()) { 47 | if (typeof routeFn !== 'function') { 48 | throw new Error('Missing routing function') 49 | } 50 | this.routeFn = routeFn 51 | this.handlers = routeHandlers 52 | this.otherwiseHandler = passThru() 53 | } 54 | 55 | /** 56 | * Registers new middleware for a given route. The initially supplied routing 57 | * function may return this route as a string to select the respective 58 | * middleware for execution for an incoming update. 59 | * 60 | * @param {string} route The route for which to register the middleware 61 | * @param {Middleware} fns Middleware(s) to register 62 | * @throws {TypeError} 63 | * @return {Router} 64 | */ 65 | on (route, ...fns) { 66 | if (fns.length === 0) { 67 | throw new TypeError('At least one handler must be provided') 68 | } 69 | this.handlers.set(route, compose(fns)) 70 | return this 71 | } 72 | 73 | /** 74 | * Allows to register middleware that is executed when no route matches, or 75 | * when the routing function returns `undefined`. If this method is not 76 | * called, then the router will simply pass through all requests to the 77 | * downstream middleware. 78 | * 79 | * @param {Middleware} fns Middleware(s) to run if no route matches 80 | * @throws {TypeError} 81 | */ 82 | otherwise (...fns) { 83 | if (fns.length === 0) { 84 | throw new TypeError('At least one otherwise handler must be provided') 85 | } 86 | this.otherwiseHandler = compose(fns) 87 | return this 88 | } 89 | 90 | middleware () { 91 | return lazy((ctx) => { 92 | const result = this.routeFn(ctx) 93 | if (result == null) { 94 | return this.otherwiseHandler 95 | } 96 | Object.assign(ctx, result.context) 97 | Object.assign(ctx.state, result.state) 98 | return this.handlers.get(result.route) || this.otherwiseHandler 99 | }) 100 | } 101 | } 102 | 103 | module.exports = Router 104 | -------------------------------------------------------------------------------- /src/scenes/context.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('opengram:scenes:context') 2 | const Composer = require('../composer') 3 | 4 | const noop = () => Promise.resolve() 5 | const now = () => Math.floor(Date.now() / 1000) 6 | 7 | /** 8 | * @class 9 | * @memberof Scenes 10 | */ 11 | class SceneContext { 12 | constructor (ctx, scenes, options) { 13 | /** @type {OpengramContext} */ 14 | this.ctx = ctx 15 | /** @type {Map} */ 16 | this.scenes = scenes 17 | /** @type {boolean} */ 18 | this.leaving = false 19 | /** @type {StageOptions} */ 20 | this.options = options 21 | } 22 | 23 | /** 24 | * Getter returns current scene session object 25 | * 26 | * @return {object} 27 | */ 28 | get session () { 29 | const sessionName = this.options.sessionName 30 | let session = this.ctx[sessionName].__scenes || {} 31 | if (session.expires < now()) { 32 | session = {} 33 | } 34 | this.ctx[sessionName].__scenes = session 35 | return session 36 | } 37 | 38 | /** 39 | * Getter returns state of current scene 40 | * 41 | * @return {object} 42 | */ 43 | get state () { 44 | this.session.state = this.session.state || {} 45 | return this.session.state 46 | } 47 | 48 | /** 49 | * Setter sets state of current scene 50 | * 51 | * @param {object} value New state value 52 | */ 53 | set state (value) { 54 | this.session.state = { ...value } 55 | } 56 | 57 | /** 58 | * @return {undefined|Scenes.WizardScene|Scenes.BaseScene} 59 | */ 60 | get current () { 61 | const sceneId = this.session.current || this.options.default 62 | return sceneId === undefined || !this.scenes.has(sceneId) 63 | ? undefined 64 | : this.scenes.get(sceneId) 65 | } 66 | 67 | /** 68 | * Resets scenes data 69 | * 70 | * @return {void} 71 | */ 72 | reset () { 73 | const sessionName = this.options.sessionName 74 | delete this.ctx[sessionName].__scenes 75 | } 76 | 77 | /** 78 | * Enter to scene by name 79 | * 80 | * Use `initialState` to pass some initial data of `ctx.scene.state` 81 | * 82 | * @param {string} sceneId Scene name 83 | * @param {object} [initialState] Scene initial state 84 | * @param {boolean} [silent] If true, enters to given scene without calling `enter`handler, and without calling `leave` handler for current scene (if user currently in scene) 85 | * @throws {Error} 86 | * @return {Promise} 87 | */ 88 | async enter (sceneId, initialState, silent) { 89 | if (!sceneId || !this.scenes.has(sceneId)) { 90 | throw new Error(`Can't find scene: ${sceneId}`) 91 | } 92 | 93 | if (!silent) { 94 | await this.leave() 95 | } 96 | 97 | debug('Entering scene', sceneId, initialState, silent) 98 | this.session.current = sceneId 99 | this.state = initialState 100 | const ttl = this.current.ttl || this.options.ttl 101 | 102 | if (ttl) { 103 | this.session.expires = now() + ttl 104 | } 105 | 106 | if (!this.current || silent) { 107 | return 108 | } 109 | 110 | const handler = 111 | 'enterMiddleware' in this.current && 112 | typeof this.current.enterMiddleware === 'function' 113 | ? this.current.enterMiddleware() 114 | : this.current.middleware() 115 | return await handler(this.ctx, noop) 116 | } 117 | 118 | /** 119 | * Used for re-entering to current scene without destroying `ctx.scene.state` 120 | * 121 | * @throws {Error} 122 | * @return {Promise} 123 | */ 124 | reenter () { 125 | return this.enter(this.session.current, this.state) 126 | } 127 | 128 | /** 129 | * Used to exit the current scene 130 | * 131 | * @return {Promise} 132 | */ 133 | async leave () { 134 | if (this.leaving) return 135 | debug('Leaving scene') 136 | try { 137 | this.leaving = true 138 | 139 | if (this.current === undefined) { 140 | return 141 | } 142 | 143 | const handler = 144 | 'leaveMiddleware' in this.current && 145 | typeof this.current.leaveMiddleware === 'function' 146 | ? this.current.leaveMiddleware() 147 | : Composer.passThru() 148 | 149 | await handler(this.ctx, noop) 150 | 151 | return this.reset() 152 | } finally { 153 | this.leaving = false 154 | } 155 | } 156 | } 157 | 158 | module.exports = SceneContext 159 | -------------------------------------------------------------------------------- /src/scenes/stage.js: -------------------------------------------------------------------------------- 1 | const SceneContext = require('../scenes/context') 2 | const Composer = require('../composer') 3 | const { compose, optional, lazy, safePassThru } = Composer 4 | 5 | /** 6 | * The default scene is the scene that is entered when the user is not in another scene. 7 | * 8 | * When you use custom name of session property or multiple sessions, you should configure `sessionName` 9 | * 10 | * After TTL expired, all scene data stored in local session of scene be permanently removed 11 | * 12 | * @typedef {object} StageOptions 13 | * @property {string} [sessionName='session'] Name of session property used for scenes, by default - `session` 14 | * @property {string} [default] Name of scene by default 15 | * @property {number} [ttl] Time of life for scenes in seconds 16 | * @memberOf Scenes 17 | */ 18 | 19 | /** 20 | * The Stage class extends Composer, so you can use all of its methods such as `hears`, `use`, `command` Catch and 21 | * more. 22 | * 23 | * These handlers have a higher priority than scene handlers, but they are always executed, 24 | * not only then the user is in the scene. 25 | * 26 | * Be attentive, scenes are only available in handlers and middleware registered after 27 | * Stage 28 | * 29 | * For example: 30 | * 31 | * ```js 32 | * const { Stage, Opengram } = require('opengram') 33 | * const bot = new Opengram(process.env.BOT_TOKEN) 34 | * const stage = new Stage([...]) // Create Stage instance and register scenes 35 | * 36 | * bot.use(stage) 37 | * bot.start(async(ctx) => { 38 | * await ctx.reply('Hello!') 39 | * await ctx.scene.enter('name') 40 | * }) 41 | * 42 | * // This handler has more priority than scenes handlers and `bot.start` (because Stage registered before bot.start) 43 | * stage.on('message', async(ctx, next) => { 44 | * console.log(ctx.message.text) 45 | * await next() 46 | * }) 47 | * 48 | * bot.launch() 49 | * ``` 50 | * 51 | * @memberof Scenes 52 | * @extends Composer 53 | */ 54 | class Stage extends Composer { 55 | /** 56 | * @constructor 57 | * @param {Array} [scenes] Array of scenes objects 58 | * @param {StageOptions} [options] Options 59 | * @throws {TypeError} 60 | */ 61 | constructor (scenes = [], options) { 62 | super() 63 | this.options = { 64 | sessionName: 'session', 65 | ...options 66 | } 67 | this.scenes = new Map() 68 | scenes.forEach((scene) => this.register(scene)) 69 | } 70 | 71 | /** 72 | * Register new scene object in scenes repository 73 | * 74 | * @param {Scenes.BaseScene|Scenes.WizardScene} [scenes] Scenes objects 75 | * @throws {TypeError} 76 | * @return {Stage} 77 | */ 78 | register (...scenes) { 79 | scenes.forEach((scene) => { 80 | if (!scene || !scene.id || typeof scene.middleware !== 'function') { 81 | throw new TypeError('opengram: Unsupported scene') 82 | } 83 | this.scenes.set(scene.id, scene) 84 | }) 85 | return this 86 | } 87 | 88 | /** 89 | * Generates and returns stage middleware for embedding 90 | * 91 | * @return {Middleware} 92 | */ 93 | middleware () { 94 | const handler = compose([ 95 | (ctx, next) => { 96 | ctx.scene = new SceneContext(ctx, this.scenes, this.options) 97 | return next() 98 | }, 99 | super.middleware(), 100 | lazy((ctx) => ctx.scene.current || safePassThru()) 101 | ]) 102 | return optional((ctx) => ctx[this.options.sessionName], handler) 103 | } 104 | 105 | /** 106 | * Generates middleware which call `ctx.scene.enter` with given arguments 107 | * 108 | * @param {string} sceneId Scene name 109 | * @param {object} [initialState] Scene initial state 110 | * @param {boolean} [silent] ??? 111 | * @throws {Error} 112 | * @return {Promise} 113 | * @return {Middleware} 114 | */ 115 | static enter (sceneId, initialState, silent) { 116 | return (ctx) => ctx.scene.enter(sceneId, initialState, silent) 117 | } 118 | 119 | /** 120 | * Generates middleware which call `ctx.scene.reenter` with given arguments 121 | * 122 | * @return {Middleware} 123 | */ 124 | static reenter () { 125 | return (ctx) => ctx.scene.reenter() 126 | } 127 | 128 | /** 129 | * Generates middleware which call `ctx.scene.leave` with given arguments 130 | * 131 | * @return {Middleware} 132 | */ 133 | static leave () { 134 | return (ctx) => ctx.scene.leave() 135 | } 136 | } 137 | 138 | module.exports = Stage 139 | -------------------------------------------------------------------------------- /src/core/helpers/utils.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto') 2 | 3 | /** 4 | * Return text of media caption / message / channel post or `undefined` if not provided 5 | * 6 | * Usage example: 7 | * ```js 8 | * // Returns entities of channel post 9 | * getEntities(ctx.channelPost) 10 | * 11 | * // Returns entities of message or media caption 12 | * getEntities(ctx.message) 13 | * ``` 14 | * 15 | * @private 16 | * @param {Message} message Message object for extracting entities 17 | * @private 18 | * @return {string|Array} 19 | */ 20 | function getEntities (message) { 21 | if (message == null) return [] 22 | if ('caption_entities' in message) return message.caption_entities ?? [] 23 | if ('entities' in message) return message.entities ?? [] 24 | return [] 25 | } 26 | 27 | /** 28 | * Return text of media caption / message / channel post / callback query / short name of the game or `undefined` 29 | * if not provided 30 | * 31 | * Usage example: 32 | * ```js 33 | * // Returns text of channel post 34 | * getText(ctx.channelPost) 35 | * 36 | * // Returns text of message or media caption 37 | * getText(ctx.message) 38 | * 39 | * // Returns data of callback query 40 | * getText(ctx.callbackQuery) 41 | * ``` 42 | * 43 | * @private 44 | * @param {Message} message Message object for extracting text 45 | * @private 46 | * @return {string|undefined} 47 | */ 48 | function getText ( 49 | message 50 | ) { 51 | if (message == null) return undefined 52 | if ('caption' in message) return message.caption 53 | if ('text' in message) return message.text 54 | if ('data' in message) return message.data 55 | if ('game_short_name' in message) return message.game_short_name 56 | return undefined 57 | } 58 | 59 | /** 60 | * Returns {@link Message} object for current update. 61 | * Works for 62 | * - `context.message` 63 | * - `context.editedMessage` 64 | * - `context.callbackQuery.message` 65 | * - `context.channelPost` 66 | * - `context.editedChannelPost` 67 | * 68 | * @param {OpengramContext} ctx Update context for extracting {@link Message} object 69 | * @private 70 | * @return {Message|undefined} 71 | */ 72 | function getMessageFromAnySource (ctx) { 73 | return ( 74 | ctx.message || 75 | ctx.editedMessage || 76 | (ctx.callbackQuery && ctx.callbackQuery.message) || 77 | ctx.channelPost || 78 | ctx.editedChannelPost 79 | ) 80 | } 81 | 82 | /** 83 | * Returns `message_thread_id` from {@link Message} object for current update. 84 | * 85 | * @param {OpengramContext} ctx Update context for extracting `message_thread_id` from {@link Message} object 86 | * @private 87 | * @return {number|undefined} 88 | */ 89 | function getThreadId (ctx) { 90 | const msg = getMessageFromAnySource(ctx) 91 | return msg?.is_topic_message ? msg.message_thread_id : undefined 92 | } 93 | 94 | /** 95 | * Returns `message_id` from {@link Message} object for current update. 96 | * 97 | * @param {OpengramContext} ctx Update context for extracting `message_id` from {@link Message} object 98 | * @private 99 | * @return {number|undefined} 100 | */ 101 | function getMsgIdFromAnySource (ctx) { 102 | const msg = getMessageFromAnySource(ctx) 103 | return (msg ?? ctx.messageReaction ?? ctx.messageReactionCount)?.message_id 104 | } 105 | 106 | /** 107 | * Prints warning messages 108 | * 109 | * @param {string} text Text of warning 110 | * @private 111 | * @return {void} 112 | */ 113 | function showWarning (text) { 114 | process.emitWarning(text) 115 | } 116 | 117 | /** 118 | * Call native "crypto.timingSafeEqual" methods. 119 | * All passed values will be converted into strings first. 120 | * 121 | * Runtime is always corresponding to the length of the first parameter (string 122 | * a). 123 | * 124 | * @author Michael Raith 125 | * @param {string} a First string 126 | * @param {string} b Second string 127 | * @private 128 | * @return {boolean} 129 | */ 130 | function timingSafeEqual (a, b) { 131 | const strA = String(a) 132 | const strB = String(b) 133 | const aLen = Buffer.byteLength(strA) 134 | const bLen = Buffer.byteLength(strB) 135 | 136 | // Always use length of a to avoid leaking the length. Even if this is a 137 | // false positive because one is a prefix of the other, the explicit length 138 | // check at the end will catch that. 139 | const bufA = Buffer.allocUnsafe(aLen) 140 | bufA.write(strA) 141 | const bufB = Buffer.allocUnsafe(aLen) 142 | bufB.write(strB) 143 | 144 | return crypto.timingSafeEqual(bufA, bufB) && aLen === bLen 145 | } 146 | 147 | module.exports = { 148 | getEntities, 149 | getText, 150 | getMessageFromAnySource, 151 | getThreadId, 152 | getMsgIdFromAnySource, 153 | showWarning, 154 | timingSafeEqual 155 | } 156 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | logo 3 |

Opengram

4 | 5 | Telegram Bot API framework for Node.js based on **Telegraf 3.38** 6 | 7 | [![Bot API Version][bots-api-image]][bots-api-url] [![CI][ci-image]][ci-url] [![codecov][codecov-image]][codecov-url] [![npm][npm-image]][npm-url] [![downloads][downloads-image]][downloads-url] [![javascript style guide][standard-image]][standard-url] [![Codacy Badge][codacy-image]][codacy-url] [![License: MIT][license-image]][license-url] [![FOSSA Status][fossa-image]][fossa-url] 8 |
9 | 10 | # Introduction 11 | 12 | Bots are special Telegram accounts designed to handle messages automatically. Users can interact with bots by sending them command messages in private or group chats. These accounts serve as an interface for code running somewhere on your server. 13 | 14 | **Opengram** is a library that makes it simple for you to develop your own Telegram bots using JavaScript. 15 | 16 | ## Features 17 | - Full Telegram Bot API 6.9 support 18 | - JSDoc annotated 19 | - Lightweight 20 | - [AWS λ](https://aws.amazon.com/en/lambda/) / [Firebase](https://firebase.google.com/) / [Glitch](https://glitch.com/) / [Fly.io](https://fly.io/) / [Deta space](https://deta.space/) / Whatever ready 21 | - [http](https://nodejs.org/api/http.html) / [https](https://nodejs.org/api/https.html) / [fastify](https://www.fastify.io/) / [Connect.js](https://github.com/senchalabs/connect) / [express.js](https://expressjs.com/) compatible webhooks 22 | - Extensible 23 | 24 | ## Quickstart 25 | 26 | > If you are new to Telegram bots, read the official [Introduction for Developers](https://core.telegram.org/bots) written by the Telegram team. 27 | 28 | - Visit @BotFather and create a new bot. You will obtain a bot token. 29 | - Install opengram: 30 | - pnpm: `pnpm install opengram` 31 | - yarn: `yarn add opengram` 32 | - npm: `npm i opengram` 33 | - Create `bot.js` file and paste code 34 | ```js 35 | const { Opengram, isTelegramError } = require('opengram') 36 | 37 | if (process.env.BOT_TOKEN === undefined) { 38 | throw new TypeError('BOT_TOKEN must be provided!') 39 | } 40 | 41 | // Create Opengram instance with BOT TOKEN given by http://t.me/BotFather 42 | const bot = new Opengram(process.env.BOT_TOKEN) 43 | 44 | // Add handler for text messages 45 | bot.on('text', async ctx => { 46 | await ctx.reply(ctx.message.text) 47 | }) 48 | 49 | // Register error handler, for preventing bot crashes 50 | bot.catch((error, ctx) => { 51 | if (isTelegramError(error)) { 52 | console.error(error, ctx) // Print error and context 53 | return 54 | } 55 | throw error 56 | }) 57 | 58 | // Start bot using long-polling 59 | bot.launch() 60 | .then(() => console.log(`Bot started`)) 61 | 62 | // Enable graceful stop 63 | process.once('SIGINT', () => bot.stop()) 64 | process.once('SIGTERM', () => bot.stop()) 65 | ``` 66 | - Run `node bot.js` 67 | - Congrats! You just wrote a Telegram bot 🥳 68 | 69 | For more examples, check [docs/examples](https://github.com/OpengramJS/opengram/tree/master/docs/examples) in repository 70 | 71 | ## Resources 72 | - [API Reference](https://reference.opengram.dev) 73 | - [GitHub Discussions](https://github.com/opengramjs/opengram/discussions) 74 | - Chats: 75 | - [Russian-speaking chat](https://t.me/opengramjs) 76 | - [English chat](https://t.me/opengram_en) 77 | - [News channel](https://t.me/opengram_news) 78 | 79 | ## License 80 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FOpengramJS%2Fopengram.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2FOpengramJS%2Fopengram?ref=badge_large) 81 | 82 | [codecov-image]: https://codecov.io/gh/OpengramJS/opengram/branch/master/graph/badge.svg?token=8HJ46DCTSC 83 | [codecov-url]: https://codecov.io/gh/OpengramJS/opengram 84 | [license-image]: https://img.shields.io/badge/License-MIT-yellow.svg 85 | [license-url]: https://opensource.org/licenses/MIT 86 | [codacy-image]: https://app.codacy.com/project/badge/Grade/0ba3bf1b270946918b13e2730d190156 87 | [codacy-url]: https://www.codacy.com/gh/OpengramJS/opengram/dashboard?utm_source=github.com&utm_medium=referral&utm_content=OpengramJS/opengram&utm_campaign=Badge_Grade 88 | [bots-api-image]: https://img.shields.io/badge/Bots%20API-v6.9-ff69b4 89 | [bots-api-url]: https://core.telegram.org/bots/api 90 | [ci-image]: https://github.com/OpengramJS/opengram/actions/workflows/ci.yml/badge.svg?branch=master 91 | [ci-url]: https://github.com/OpengramJS/opengram/actions/workflows/actions/workflows/ci.yml 92 | [npm-image]: https://img.shields.io/npm/v/opengram.svg 93 | [npm-url]: https://npmjs.org/package/opengram 94 | [downloads-image]: https://img.shields.io/npm/dm/opengram.svg 95 | [downloads-url]: https://npmjs.org/package/opengram 96 | [standard-image]: https://img.shields.io/badge/code_style-standard-brightgreen.svg 97 | [standard-url]: https://standardjs.com 98 | [fossa-image]: https://app.fossa.com/api/projects/git%2Bgithub.com%2FOpengramJS%2Fopengram.svg?type=shield 99 | [fossa-url]: https://app.fossa.com/projects/git%2Bgithub.com%2FOpengramJS%2Fopengram?ref=badge_shield 100 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct - Opengram 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to a positive environment for our 15 | community include: 16 | 17 | * Demonstrating empathy and kindness toward other people 18 | * Being respectful of differing opinions, viewpoints, and experiences 19 | * Giving and gracefully accepting constructive feedback 20 | * Accepting responsibility and apologizing to those affected by our mistakes, 21 | and learning from the experience 22 | * Focusing on what is best not just for us as individuals, but for the 23 | overall community 24 | 25 | Examples of unacceptable behavior include: 26 | 27 | * The use of sexualized language or imagery, and sexual attention or 28 | advances 29 | * Trolling, insulting or derogatory comments, and personal or political attacks 30 | * Public or private harassment 31 | * Publishing others' private information, such as a physical or email 32 | address, without their explicit permission 33 | * Other conduct which could reasonably be considered inappropriate in a 34 | professional setting 35 | 36 | ## Our Responsibilities 37 | 38 | Project maintainers are responsible for clarifying and enforcing our standards of 39 | acceptable behavior and will take appropriate and fair corrective action in 40 | response to any behavior that they deem inappropriate, 41 | threatening, offensive, or harmful. 42 | 43 | Project maintainers have the right and responsibility to remove, edit, or reject 44 | comments, commits, code, wiki edits, issues, and other contributions that are 45 | not aligned to this Code of Conduct, and will 46 | communicate reasons for moderation decisions when appropriate. 47 | 48 | ## Scope 49 | 50 | This Code of Conduct applies within all community spaces, and also applies when 51 | an individual is officially representing the community in public spaces. 52 | Examples of representing our community include using an official e-mail address, 53 | posting via an official social media account, or acting as an appointed 54 | representative at an online or offline event. 55 | 56 | ## Enforcement 57 | 58 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 59 | reported to the community leaders responsible for enforcement at . 60 | All complaints will be reviewed and investigated promptly and fairly. 61 | 62 | All community leaders are obligated to respect the privacy and security of the 63 | reporter of any incident. 64 | 65 | ## Enforcement Guidelines 66 | 67 | Community leaders will follow these Community Impact Guidelines in determining 68 | the consequences for any action they deem in violation of this Code of Conduct: 69 | 70 | ### 1. Correction 71 | 72 | **Community Impact**: Use of inappropriate language or other behavior deemed 73 | unprofessional or unwelcome in the community. 74 | 75 | **Consequence**: A private, written warning from community leaders, providing 76 | clarity around the nature of the violation and an explanation of why the 77 | behavior was inappropriate. A public apology may be requested. 78 | 79 | ### 2. Warning 80 | 81 | **Community Impact**: A violation through a single incident or series 82 | of actions. 83 | 84 | **Consequence**: A warning with consequences for continued behavior. No 85 | interaction with the people involved, including unsolicited interaction with 86 | those enforcing the Code of Conduct, for a specified period of time. This 87 | includes avoiding interactions in community spaces as well as external channels 88 | like social media. Violating these terms may lead to a temporary or 89 | permanent ban. 90 | 91 | ### 3. Temporary Ban 92 | 93 | **Community Impact**: A serious violation of community standards, including 94 | sustained inappropriate behavior. 95 | 96 | **Consequence**: A temporary ban from any sort of interaction or public 97 | communication with the community for a specified period of time. No public or 98 | private interaction with the people involved, including unsolicited interaction 99 | with those enforcing the Code of Conduct, is allowed during this period. 100 | Violating these terms may lead to a permanent ban. 101 | 102 | ### 4. Permanent Ban 103 | 104 | **Community Impact**: Demonstrating a pattern of violation of community 105 | standards, including sustained inappropriate behavior, harassment of an 106 | individual, or aggression toward or disparagement of classes of individuals. 107 | 108 | **Consequence**: A permanent ban from any sort of public interaction within 109 | the community. 110 | 111 | ## Attribution 112 | 113 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 114 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html, version 2.0, 115 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct/code_of_conduct.md 116 | 117 | [homepage]: https://www.contributor-covenant.org 118 | 119 | For answers to common questions about this code of conduct, see 120 | https://www.contributor-covenant.org/faq 121 | -------------------------------------------------------------------------------- /src/core/replicators.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | copyMethods: { 3 | audio: 'sendAudio', 4 | contact: 'sendContact', 5 | document: 'sendDocument', 6 | location: 'sendLocation', 7 | photo: 'sendPhoto', 8 | sticker: 'sendSticker', 9 | text: 'sendMessage', 10 | venue: 'sendVenue', 11 | video: 'sendVideo', 12 | video_note: 'sendVideoNote', 13 | animation: 'sendAnimation', 14 | voice: 'sendVoice', 15 | poll: 'sendPoll' 16 | }, 17 | text: (message) => { 18 | return { 19 | reply_markup: message.reply_markup, 20 | text: message.text, 21 | entities: message.entities, 22 | protect_content: message.has_protected_content 23 | } 24 | }, 25 | contact: (message) => { 26 | return { 27 | reply_markup: message.reply_markup, 28 | phone_number: message.contact.phone_number, 29 | first_name: message.contact.first_name, 30 | last_name: message.contact.last_name, 31 | protect_content: message.has_protected_content 32 | } 33 | }, 34 | location: (message) => { 35 | return { 36 | reply_markup: message.reply_markup, 37 | latitude: message.location.latitude, 38 | longitude: message.location.longitude, 39 | horizontal_accuracy: message.location.horizontal_accuracy, 40 | live_period: message.location.live_period, 41 | heading: message.location.heading, 42 | proximity_alert_radius: message.location.proximity_alert_radius, 43 | protect_content: message.has_protected_content 44 | } 45 | }, 46 | venue: (message) => { 47 | return { 48 | reply_markup: message.reply_markup, 49 | latitude: message.venue.location.latitude, 50 | longitude: message.venue.location.longitude, 51 | title: message.venue.title, 52 | address: message.venue.address, 53 | foursquare_id: message.venue.foursquare_id, 54 | foursquare_type: message.venue.foursquare_type, 55 | protect_content: message.has_protected_content, 56 | google_place_id: message.venue.google_place_id, 57 | google_place_type: message.venue.google_place_type 58 | } 59 | }, 60 | voice: (message) => { 61 | return { 62 | reply_markup: message.reply_markup, 63 | voice: message.voice.file_id, 64 | duration: message.voice.duration, 65 | caption: message.caption, 66 | caption_entities: message.caption_entities, 67 | protect_content: message.has_protected_content 68 | } 69 | }, 70 | audio: (message) => { 71 | return { 72 | reply_markup: message.reply_markup, 73 | audio: message.audio.file_id, 74 | thumbnail: message.audio.thumbnail, 75 | duration: message.audio.duration, 76 | performer: message.audio.performer, 77 | title: message.audio.title, 78 | caption: message.caption, 79 | caption_entities: message.caption_entities, 80 | protect_content: message.has_protected_content 81 | } 82 | }, 83 | video: (message) => { 84 | return { 85 | reply_markup: message.reply_markup, 86 | video: message.video.file_id, 87 | thumbnail: message.video.thumbnail, 88 | caption: message.caption, 89 | caption_entities: message.caption_entities, 90 | duration: message.video.duration, 91 | width: message.video.width, 92 | height: message.video.height, 93 | supports_streaming: !!message.video.supports_streaming, 94 | has_spoiler: message.has_media_spoiler, 95 | protect_content: message.has_protected_content 96 | } 97 | }, 98 | document: (message) => { 99 | return { 100 | reply_markup: message.reply_markup, 101 | document: message.document.file_id, 102 | caption: message.caption, 103 | caption_entities: message.caption_entities, 104 | protect_content: message.has_protected_content 105 | } 106 | }, 107 | sticker: (message) => { 108 | return { 109 | reply_markup: message.reply_markup, 110 | sticker: message.sticker.file_id, 111 | protect_content: message.has_protected_content, 112 | emoji: message.sticker.emoji 113 | } 114 | }, 115 | photo: (message) => { 116 | return { 117 | reply_markup: message.reply_markup, 118 | photo: message.photo[message.photo.length - 1].file_id, 119 | caption: message.caption, 120 | caption_entities: message.caption_entities, 121 | has_spoiler: message.has_media_spoiler, 122 | protect_content: message.has_protected_content 123 | } 124 | }, 125 | video_note: (message) => { 126 | return { 127 | reply_markup: message.reply_markup, 128 | video_note: message.video_note.file_id, 129 | thumbnail: message.video_note.thumbnail, 130 | length: message.video_note.length, 131 | duration: message.video_note.duration, 132 | protect_content: message.has_protected_content 133 | } 134 | }, 135 | animation: (message) => { 136 | return { 137 | reply_markup: message.reply_markup, 138 | animation: message.animation.file_id, 139 | thumbnail: message.animation.thumbnail, 140 | duration: message.animation.duration, 141 | has_spoiler: message.has_media_spoiler, 142 | protect_content: message.has_protected_content 143 | } 144 | }, 145 | poll: (message) => { 146 | return { 147 | question: message.poll.question, 148 | type: message.poll.type, 149 | is_anonymous: message.poll.is_anonymous, 150 | allows_multiple_answers: message.poll.allows_multiple_answers, 151 | correct_option_id: message.poll.correct_option_id, 152 | options: message.poll.options.map(({ text }) => text), 153 | protect_content: message.has_protected_content, 154 | explanation: message.poll.explanation, 155 | explanation_entities: message.poll.explanation_entities, 156 | open_period: message.poll.open_period, 157 | close_date: message.poll.close_date 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /docs/examples/basic/keyboard.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { Opengram, Markup, Extra, isTelegramError } = require('opengram') 4 | 5 | // Create Opengram instance with BOT TOKEN given by http://t.me/BotFather 6 | const bot = new Opengram(process.env.BOT_TOKEN) 7 | 8 | // Add updates logging 9 | bot.use(Opengram.log()) 10 | 11 | // Register command handler /onetime 12 | bot.command('onetime', ({ reply }) => 13 | reply('One time keyboard', Markup 14 | .keyboard(['/simple', '/inline', '/pyramid']) 15 | .oneTime() 16 | .resize() 17 | .extra() 18 | ) 19 | ) 20 | 21 | // Register command handler /custom 22 | bot.command('custom', ({ reply }) => { 23 | return reply('Custom buttons keyboard', Markup 24 | .keyboard([ 25 | ['🔍 Search', '😎 Popular'], // Row1 with 2 buttons 26 | ['☸ Setting', '📞 Feedback'], // Row2 with 2 buttons 27 | ['📢 Ads', '⭐️ Rate us', '👥 Share'] // Row3 with 3 buttons 28 | ]) 29 | .oneTime() 30 | .resize() 31 | .extra() 32 | ) 33 | }) 34 | 35 | // Register command handler 🔍 Search text 36 | bot.hears('🔍 Search', ctx => ctx.reply('Yay!')) 37 | 38 | // Register command handler 📢 Ads text 39 | bot.hears('📢 Ads', ctx => ctx.reply('Free hugs. Call now!')) 40 | 41 | // Register command handler /special 42 | bot.command('special', (ctx) => { 43 | return ctx.reply('Special buttons keyboard', Extra.markup((markup) => { 44 | return markup.resize() 45 | .keyboard([ 46 | markup.contactRequestButton('Send contact'), 47 | markup.locationRequestButton('Send location') 48 | ]) 49 | })) 50 | }) 51 | 52 | // Register command handler /pyramid 53 | bot.command('pyramid', (ctx) => { 54 | return ctx.reply('Keyboard wrap', Extra.markup( 55 | Markup.keyboard(['one', 'two', 'three', 'four', 'five', 'six'], { 56 | wrap: (btn, index, currentRow) => currentRow.length >= (index + 1) / 2 57 | }) 58 | )) 59 | }) 60 | 61 | // Register command handler /simple 62 | bot.command('simple', (ctx) => { 63 | return ctx.replyWithHTML('Coke or Pepsi?', Extra.markup( 64 | Markup.keyboard(['Coke', 'Pepsi']) 65 | )) 66 | }) 67 | 68 | // Register command handler /inline 69 | bot.command('inline', (ctx) => { 70 | return ctx.reply('Coke or Pepsi?', Extra.HTML().markup((m) => 71 | m.inlineKeyboard([ 72 | m.callbackButton('Coke', 'Coke'), 73 | m.callbackButton('Pepsi', 'Pepsi') 74 | ]))) 75 | }) 76 | 77 | // Register command handler /random 78 | bot.command('random', (ctx) => { 79 | return ctx.reply('random example', 80 | Markup.inlineKeyboard([ 81 | Markup.callbackButton('Coke', 'Coke'), 82 | Markup.callbackButton('Dr Pepper', 'Dr Pepper', Math.random() > 0.5), 83 | Markup.callbackButton('Pepsi', 'Pepsi') 84 | ]).extra() 85 | ) 86 | }) 87 | 88 | // Register command handler /caption 89 | bot.command('caption', (ctx) => { 90 | return ctx.replyWithPhoto({ url: 'https://picsum.photos/200/300/?random' }, 91 | Extra.load({ caption: 'Caption' }) 92 | .markdown() 93 | .markup((m) => 94 | m.inlineKeyboard([ 95 | m.callbackButton('Plain', 'plain'), 96 | m.callbackButton('Italic', 'italic') 97 | ]) 98 | ) 99 | ) 100 | }) 101 | 102 | // Register command handler /wrap 103 | bot.hears(/\/wrap (\d+)/, (ctx) => { 104 | return ctx.reply('Keyboard wrap', Extra.markup( 105 | Markup.keyboard(['one', 'two', 'three', 'four', 'five', 'six'], { 106 | columns: parseInt(ctx.match[1]) 107 | }) 108 | )) 109 | }) 110 | 111 | // Register callback query handler for "Dr Pepper" data 112 | bot.action('Dr Pepper', (ctx, next) => { 113 | return ctx.reply('👍').then(() => next()) 114 | }) 115 | 116 | // Register callback query handler for "plain" data 117 | bot.action('plain', async (ctx) => { 118 | await ctx.answerCbQuery() 119 | await ctx.editMessageCaption('Caption', Markup.inlineKeyboard([ 120 | Markup.callbackButton('Plain', 'plain'), 121 | Markup.callbackButton('Italic', 'italic') 122 | ])) 123 | }) 124 | 125 | // Register callback query handler for "italic" data 126 | bot.action('italic', async (ctx) => { 127 | await ctx.answerCbQuery() 128 | await ctx.editMessageCaption('_Caption_', Extra.markdown().markup(Markup.inlineKeyboard([ 129 | Markup.callbackButton('Plain', 'plain'), 130 | Markup.callbackButton('* Italic *', 'italic') 131 | ]))) 132 | }) 133 | 134 | // Register handler for callback query with Regex trigger 135 | bot.action(/.+/, (ctx) => { 136 | return ctx.answerCbQuery(`Oh, ${ctx.match[0]}! Great choice`) 137 | }) 138 | 139 | const commandsList = [ 140 | { 141 | command: '/onetime', 142 | description: 'Sends onetime text keyboard' 143 | }, 144 | { 145 | command: '/custom', 146 | description: 'Sends onetime multi row text keyboard' 147 | }, 148 | { 149 | command: '/special', 150 | description: 'Sends text keyboard with contact & location buttons' 151 | }, 152 | { 153 | command: '/pyramid', 154 | description: 'Sends text keyboard with pyramid rows, calculated with wrap function' 155 | }, 156 | { 157 | command: '/simple', 158 | description: 'Sends simple text keyboard' 159 | }, 160 | { 161 | command: '/inline', 162 | description: 'Sends dynamically generated inline keyboard with callback buttons' 163 | }, 164 | { 165 | command: '/random', 166 | description: 'Sends inline keyboard with callback button where one of buttons randomly hided' 167 | }, 168 | { 169 | command: '/caption', 170 | description: 'Sends dynamically generated inline keyboard with photo and caption' 171 | }, 172 | { 173 | command: '/wrap', 174 | description: 'Sends buttons with count of columns given in first command argument, for example /wrap 2' 175 | } 176 | ] 177 | 178 | // Add start command handler for printing commands list 179 | bot.start(ctx => { 180 | return ctx.reply( 181 | commandsList 182 | .map(({ command, description }) => `${command} - ${description}`) 183 | .join('\n') 184 | ) 185 | }) 186 | 187 | // Register error handler, for preventing bot crashes 188 | bot.catch((error, ctx) => { 189 | if (isTelegramError(error)) { 190 | console.error(error, ctx) // Print error and context 191 | return 192 | } 193 | throw error 194 | }) 195 | 196 | // Set commands list in bot menu 197 | bot.telegram.setMyCommands(commandsList) 198 | .then(() => bot.launch()) // Start bot using long-polling 199 | .then(() => console.log('Bot started')) 200 | 201 | // Enable graceful stop 202 | process.once('SIGINT', () => bot.stop()) 203 | process.once('SIGTERM', () => bot.stop()) 204 | -------------------------------------------------------------------------------- /src/session.js: -------------------------------------------------------------------------------- 1 | const { showWarning } = require('./core/helpers/utils') 2 | const debug = require('debug')('opengram:session') 3 | 4 | const storeSym = Symbol('store') 5 | const ttlSym = Symbol('ttl') 6 | const propSym = Symbol('property') 7 | const keyGeneratorFnSym = Symbol('keyGeneratorFn') 8 | const storeSetMethodSym = Symbol('storeSetMethod') 9 | 10 | const WARN_AFTER_SAVE_TEXT = 'A write/read attempt on the session after it was saved detected! Perhaps the chain of promises has broken.' 11 | const ERROR_SESSION_KEY_NOT_DEFINED = 'Cannot access session data because this update does not belong to a chat, so the session key not available!' 12 | 13 | /** 14 | * @module Session 15 | */ 16 | 17 | /** 18 | * @typedef {object} SessionOptions 19 | * @property {Function} [getSessionKey] Function for generating session key. 20 | * @property {string} [property] Sets session property name in context 21 | * @property {number} [ttl] Time to live 22 | * @property {object} [store] Store 23 | */ 24 | 25 | class Session { 26 | /** 27 | * Constructor of session class 28 | * 29 | * @param {SessionOptions} [options] Options 30 | */ 31 | constructor (options = {}) { 32 | this[storeSym] = options.store ?? new Map() 33 | this[propSym] = options.property ?? 'session' 34 | this[keyGeneratorFnSym] = options.getSessionKey ?? getSessionKey 35 | this[ttlSym] = options.ttl && options.ttl * 1000 36 | this[storeSetMethodSym] = typeof this[storeSym].put === 'function' ? 'put' : 'set' 37 | } 38 | 39 | /** 40 | * Store getter 41 | * 42 | * Return store object given in constructor 43 | * 44 | * @return {object} 45 | */ 46 | get store () { 47 | return this[storeSym] 48 | } 49 | 50 | /** 51 | * TTL getter 52 | * 53 | * Returns current ttl in **seconds** or `undefined` value 54 | * 55 | * @return {number|undefined} 56 | */ 57 | get ttl () { 58 | return this[ttlSym] 59 | } 60 | 61 | /** 62 | * TTL setter 63 | * 64 | * Sets new ttl for session 65 | * 66 | * @return {void} 67 | */ 68 | set ttl (seconds) { 69 | this[ttlSym] = seconds 70 | } 71 | 72 | /** 73 | * Returns session middleware 74 | * 75 | * @return {Middleware} 76 | */ 77 | middleware () { 78 | const method = this[storeSetMethodSym] 79 | const propName = this[propSym] 80 | const getSessionKey = this[keyGeneratorFnSym] 81 | 82 | return async (ctx, next) => { 83 | const key = getSessionKey(ctx) 84 | 85 | if (!key) { 86 | Object.defineProperty(ctx, propName, { 87 | get: () => { 88 | throw new Error(ERROR_SESSION_KEY_NOT_DEFINED) 89 | }, 90 | set: () => { 91 | throw new Error(ERROR_SESSION_KEY_NOT_DEFINED) 92 | } 93 | }) 94 | return await next() 95 | } 96 | 97 | let afterSave = false 98 | 99 | const wrapSession = (targetSessionObject) => ( 100 | new Proxy({ ...targetSessionObject }, { 101 | set: (target, prop, value) => { 102 | if (afterSave) showWarning(WARN_AFTER_SAVE_TEXT + ` [${propName}.${prop}]`) 103 | target[prop] = value 104 | return true 105 | }, 106 | get (target, prop) { 107 | if (afterSave) showWarning(WARN_AFTER_SAVE_TEXT + ` [${propName}.${prop}]`) 108 | return target[prop] 109 | }, 110 | deleteProperty: (target, prop) => { 111 | if (afterSave) showWarning(WARN_AFTER_SAVE_TEXT + ` [${propName}.${prop}]`) 112 | delete target[prop] 113 | return true 114 | } 115 | }) 116 | ) 117 | 118 | const now = Date.now() 119 | 120 | const state = await Promise.resolve( 121 | this.store.get(key) 122 | ) || { session: {} } 123 | 124 | let { session, expires } = state 125 | 126 | // Wrap session to Proxy 127 | session = wrapSession(session) 128 | 129 | debug('session snapshot', key, session) 130 | 131 | if (expires && expires < now) { 132 | debug('session expired', key) 133 | session = {} 134 | } 135 | 136 | Object.defineProperty(ctx, propName, { 137 | get: () => session, 138 | set: (newSession) => { 139 | // Wrap session to Proxy 140 | session = wrapSession(newSession) 141 | } 142 | }) 143 | 144 | const result = await next(ctx) 145 | 146 | debug('save session', key, session) 147 | const newSession = { ...session } // Bypass proxy 148 | afterSave = true 149 | await Promise.resolve( 150 | this.store[method](key, { 151 | session: newSession, 152 | expires: this.ttl ? now + this.ttl : null 153 | }) 154 | ) 155 | debug('session saved', key, session) 156 | 157 | return result 158 | } 159 | } 160 | } 161 | 162 | /** 163 | * Creates session middleware with given store and options 164 | * 165 | * ### Custom session property 166 | * You can set custom session property using `property` option 167 | * 168 | * ```js 169 | * bot.use( 170 | * session({ 171 | * property: 'propName' 172 | * }) 173 | * ) 174 | * ``` 175 | * For this example, session available in `ctx.propName` 176 | * 177 | * ### Custom session key 178 | * By default, session key in storage generated with this function: 179 | * ```js 180 | * (ctx) => { 181 | * return ctx.from && ctx.chat && `${ctx.from.id}:${ctx.chat.id}` 182 | * } 183 | * ``` 184 | * 185 | * For example, you can redefine this function for storing session with chat key, like this: 186 | * ```js 187 | * (ctx) => { 188 | * return ctx.chat && `${ctx.chat.id}` 189 | * } 190 | * ``` 191 | * 192 | * If you don't want to add session object, you can return `null` or `undefined` from this function. 193 | * 194 | * For example, session working only in chat updates: 195 | * ```js 196 | * (ctx) => { 197 | * if (ctx.chat.type === 'private') return null // When chat type is private `ctx.session` not available 198 | * return ctx.chat && `${ctx.chat.id}` 199 | * } 200 | * ``` 201 | * 202 | * ### TTL ( Time to live) 203 | * This parameter can set in `ttl` option in **seconds**, expire time of session, 204 | * by default session time not limited, but if you use in memory store like 205 | * [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) or other, 206 | * session be destroyed after bot restart 207 | * 208 | * @param {SessionOptions} [options] Session options 209 | * @return {Session} 210 | */ 211 | function sessionFactory (options) { 212 | return new Session(options) 213 | } 214 | 215 | function getSessionKey (ctx) { 216 | return ctx.from && ctx.chat && `${ctx.from.id}:${ctx.chat.id}` 217 | } 218 | 219 | module.exports = sessionFactory 220 | -------------------------------------------------------------------------------- /docs/media/Logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 26 | 29 | 39 | 44 | 49 | 51 | 53 | 76 | 91 | 93 | 98 | 102 | 107 | 111 | 117 | 123 | 129 | 131 | 133 | 135 | 137 | 139 | 141 | image/svg+xml 144 | 147 | 150 | 152 | 155 | Openclipart 158 | 160 | 162 | icon_cube_green 165 | 2008-05-26T15:28:45 168 | 170 | https://openclipart.org/detail/17060/icon_cube_green-by-jean_victor_balin 173 | 175 | 177 | jean_victor_balin 180 | 182 | 184 | 186 | 188 | cube 191 | icon 194 | item 197 | 199 | 201 | 203 | 206 | 209 | 212 | 215 | 217 | 219 | 221 | 223 | -------------------------------------------------------------------------------- /src/core/exeptionsList.js: -------------------------------------------------------------------------------- 1 | const exceptionsList = { 2 | TelegramError: { 3 | BadRequest: { 4 | MessageNotModified: { match: 'message is not modified' }, 5 | MessageToForwardNotFound: { match: 'message to forward not found' }, 6 | MessageIdInvalid: { match: 'message_id_invalid' }, 7 | MessageToDeleteNotFound: { match: 'message to delete not found' }, 8 | MessageToPinNotFound: { match: 'message to pin not found' }, 9 | MessageToReplyNotFound: { match: 'reply message not found' }, 10 | MessageIdentifierNotSpecified: { match: 'message identifier is not specified' }, 11 | MessageTextIsEmpty: { match: 'message text is empty' }, 12 | MessageCantBeEdited: { match: 'message can\'t be edited' }, 13 | MessageCantBeDeleted: { match: 'message can\'t be deleted' }, 14 | MessageCantBeForwarded: { match: 'message can\'t be forwarded' }, 15 | MessageToEditNotFound: { match: 'message to edit not found' }, 16 | MessageIsTooLong: { match: 'message is too long' }, 17 | ToMuchMessages: { match: 'too much messages to send as an album' }, 18 | ObjectExpectedAsReplyMarkup: { match: 'object expected as reply markup' }, 19 | InlineKeyboardExpected: { match: 'inline keyboard expected' }, 20 | PollCantBeStopped: { match: 'poll can\'t be stopped' }, 21 | PollHasAlreadyBeenClosed: { match: 'poll has already been closed' }, 22 | PollsCantBeSentToPrivateChats: { match: "polls can't be sent to private chats" }, 23 | PollMustHaveMoreOptions: { match: 'poll must have at least 2 option' }, 24 | PollCantHaveMoreOptions: { match: "poll can't have more than 10 options" }, 25 | PollOptionsMustBeNonEmpty: { match: 'poll options must be non-empty' }, 26 | PollQuestionMustBeNonEmpty: { match: 'poll question must be non-empty' }, 27 | PollOptionsLengthTooLong: { match: 'poll options length must not exceed 100' }, 28 | PollQuestionLengthTooLong: { match: 'poll question length must not exceed 255' }, 29 | PollCanBeRequestedInPrivateChatsOnly: { match: 'poll can be requested in private chats only' }, 30 | MessageWithPollNotFound: { match: 'message with poll to stop not found' }, 31 | MessageIsNotAPoll: { match: 'message is not a poll' }, 32 | ChatNotFound: { match: 'chat not found' }, 33 | ChatIdIsEmpty: { match: 'chat_id is empty' }, 34 | InvalidUserId: { match: 'user_id_invalid' }, 35 | ChatDescriptionIsNotModified: { match: 'chat description is not modified' }, 36 | InvalidQueryID: { match: 'query is too old and response timeout expired or query id is invalid' }, 37 | InvalidPeerID: { match: 'peer_id_invalid' }, 38 | InvalidHTTPUrlContent: { match: 'failed to get http url content' }, 39 | ButtonURLInvalid: { match: 'button_url_invalid' }, 40 | URLHostIsEmpty: { match: 'url host is empty' }, 41 | StartParamInvalid: { match: 'start_param_invalid' }, 42 | ButtonDataInvalid: { match: 'button_data_invalid' }, 43 | FileIsTooBig: { match: 'file is too big' }, 44 | WrongFileIdentifier: { match: 'wrong file identifier/http url specified' }, 45 | GroupDeactivated: { match: 'group chat was deactivated' }, 46 | PhotoAsInputFileRequired: { match: 'photo should be uploaded as an inputfile' }, 47 | InvalidStickersSet: { match: 'stickerset_invalid' }, 48 | NoStickerInRequest: { match: 'there is no sticker in the request' }, 49 | TooMuchStickersInSet: { match: 'stickers_too_much' }, 50 | ChatAdminRequired: { match: 'chat_admin_required' }, 51 | NeedAdministratorRightsInTheChannel: { match: 'need administrator rights in the channel chat' }, 52 | NotEnoughRightsToPinMessage: { match: 'not enough rights to pin a message' }, 53 | MethodNotAvailableInPrivateChats: { match: 'method is available only for supergroups and channel' }, 54 | CantDemoteChatCreator: { match: 'can\'t demote chat creator' }, 55 | CantRestrictSelf: { match: "can't restrict self" }, 56 | NotEnoughRightsToRestrict: { match: 'not enough rights to restrict/unrestrict chat member' }, 57 | PhotoDimensions: { match: 'photo_invalid_dimensions' }, 58 | UnavailableMembers: { match: 'supergroup members are unavailable' }, 59 | TypeOfFileMismatch: { match: 'type of file mismatch' }, 60 | WrongRemoteFileIdSpecified: { match: 'wrong remote file id specified' }, // ???? 61 | PaymentProviderInvalid: { match: 'payment_provider_invalid' }, 62 | CurrencyTotalAmountInvalid: { match: 'currency_total_amount_invalid' }, 63 | WebhookRequireHTTPS: { match: 'https url must be provided for webhook' }, 64 | BadWebhookPort: { match: 'webhook can be set up only on ports 80, 88, 443 or 8443' }, 65 | BadWebhookAddrInfo: { match: 'getaddrinfo: temporary failure in name resolution' }, 66 | BadWebhookNoAddressAssociatedWithHostname: { match: 'failed to resolve host: no address associated with hostname' }, 67 | CantParseUrl: { match: 'can\'t parse url' }, 68 | UnsupportedUrlProtocol: { match: 'unsupported url protocol' }, 69 | CantParseEntities: { match: 'can\'t parse entities' }, 70 | ResultIdDuplicate: { match: 'result_id_duplicate' }, 71 | BotDomainInvalid: { match: 'bot_domain_invalid' }, 72 | MethodIsNotAvailable: { match: 'method is available only for supergroups' }, 73 | CantRestrictChatOwner: { match: 'can\'t remove chat owner' }, 74 | UserIsAnAdministratorOfTheChat: { match: 'user is an administrator of the chat' }, 75 | MethodNotKnown: { match: 'method not found' } 76 | }, 77 | ConflictError: { 78 | TerminatedByOtherGetUpdates: { match: 'terminated by other getupdates request' }, 79 | CantGetUpdates: { match: 'can\'t use getupdates method while webhook is active' } 80 | }, 81 | ForbiddenError: { 82 | BotKicked: { match: 'bot was kicked from' }, 83 | BotBlocked: { match: 'bot was blocked by the user' }, 84 | UserDeactivated: { match: 'user is deactivated' }, 85 | CantInitiateConversation: { match: 'bot can\'t initiate conversation with a user' }, 86 | CantTalkWithBots: { match: 'bot can\'t send messages to bots' } 87 | } 88 | } 89 | } 90 | 91 | const exceptionsHTTPCodes = { 92 | 400: 'BadRequest', 93 | 409: 'ConflictError', 94 | 403: 'ForbiddenError' 95 | } 96 | 97 | const exceptionsHTTPCodesReverse = Object.fromEntries( 98 | Object.entries(exceptionsHTTPCodes) 99 | .map(a => a.reverse()) 100 | ) 101 | 102 | const exceptionsToMatch = Object.fromEntries( 103 | Object.entries(exceptionsList.TelegramError) 104 | .map(([name, value]) => [exceptionsHTTPCodesReverse[name], value]) 105 | ) 106 | 107 | function matchExceptionType (err) { 108 | if (!err.description || !err.error_code || !exceptionsToMatch[err.error_code]) return null 109 | 110 | const [errName] = Object.entries(exceptionsToMatch[err.error_code]) 111 | .find( 112 | ([, meta]) => err.description 113 | .toLowerCase() 114 | .includes(meta.match.toLowerCase()) 115 | ) ?? [null] 116 | 117 | return errName 118 | } 119 | 120 | module.exports = { exceptionsList, matchExceptionType, exceptionsHTTPCodes, exceptionsHTTPCodesReverse } 121 | -------------------------------------------------------------------------------- /src/extra.js: -------------------------------------------------------------------------------- 1 | const { Markup } = require('./markup') 2 | 3 | /** 4 | * Class for building extra parameters of messages 5 | * 6 | * @class 7 | */ 8 | class Extra { 9 | /** 10 | * @constructor 11 | * @param {object} [opts] Initial extra parameters 12 | * ```js 13 | * // Loads `reply_to_message_id: 1` parameter 14 | * new Extra({ reply_to_message_id: 1 }) 15 | * ``` 16 | */ 17 | constructor (opts) { 18 | this.load(opts) 19 | } 20 | 21 | /** 22 | * Loads extra parameters from object to Extra instance 23 | * ```js 24 | * // Returns new instance with `reply_to_message_id: 1` 25 | * Extra.load({ reply_to_message_id: 1 }) 26 | * // Loads `reply_to_message_id: 1` parameter to exists instance 27 | * new Extra().load({ reply_to_message_id: 1 }) 28 | * ``` 29 | * 30 | * @param {object} opts Extra parameters object 31 | * @return {Extra} 32 | */ 33 | load (opts = {}) { 34 | return Object.assign(this, opts) 35 | } 36 | 37 | /** 38 | * Adding reply to message 39 | * 40 | * @see https://core.telegram.org/bots/api#sendmessage 41 | * @param {number} messageId Message id to reply 42 | * @return {Extra} 43 | */ 44 | inReplyTo (messageId) { 45 | this.reply_to_message_id = messageId 46 | return this 47 | } 48 | 49 | /** 50 | * Enable / Disable notification for message 51 | * 52 | * @see https://core.telegram.org/bots/api#sendmessage 53 | * @param {boolean} [value=true] Value 54 | * @return {Extra} 55 | */ 56 | notifications (value = true) { 57 | this.disable_notification = !value 58 | return this 59 | } 60 | 61 | /** 62 | * Enable / Disable web preview for links in message 63 | * 64 | * @see https://core.telegram.org/bots/api#sendmessage 65 | * @param {boolean} [value=true] Value 66 | * @return {Extra} 67 | */ 68 | webPreview (value = true) { 69 | this.disable_web_page_preview = !value 70 | return this 71 | } 72 | 73 | /** 74 | * Markup factory function 75 | * 76 | * @name MarkupCallback 77 | * @function 78 | * @param {Markup} markup Empty created Markup instance 79 | * @example 80 | * Extra.markdown().markup((markup) => markup.removeKeyboard()) 81 | */ 82 | 83 | /** 84 | * Adds (inline-)keyboard markup to Extra instance 85 | * 86 | * ```js 87 | * // Example with factory function 88 | * ctx.reply('Hello', 89 | * Extra.markdown() 90 | * .markup((markup) => markup.removeKeyboard()) 91 | * ) 92 | * 93 | * // With Markup instance / object 94 | * const { Markup } = require('opengram') 95 | * const keyboard = Markup.inlineKeyboard([Markup.callbackButton('Anime', 'data')]) 96 | * 97 | * ctx.reply('Hello', 98 | * Extra.markdown().markup(keyboard) 99 | * ) 100 | * ``` 101 | * 102 | * @see https://core.telegram.org/bots/api#sendmessage 103 | * @param {object|MarkupCallback} markup Callback returning markup / Markup object 104 | * @return {Extra} 105 | */ 106 | markup (markup) { 107 | if (typeof markup === 'function') { 108 | markup = markup(new Markup()) 109 | } 110 | this.reply_markup = { ...markup } 111 | return this 112 | } 113 | 114 | /** 115 | * Enable / Disable `parse_mode: 'HTML'` for message 116 | * 117 | * @see https://core.telegram.org/bots/api#formatting-options 118 | * @param {boolean} [value=true] Value 119 | * @return {Extra} 120 | */ 121 | HTML (value = true) { 122 | this.parse_mode = value ? 'HTML' : undefined 123 | return this 124 | } 125 | 126 | /** 127 | * Enable / Disable `parse_mode: 'Markdown'` for message 128 | * 129 | * @see https://core.telegram.org/bots/api#formatting-options 130 | * @param {boolean} [value=true] Value 131 | * @return {Extra} 132 | */ 133 | markdown (value = true) { 134 | this.parse_mode = value ? 'Markdown' : undefined 135 | return this 136 | } 137 | 138 | /** 139 | * Enable / Disable `parse_mode: 'MarkdownV2'` for message 140 | * 141 | * @see https://core.telegram.org/bots/api#formatting-options 142 | * @param {boolean} [value=true] value 143 | * @return {Extra} 144 | */ 145 | markdownV2 (value = true) { 146 | this.parse_mode = value ? 'MarkdownV2' : undefined 147 | return this 148 | } 149 | 150 | /** 151 | * Adds caption for the animation, audio, document, photo, video or voice 152 | * 153 | * @see https://core.telegram.org/bots/api#sendmessage 154 | * @param {string} [caption] The text of caption 155 | * @return {Extra} 156 | */ 157 | caption (caption = '') { 158 | this.caption = caption 159 | return this 160 | } 161 | 162 | /** 163 | * Adds entities for message text 164 | * 165 | * @see https://core.telegram.org/bots/api#sendmessage 166 | * @param {MessageEntity[]} entities Array of entities 167 | * @return {Extra} 168 | */ 169 | entities (entities) { 170 | this.entities = entities 171 | return this 172 | } 173 | 174 | /** 175 | * Adds caption entities for the animation, audio, document, photo, video or voice 176 | * 177 | * @see https://core.telegram.org/bots/api#sendmessage 178 | * @param {MessageEntity[]} entities Array of entities 179 | * @return {Extra} 180 | */ 181 | captionEntities (entities) { 182 | this.caption_entities = entities 183 | return this 184 | } 185 | 186 | /** 187 | * Adding reply to message 188 | * 189 | * @see https://core.telegram.org/bots/api#sendmessage 190 | * @param {number} messageId Message id to reply 191 | * @return {Extra} 192 | */ 193 | static inReplyTo (messageId) { 194 | return new Extra().inReplyTo(messageId) 195 | } 196 | 197 | /** 198 | * Enable / Disable notification for message 199 | * 200 | * @see https://core.telegram.org/bots/api#sendmessage 201 | * @param {boolean} [value=true] Value 202 | * @return {Extra} 203 | */ 204 | static notifications (value) { 205 | return new Extra().notifications(value) 206 | } 207 | 208 | /** 209 | * Enable / Disable web preview for links in message 210 | * 211 | * @see https://core.telegram.org/bots/api#sendmessage 212 | * @param {boolean} [value=true] Value 213 | * @return {Extra} 214 | */ 215 | static webPreview (value) { 216 | return new Extra().webPreview(value) 217 | } 218 | 219 | /** 220 | * Loads extra parameters from object to Extra instance 221 | * ```js 222 | * // Returns new instance with `reply_to_message_id: 1` 223 | * Extra.load({ reply_to_message_id: 1 }) 224 | * // Loads `reply_to_message_id: 1` parameter to exists instance 225 | * new Extra().load({ reply_to_message_id: 1 }) 226 | * ``` 227 | * 228 | * @param {object} opts Extra parameters object 229 | * @return {Extra} 230 | */ 231 | static load (opts) { 232 | return new Extra(opts) 233 | } 234 | 235 | /** 236 | * Adds (inline-)keyboard markup to Extra instance 237 | * 238 | * ```js 239 | * // Example with factory function 240 | * ctx.reply('Hello', 241 | * Extra.markdown() 242 | * .markup((markup) => markup.removeKeyboard()) 243 | * ) 244 | * 245 | * // With Markup instance / object 246 | * const { Markup } = require('opengram') 247 | * const keyboard = Markup.inlineKeyboard([Markup.callbackButton('Anime', 'data')]) 248 | * 249 | * ctx.reply('Hello', 250 | * Extra.markdown().markup(keyboard) 251 | * ) 252 | * ``` 253 | * 254 | * @see https://core.telegram.org/bots/api#sendmessage 255 | * @param {object|Markup|MarkupCallback} markup Markup object 256 | * @return {object} 257 | */ 258 | static markup (markup) { 259 | return new Extra().markup(markup) 260 | } 261 | 262 | /** 263 | * Adds entities for message text 264 | * 265 | * @see https://core.telegram.org/bots/api#sendmessage 266 | * @param {MessageEntity[]} entities Array of entities 267 | * @return {Extra} 268 | */ 269 | static entities (entities) { 270 | return new Extra().entities(entities) 271 | } 272 | 273 | /** 274 | * Enable / Disable `parse_mode: 'HTML'` for message 275 | * 276 | * @see https://core.telegram.org/bots/api#formatting-options 277 | * @param {boolean} [value=true] Value 278 | * @return {Extra} 279 | */ 280 | static HTML (value) { 281 | return new Extra().HTML(value) 282 | } 283 | 284 | /** 285 | * Enable / Disable `parse_mode: 'Markdown'` for message 286 | * 287 | * @see https://core.telegram.org/bots/api#formatting-options 288 | * @param {boolean} [value=true] Value 289 | * @return {Extra} 290 | */ 291 | static markdown (value) { 292 | return new Extra().markdown(value) 293 | } 294 | 295 | /** 296 | * Enable / Disable `parse_mode: 'MarkdownV2'` for message 297 | * 298 | * @see https://core.telegram.org/bots/api#formatting-options 299 | * @param {boolean} [value=true] Value 300 | * @return {Extra} 301 | */ 302 | static markdownV2 (value) { 303 | return new Extra().markdownV2(value) 304 | } 305 | 306 | /** 307 | * Adds caption for the animation, audio, document, photo, video or voice 308 | * 309 | * @see https://core.telegram.org/bots/api#sendmessage 310 | * @param {string} [caption] The text of caption 311 | * @return {Extra} 312 | */ 313 | static caption (caption) { 314 | return new Extra().caption(caption) 315 | } 316 | 317 | /** 318 | * Adds caption entities for the animation, audio, document, photo, video or voice 319 | * 320 | * @see https://core.telegram.org/bots/api#sendmessage 321 | * @param {MessageEntity[]} entities Array of entities 322 | * @return {Extra} 323 | */ 324 | static captionEntities (entities) { 325 | return new Extra().captionEntities(entities) 326 | } 327 | } 328 | 329 | /** 330 | * Markup class. You can import Markup from Extra 331 | * ```js 332 | * const { Extra: { Markup } } = require('opengram') 333 | * ``` 334 | * 335 | * @type {Markup} 336 | */ 337 | Extra.Markup = Markup 338 | 339 | module.exports = Extra 340 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Opengram 2 | 3 | First off, thanks for taking the time to contribute! ❤️ 4 | 5 | All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉 6 | 7 | > And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about: 8 | > - Star the project 9 | > - Tweet about it 10 | > - Refer this project in your project's readme 11 | > - Mention the project at local meetups and tell your friends/colleagues 12 | 13 | ## Table of Contents 14 | 15 | - [Code of Conduct](#code-of-conduct) 16 | - [I Have a Question](#i-have-a-question) 17 | - [I Want To Contribute](#i-want-to-contribute) 18 | - [Reporting Bugs](#reporting-bugs) 19 | - [Suggesting Enhancements](#suggesting-enhancements) 20 | - [Your First Code Contribution](#your-first-code-contribution) 21 | - [Improving The Documentation](#improving-the-documentation) 22 | - [Styleguides](#styleguides) 23 | - [Commit Messages](#commit-messages) 24 | - [Codestyle](#codestyle) 25 | - [Pull Request Process](#pull-request-process) 26 | 27 | ## Code of Conduct 28 | 29 | This project and everyone participating in it is governed by the 30 | [Opengram Code of Conduct](https://github.com/OpengramJS/opengram/blob/master/CODE_OF_CONDUCT.md). 31 | By participating, you are expected to uphold this code. Please report unacceptable behavior 32 | to . 33 | 34 | 35 | ## I Have a Question 36 | 37 | > If you want to ask a question, we assume that you have read the available [Documentation](https://opengram.dev). 38 | 39 | Before you ask a question, it is best to search for existing [Issues](https://github.com/OpengramJS/opengram/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first. 40 | 41 | If you then still feel the need to ask a question and need clarification, we recommend the following: 42 | 43 | - Open an [Issue](https://github.com/OpengramJS/opengram/issues/new). 44 | - Provide as much context as you can about what you're running into. 45 | - Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant. 46 | 47 | We will then take care of the issue as soon as possible. 48 | 49 | ## I Want To Contribute 50 | 51 | > ### Legal Notice 52 | > When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. 53 | 54 | ### Reporting Bugs 55 | 56 | #### Before Submitting a Bug Report 57 | 58 | A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. 59 | 60 | - Make sure that you are using the latest version. 61 | - Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://opengram.dev). If you are looking for support, you might want to check [this section](#i-have-a-question)). 62 | - To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/OpengramJS/opengramissues?q=label%3Abug). 63 | - Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue. 64 | - Collect information about the bug: 65 | - Stack trace (Traceback) 66 | - OS, Platform and Version (Windows, Linux, macOS, x86, ARM) 67 | - Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant. 68 | - Possibly your input and the output 69 | - Can you reliably reproduce the issue? And can you also reproduce it with older versions? 70 | 71 | #### How Do I Submit a Good Bug Report? 72 | 73 | > You must never report security related issues, vulnerabilities or bugs including sensitive information to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to . 74 | 75 | We use GitHub issues to track bugs and errors. If you run into an issue with the project: 76 | 77 | - Open an [Issue](https://github.com/OpengramJS/opengram/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) 78 | - Explain the behavior you would expect and the actual behavior. 79 | - Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case. 80 | - Provide the information you collected in the previous section. 81 | 82 | Once it's filed: 83 | 84 | - The project team will label the issue accordingly. 85 | - A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced. 86 | - If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution). 87 | 88 | ### Suggesting Enhancements 89 | 90 | This section guides you through submitting an enhancement suggestion for Opengram, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions. 91 | 92 | #### Before Submitting an Enhancement 93 | 94 | - Make sure that you are using the latest version. 95 | - Read the [documentation](https://opengram.dev) carefully and find out if the functionality is already covered, maybe by an individual configuration. 96 | - Perform a [search](https://github.com/OpengramJS/opengram/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. 97 | - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library. 98 | 99 | #### How Do I Submit a Good Enhancement Suggestion? 100 | 101 | Enhancement suggestions are tracked as [GitHub issues](https://github.com/OpengramJS/opengram/issues). 102 | 103 | - Use a **clear and descriptive title** for the issue to identify the suggestion. 104 | - Provide a **step-by-step description of the suggested enhancement** in as many details as possible. 105 | - **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you. 106 | - You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux. 107 | - **Explain why this enhancement would be useful** to most Opengram users. You may also want to point out the other projects that solved it better and which could serve as inspiration. 108 | 109 | ## Styleguides 110 | ### Commit Messages 111 | We use commit lint for commit messages with [Conventional Commits v1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) 112 | 113 | ### Codestyle 114 | We use ESLint with [standard](https://standardjs.com/) configuration to maintain code style 115 | 116 | ## Pull Request Process 117 | You can contribute changes to this repo by opening a pull request: 118 | 119 | 1) After forking this repository to your Git account, make the proposed changes on your forked branch. 120 | 2) Run tests and linting locally. 121 | - Install dev dependencies, run `npm i` 122 | - Run `npm run lint && npm test && npm run remark`. 123 | 3) Commit your changes and push them to your forked repository. 124 | 4) Navigate to the main `opengram` repository and select the *Pull Requests* tab. 125 | 5) Click the *New pull request* button, then select the option "Compare across forks" 126 | 6) Leave the base branch set to main. Set the compare branch to your forked branch, and open the pull request. 127 | 7) Once your pull request is created, ensure that all checks have passed and that your branch has no conflicts with the base branch. If there are any issues, resolve these changes in your local repository, and then commit and push them to git. 128 | 8) Similarly, respond to any reviewer comments or requests for changes by making edits to your local repository and pushing them to Git. 129 | 9) Once the pull request has been reviewed, those with write access to the branch will be able to merge your changes into the `opengram` repository. 130 | 131 | If you need more information on the steps to create a pull request, you can find a detailed walkthrough in the [Github documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork) 132 | -------------------------------------------------------------------------------- /test/markup.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const { Markup } = require('../') 3 | const { hideSymbol } = require('../src/markup') 4 | 5 | test('should generate removeKeyboard markup', t => { 6 | const markup = { ...Markup.removeKeyboard() } 7 | t.deepEqual(markup, { remove_keyboard: true }) 8 | }) 9 | 10 | test('should generate forceReply markup', t => { 11 | const markup = { ...Markup.forceReply() } 12 | t.deepEqual(markup, { force_reply: true }) 13 | }) 14 | 15 | test('should generate resizeKeyboard markup', t => { 16 | const markup = { ...Markup.keyboard([]).resize() } 17 | t.deepEqual(markup, { resize_keyboard: true }) 18 | }) 19 | 20 | test('should generate oneTimeKeyboard markup', t => { 21 | const markup = { ...Markup.keyboard([]).oneTime() } 22 | t.deepEqual(markup, { one_time_keyboard: true }) 23 | }) 24 | 25 | test('should generate selective hide markup', t => { 26 | const markup = { ...Markup.removeKeyboard().selective() } 27 | t.deepEqual(markup, { remove_keyboard: true, selective: true }) 28 | }) 29 | 30 | test('should generate selective one time keyboard markup', t => { 31 | const markup = { ...Markup.keyboard([]).selective().oneTime() } 32 | t.deepEqual(markup, { selective: true, one_time_keyboard: true }) 33 | }) 34 | 35 | test('should generate persistent keyboard markup', t => { 36 | const markup = { ...Markup.keyboard([]).persistent() } 37 | t.deepEqual(markup, { is_persistent: true }) 38 | }) 39 | 40 | test('should generate keyboard markup', t => { 41 | const markup = { ...Markup.keyboard([['one'], ['two', 'three']]) } 42 | t.deepEqual(markup, { 43 | keyboard: [ 44 | ['one'], 45 | ['two', 'three'] 46 | ] 47 | }) 48 | }) 49 | 50 | test('should generate keyboard markup with default setting', t => { 51 | const markup = { ...Markup.keyboard(['one', 'two', 'three']) } 52 | t.deepEqual(markup, { 53 | keyboard: [ 54 | ['one'], 55 | ['two'], 56 | ['three'] 57 | ] 58 | }) 59 | }) 60 | 61 | test('should generate keyboard markup with options', t => { 62 | const markup = { ...Markup.keyboard(['one', 'two', 'three'], { columns: 3 }) } 63 | t.deepEqual(markup, { 64 | keyboard: [ 65 | ['one', 'two', 'three'] 66 | ] 67 | }) 68 | }) 69 | 70 | test('should generate keyboard markup with custom columns', t => { 71 | const markup = { ...Markup.keyboard(['one', 'two', 'three', 'four'], { columns: 3 }) } 72 | t.deepEqual(markup, { 73 | keyboard: [ 74 | ['one', 'two', 'three'], 75 | ['four'] 76 | ] 77 | }) 78 | }) 79 | 80 | test('should generate keyboard markup with custom wrap fn', t => { 81 | const markup = { 82 | ...Markup.keyboard(['one', 'two', 'three', 'four'], { 83 | wrap: (btn, index, currentRow) => index % 2 !== 0 84 | }) 85 | } 86 | t.deepEqual(markup, { 87 | keyboard: [ 88 | ['one'], 89 | ['two', 'three'], 90 | ['four'] 91 | ] 92 | }) 93 | }) 94 | 95 | test('should generate inline keyboard markup with default setting', (t) => { 96 | const markup = { ...Markup.inlineKeyboard(['one', 'two', 'three', 'four']) } 97 | t.deepEqual(markup, { 98 | inline_keyboard: [[ 99 | 'one', 100 | 'two', 101 | 'three', 102 | 'four' 103 | ]] 104 | }) 105 | }) 106 | 107 | test('should generate extra from keyboard markup', t => { 108 | const markup = { ...Markup.inlineKeyboard(['one', 'two', 'three', 'four']).extra() } 109 | t.deepEqual(markup, { 110 | reply_markup: { 111 | inline_keyboard: [[ 112 | 'one', 113 | 'two', 114 | 'three', 115 | 'four' 116 | ]] 117 | } 118 | }) 119 | }) 120 | 121 | test('should generate standart button markup', t => { 122 | const markup = { ...Markup.button('foo') } 123 | t.deepEqual(markup, { text: 'foo', [hideSymbol]: false }) 124 | }) 125 | 126 | test('should generate cb button markup', t => { 127 | const markup = { ...Markup.callbackButton('foo', 'bar') } 128 | t.deepEqual(markup, { text: 'foo', callback_data: 'bar', [hideSymbol]: false }) 129 | }) 130 | 131 | test('should generate url button markup', t => { 132 | const markup = { ...Markup.urlButton('foo', 'https://bar.tld') } 133 | t.deepEqual(markup, { text: 'foo', url: 'https://bar.tld', [hideSymbol]: false }) 134 | }) 135 | 136 | test('should generate location request button markup', t => { 137 | const markup = { ...Markup.locationRequestButton('send location') } 138 | t.deepEqual(markup, { text: 'send location', request_location: true, [hideSymbol]: false }) 139 | }) 140 | 141 | test('should generate contact request button markup', t => { 142 | const markup = { ...Markup.contactRequestButton('send contact') } 143 | t.deepEqual(markup, { text: 'send contact', request_contact: true, [hideSymbol]: false }) 144 | }) 145 | 146 | test('should generate switch inline query button markup', t => { 147 | const markup = { ...Markup.switchToChatButton('play now', 'foo') } 148 | t.deepEqual(markup, { text: 'play now', switch_inline_query: 'foo', [hideSymbol]: false }) 149 | }) 150 | 151 | test('should generate switch inline query button markup for chat', t => { 152 | const markup = { ...Markup.switchToCurrentChatButton('play now', 'foo') } 153 | t.deepEqual(markup, { text: 'play now', switch_inline_query_current_chat: 'foo', [hideSymbol]: false }) 154 | }) 155 | 156 | test('should generate game button markup', t => { 157 | const markup = { ...Markup.gameButton('play') } 158 | t.deepEqual(markup, { text: 'play', callback_game: {}, [hideSymbol]: false }) 159 | }) 160 | 161 | test('should generate hidden game button markup', t => { 162 | const markup = { ...Markup.gameButton('play again', true) } 163 | t.deepEqual(markup, { text: 'play again', callback_game: {}, [hideSymbol]: true }) 164 | }) 165 | 166 | test('should generate webApp button markup', t => { 167 | const markup = { ...Markup.webApp('Order food', 'https://example.com') } 168 | t.deepEqual(markup, { text: 'Order food', web_app: { url: 'https://example.com' }, [hideSymbol]: false }) 169 | }) 170 | 171 | test('should generate userRequest button markup', t => { 172 | const markup = { ...Markup.userRequest('Select user', 123, true) } 173 | t.deepEqual(markup, { text: 'Select user', request_user: { user_is_premium: true, request_id: 123 }, [hideSymbol]: false }) 174 | }) 175 | 176 | test('should generate botRequest button markup', t => { 177 | const markup = { ...Markup.botRequest('Select bot', 123) } 178 | t.deepEqual(markup, { text: 'Select bot', request_user: { request_id: 123, user_is_bot: true }, [hideSymbol]: false }) 179 | }) 180 | 181 | test('should generate groupRequest button markup', t => { 182 | const markup = { ...Markup.groupRequest('Select group', 123, {}, false) } 183 | t.deepEqual(markup, { 184 | text: 'Select group', 185 | request_chat: { request_id: 123, chat_is_channel: false }, 186 | [hideSymbol]: false 187 | }) 188 | }) 189 | 190 | test('should generate channelRequest button markup', t => { 191 | const markup = { ...Markup.channelRequest('Select channel', 123, {}, false) } 192 | t.deepEqual(markup, { 193 | text: 'Select channel', 194 | request_chat: { request_id: 123, chat_is_channel: true }, 195 | [hideSymbol]: false 196 | }) 197 | }) 198 | 199 | test('should generate switchToChosenChatButton button markup', t => { 200 | const markup = { 201 | ...Markup.switchToChosenChatButton('Switch', '123', { 202 | allow_bot_chats: true, 203 | allow_channel_chats: true, 204 | allow_group_chats: true, 205 | allow_user_chats: true 206 | }) 207 | } 208 | t.deepEqual(markup, { 209 | text: 'Switch', 210 | switch_inline_query_chosen_chat: { 211 | query: '123', 212 | allow_bot_chats: true, 213 | allow_channel_chats: true, 214 | allow_group_chats: true, 215 | allow_user_chats: true 216 | }, 217 | [hideSymbol]: false 218 | }) 219 | }) 220 | 221 | test('should generate markup', t => { 222 | const markup = Markup.formatHTML('strike', [ 223 | { 224 | offset: 0, 225 | length: 6, 226 | type: 'strikethrough' 227 | } 228 | ]) 229 | t.deepEqual(markup, 'strike') 230 | }) 231 | 232 | test('should generate multi markup', t => { 233 | const markup = Markup.formatHTML('strike bold', [ 234 | { 235 | offset: 0, 236 | length: 6, 237 | type: 'strikethrough' 238 | }, 239 | { 240 | offset: 7, 241 | length: 4, 242 | type: 'bold' 243 | } 244 | ]) 245 | t.deepEqual(markup, 'strike bold') 246 | }) 247 | 248 | test('should generate nested markup', t => { 249 | const markup = Markup.formatHTML('test', [ 250 | { 251 | offset: 0, 252 | length: 4, 253 | type: 'bold' 254 | }, 255 | { 256 | offset: 0, 257 | length: 4, 258 | type: 'strikethrough' 259 | } 260 | ]) 261 | t.deepEqual(markup, 'test') 262 | }) 263 | 264 | test('should generate nested multi markup', t => { 265 | const markup = Markup.formatHTML('strikeboldunder', [ 266 | { 267 | offset: 0, 268 | length: 15, 269 | type: 'strikethrough' 270 | }, 271 | { 272 | offset: 6, 273 | length: 9, 274 | type: 'bold' 275 | }, 276 | { 277 | offset: 10, 278 | length: 5, 279 | type: 'underline' 280 | } 281 | ]) 282 | t.deepEqual(markup, 'strikeboldunder') 283 | }) 284 | 285 | test('should generate nested multi markup 2', t => { 286 | const markup = Markup.formatHTML('×11 22 333× ×С123456× ×1 22 333×', [ 287 | { 288 | offset: 1, 289 | length: 9, 290 | type: 'bold' 291 | }, 292 | { 293 | offset: 1, 294 | length: 9, 295 | type: 'italic' 296 | }, 297 | { 298 | offset: 12, 299 | length: 7, 300 | type: 'italic' 301 | }, 302 | { 303 | offset: 19, 304 | length: 36, 305 | type: 'italic' 306 | }, 307 | { 308 | offset: 19, 309 | length: 8, 310 | type: 'bold' 311 | } 312 | ]) 313 | t.deepEqual(markup, '×11 22 333× ×С123456× ×1 22 333×') 314 | }) 315 | 316 | test('should generate correct HTML with emojis', (t) => { 317 | const markup = Markup.formatHTML('👨‍👩‍👧‍👦underline 👩‍👩‍👦‍👦bold 👨‍👨‍👦‍👦italic', [ 318 | { 319 | offset: 0, 320 | length: 20, 321 | type: 'underline' 322 | }, 323 | { 324 | offset: 21, 325 | length: 15, 326 | type: 'bold' 327 | }, 328 | { 329 | offset: 37, 330 | length: 17, 331 | type: 'italic' 332 | } 333 | ]) 334 | t.deepEqual(markup, '👨‍👩‍👧‍👦underline 👩‍👩‍👦‍👦bold 👨‍👨‍👦‍👦italic') 335 | }) 336 | 337 | test('should generate correct HTML with HTML-reserved characters', (t) => { 338 | const markup = Markup.formatHTML('123', [{ offset: 1, length: 3, type: 'underline' }]) 339 | t.deepEqual(markup, '<b>123</b>') 340 | }) 341 | 342 | test('should escape HTML characters', (t) => { 343 | const markup = Markup.escapeHTML('<>&') 344 | t.deepEqual(markup, '<>&') 345 | }) 346 | 347 | test('should escape Markdown characters', (t) => { 348 | const markup = Markup.escapeMarkdown('_*[`') 349 | t.deepEqual(markup, '\\_\\*\\[\\`') 350 | }) 351 | 352 | test('should escape MarkdownV2 characters', (t) => { 353 | const markup = Markup.escapeMarkdownV2('_*[]()~`>#+-=|{}.!') 354 | t.deepEqual(markup, '\\_\\*\\[\\]\\(\\)\\~\\`\\>\\#\\+\\-\\=\\|\\{\\}\\.\\!') 355 | }) 356 | 357 | test('should escape HTML characters with template string', (t) => { 358 | const markup = Markup.HTML`${'<>&'}` 359 | t.deepEqual(markup, '<>&') 360 | }) 361 | 362 | test('should escape Markdown characters with template string', (t) => { 363 | const markup = Markup.md`${'_*[`'}` 364 | t.deepEqual(markup, '\\_\\*\\[\\`') 365 | }) 366 | 367 | test('should escape MarkdownV2 characters with template string', (t) => { 368 | const markup = Markup.mdv2`${'_*[]()~`>#+-=|{}.!'}` 369 | t.deepEqual(markup, '\\_\\*\\[\\]\\(\\)\\~\\`\\>\\#\\+\\-\\=\\|\\{\\}\\.\\!') 370 | }) 371 | -------------------------------------------------------------------------------- /src/core/exceptions.js: -------------------------------------------------------------------------------- 1 | const { TelegramError } = require('./error') 2 | /** 3 | @namespace Exceptions 4 | @description This namespace contains exception classes 5 | */ 6 | /** 7 | @memberOf Exceptions 8 | @extends TelegramError 9 | */ 10 | class BadRequest extends TelegramError {} 11 | /** 12 | @memberOf Exceptions 13 | @extends BadRequest 14 | */ 15 | class MessageNotModified extends BadRequest {} 16 | /** 17 | @memberOf Exceptions 18 | @extends BadRequest 19 | */ 20 | class MessageToForwardNotFound extends BadRequest {} 21 | /** 22 | @memberOf Exceptions 23 | @extends BadRequest 24 | */ 25 | class MessageIdInvalid extends BadRequest {} 26 | /** 27 | @memberOf Exceptions 28 | @extends BadRequest 29 | */ 30 | class MessageToDeleteNotFound extends BadRequest {} 31 | /** 32 | @memberOf Exceptions 33 | @extends BadRequest 34 | */ 35 | class MessageToPinNotFound extends BadRequest {} 36 | /** 37 | @memberOf Exceptions 38 | @extends BadRequest 39 | */ 40 | class MessageToReplyNotFound extends BadRequest {} 41 | /** 42 | @memberOf Exceptions 43 | @extends BadRequest 44 | */ 45 | class MessageIdentifierNotSpecified extends BadRequest {} 46 | /** 47 | @memberOf Exceptions 48 | @extends BadRequest 49 | */ 50 | class MessageTextIsEmpty extends BadRequest {} 51 | /** 52 | @memberOf Exceptions 53 | @extends BadRequest 54 | */ 55 | class MessageCantBeEdited extends BadRequest {} 56 | /** 57 | @memberOf Exceptions 58 | @extends BadRequest 59 | */ 60 | class MessageCantBeDeleted extends BadRequest {} 61 | /** 62 | @memberOf Exceptions 63 | @extends BadRequest 64 | */ 65 | class MessageCantBeForwarded extends BadRequest {} 66 | /** 67 | @memberOf Exceptions 68 | @extends BadRequest 69 | */ 70 | class MessageToEditNotFound extends BadRequest {} 71 | /** 72 | @memberOf Exceptions 73 | @extends BadRequest 74 | */ 75 | class MessageIsTooLong extends BadRequest {} 76 | /** 77 | @memberOf Exceptions 78 | @extends BadRequest 79 | */ 80 | class ToMuchMessages extends BadRequest {} 81 | /** 82 | @memberOf Exceptions 83 | @extends BadRequest 84 | */ 85 | class ObjectExpectedAsReplyMarkup extends BadRequest {} 86 | /** 87 | @memberOf Exceptions 88 | @extends BadRequest 89 | */ 90 | class InlineKeyboardExpected extends BadRequest {} 91 | /** 92 | @memberOf Exceptions 93 | @extends BadRequest 94 | */ 95 | class PollCantBeStopped extends BadRequest {} 96 | /** 97 | @memberOf Exceptions 98 | @extends BadRequest 99 | */ 100 | class PollHasAlreadyBeenClosed extends BadRequest {} 101 | /** 102 | @memberOf Exceptions 103 | @extends BadRequest 104 | */ 105 | class PollsCantBeSentToPrivateChats extends BadRequest {} 106 | /** 107 | @memberOf Exceptions 108 | @extends BadRequest 109 | */ 110 | class PollMustHaveMoreOptions extends BadRequest {} 111 | /** 112 | @memberOf Exceptions 113 | @extends BadRequest 114 | */ 115 | class PollCantHaveMoreOptions extends BadRequest {} 116 | /** 117 | @memberOf Exceptions 118 | @extends BadRequest 119 | */ 120 | class PollOptionsMustBeNonEmpty extends BadRequest {} 121 | /** 122 | @memberOf Exceptions 123 | @extends BadRequest 124 | */ 125 | class PollQuestionMustBeNonEmpty extends BadRequest {} 126 | /** 127 | @memberOf Exceptions 128 | @extends BadRequest 129 | */ 130 | class PollOptionsLengthTooLong extends BadRequest {} 131 | /** 132 | @memberOf Exceptions 133 | @extends BadRequest 134 | */ 135 | class PollQuestionLengthTooLong extends BadRequest {} 136 | /** 137 | @memberOf Exceptions 138 | @extends BadRequest 139 | */ 140 | class PollCanBeRequestedInPrivateChatsOnly extends BadRequest {} 141 | /** 142 | @memberOf Exceptions 143 | @extends BadRequest 144 | */ 145 | class MessageWithPollNotFound extends BadRequest {} 146 | /** 147 | @memberOf Exceptions 148 | @extends BadRequest 149 | */ 150 | class MessageIsNotAPoll extends BadRequest {} 151 | /** 152 | @memberOf Exceptions 153 | @extends BadRequest 154 | */ 155 | class ChatNotFound extends BadRequest {} 156 | /** 157 | @memberOf Exceptions 158 | @extends BadRequest 159 | */ 160 | class ChatIdIsEmpty extends BadRequest {} 161 | /** 162 | @memberOf Exceptions 163 | @extends BadRequest 164 | */ 165 | class InvalidUserId extends BadRequest {} 166 | /** 167 | @memberOf Exceptions 168 | @extends BadRequest 169 | */ 170 | class ChatDescriptionIsNotModified extends BadRequest {} 171 | /** 172 | @memberOf Exceptions 173 | @extends BadRequest 174 | */ 175 | class InvalidQueryID extends BadRequest {} 176 | /** 177 | @memberOf Exceptions 178 | @extends BadRequest 179 | */ 180 | class InvalidPeerID extends BadRequest {} 181 | /** 182 | @memberOf Exceptions 183 | @extends BadRequest 184 | */ 185 | class InvalidHTTPUrlContent extends BadRequest {} 186 | /** 187 | @memberOf Exceptions 188 | @extends BadRequest 189 | */ 190 | class ButtonURLInvalid extends BadRequest {} 191 | /** 192 | @memberOf Exceptions 193 | @extends BadRequest 194 | */ 195 | class URLHostIsEmpty extends BadRequest {} 196 | /** 197 | @memberOf Exceptions 198 | @extends BadRequest 199 | */ 200 | class StartParamInvalid extends BadRequest {} 201 | /** 202 | @memberOf Exceptions 203 | @extends BadRequest 204 | */ 205 | class ButtonDataInvalid extends BadRequest {} 206 | /** 207 | @memberOf Exceptions 208 | @extends BadRequest 209 | */ 210 | class FileIsTooBig extends BadRequest {} 211 | /** 212 | @memberOf Exceptions 213 | @extends BadRequest 214 | */ 215 | class WrongFileIdentifier extends BadRequest {} 216 | /** 217 | @memberOf Exceptions 218 | @extends BadRequest 219 | */ 220 | class GroupDeactivated extends BadRequest {} 221 | /** 222 | @memberOf Exceptions 223 | @extends BadRequest 224 | */ 225 | class PhotoAsInputFileRequired extends BadRequest {} 226 | /** 227 | @memberOf Exceptions 228 | @extends BadRequest 229 | */ 230 | class InvalidStickersSet extends BadRequest {} 231 | /** 232 | @memberOf Exceptions 233 | @extends BadRequest 234 | */ 235 | class NoStickerInRequest extends BadRequest {} 236 | /** 237 | @memberOf Exceptions 238 | @extends BadRequest 239 | */ 240 | class TooMuchStickersInSet extends BadRequest {} 241 | /** 242 | @memberOf Exceptions 243 | @extends BadRequest 244 | */ 245 | class ChatAdminRequired extends BadRequest {} 246 | /** 247 | @memberOf Exceptions 248 | @extends BadRequest 249 | */ 250 | class NeedAdministratorRightsInTheChannel extends BadRequest {} 251 | /** 252 | @memberOf Exceptions 253 | @extends BadRequest 254 | */ 255 | class NotEnoughRightsToPinMessage extends BadRequest {} 256 | /** 257 | @memberOf Exceptions 258 | @extends BadRequest 259 | */ 260 | class MethodNotAvailableInPrivateChats extends BadRequest {} 261 | /** 262 | @memberOf Exceptions 263 | @extends BadRequest 264 | */ 265 | class CantDemoteChatCreator extends BadRequest {} 266 | /** 267 | @memberOf Exceptions 268 | @extends BadRequest 269 | */ 270 | class CantRestrictSelf extends BadRequest {} 271 | /** 272 | @memberOf Exceptions 273 | @extends BadRequest 274 | */ 275 | class NotEnoughRightsToRestrict extends BadRequest {} 276 | /** 277 | @memberOf Exceptions 278 | @extends BadRequest 279 | */ 280 | class PhotoDimensions extends BadRequest {} 281 | /** 282 | @memberOf Exceptions 283 | @extends BadRequest 284 | */ 285 | class UnavailableMembers extends BadRequest {} 286 | /** 287 | @memberOf Exceptions 288 | @extends BadRequest 289 | */ 290 | class TypeOfFileMismatch extends BadRequest {} 291 | /** 292 | @memberOf Exceptions 293 | @extends BadRequest 294 | */ 295 | class WrongRemoteFileIdSpecified extends BadRequest {} 296 | /** 297 | @memberOf Exceptions 298 | @extends BadRequest 299 | */ 300 | class PaymentProviderInvalid extends BadRequest {} 301 | /** 302 | @memberOf Exceptions 303 | @extends BadRequest 304 | */ 305 | class CurrencyTotalAmountInvalid extends BadRequest {} 306 | /** 307 | @memberOf Exceptions 308 | @extends BadRequest 309 | */ 310 | class WebhookRequireHTTPS extends BadRequest {} 311 | /** 312 | @memberOf Exceptions 313 | @extends BadRequest 314 | */ 315 | class BadWebhookPort extends BadRequest {} 316 | /** 317 | @memberOf Exceptions 318 | @extends BadRequest 319 | */ 320 | class BadWebhookAddrInfo extends BadRequest {} 321 | /** 322 | @memberOf Exceptions 323 | @extends BadRequest 324 | */ 325 | class BadWebhookNoAddressAssociatedWithHostname extends BadRequest {} 326 | /** 327 | @memberOf Exceptions 328 | @extends BadRequest 329 | */ 330 | class CantParseUrl extends BadRequest {} 331 | /** 332 | @memberOf Exceptions 333 | @extends BadRequest 334 | */ 335 | class UnsupportedUrlProtocol extends BadRequest {} 336 | /** 337 | @memberOf Exceptions 338 | @extends BadRequest 339 | */ 340 | class CantParseEntities extends BadRequest {} 341 | /** 342 | @memberOf Exceptions 343 | @extends BadRequest 344 | */ 345 | class ResultIdDuplicate extends BadRequest {} 346 | /** 347 | @memberOf Exceptions 348 | @extends BadRequest 349 | */ 350 | class BotDomainInvalid extends BadRequest {} 351 | /** 352 | @memberOf Exceptions 353 | @extends BadRequest 354 | */ 355 | class MethodIsNotAvailable extends BadRequest {} 356 | /** 357 | @memberOf Exceptions 358 | @extends BadRequest 359 | */ 360 | class CantRestrictChatOwner extends BadRequest {} 361 | /** 362 | @memberOf Exceptions 363 | @extends BadRequest 364 | */ 365 | class UserIsAnAdministratorOfTheChat extends BadRequest {} 366 | /** 367 | @memberOf Exceptions 368 | @extends BadRequest 369 | */ 370 | class MethodNotKnown extends BadRequest {} 371 | /** 372 | @memberOf Exceptions 373 | @extends TelegramError 374 | */ 375 | class ConflictError extends TelegramError {} 376 | /** 377 | @memberOf Exceptions 378 | @extends ConflictError 379 | */ 380 | class TerminatedByOtherGetUpdates extends ConflictError {} 381 | /** 382 | @memberOf Exceptions 383 | @extends ConflictError 384 | */ 385 | class CantGetUpdates extends ConflictError {} 386 | /** 387 | @memberOf Exceptions 388 | @extends TelegramError 389 | */ 390 | class ForbiddenError extends TelegramError {} 391 | /** 392 | @memberOf Exceptions 393 | @extends ForbiddenError 394 | */ 395 | class BotKicked extends ForbiddenError {} 396 | /** 397 | @memberOf Exceptions 398 | @extends ForbiddenError 399 | */ 400 | class BotBlocked extends ForbiddenError {} 401 | /** 402 | @memberOf Exceptions 403 | @extends ForbiddenError 404 | */ 405 | class UserDeactivated extends ForbiddenError {} 406 | /** 407 | @memberOf Exceptions 408 | @extends ForbiddenError 409 | */ 410 | class CantInitiateConversation extends ForbiddenError {} 411 | /** 412 | @memberOf Exceptions 413 | @extends ForbiddenError 414 | */ 415 | class CantTalkWithBots extends ForbiddenError {} 416 | 417 | module.exports = { 418 | Exceptions: { 419 | BadRequest, 420 | MessageNotModified, 421 | MessageToForwardNotFound, 422 | MessageIdInvalid, 423 | MessageToDeleteNotFound, 424 | MessageToPinNotFound, 425 | MessageToReplyNotFound, 426 | MessageIdentifierNotSpecified, 427 | MessageTextIsEmpty, 428 | MessageCantBeEdited, 429 | MessageCantBeDeleted, 430 | MessageCantBeForwarded, 431 | MessageToEditNotFound, 432 | MessageIsTooLong, 433 | ToMuchMessages, 434 | ObjectExpectedAsReplyMarkup, 435 | InlineKeyboardExpected, 436 | PollCantBeStopped, 437 | PollHasAlreadyBeenClosed, 438 | PollsCantBeSentToPrivateChats, 439 | PollMustHaveMoreOptions, 440 | PollCantHaveMoreOptions, 441 | PollOptionsMustBeNonEmpty, 442 | PollQuestionMustBeNonEmpty, 443 | PollOptionsLengthTooLong, 444 | PollQuestionLengthTooLong, 445 | PollCanBeRequestedInPrivateChatsOnly, 446 | MessageWithPollNotFound, 447 | MessageIsNotAPoll, 448 | ChatNotFound, 449 | ChatIdIsEmpty, 450 | InvalidUserId, 451 | ChatDescriptionIsNotModified, 452 | InvalidQueryID, 453 | InvalidPeerID, 454 | InvalidHTTPUrlContent, 455 | ButtonURLInvalid, 456 | URLHostIsEmpty, 457 | StartParamInvalid, 458 | ButtonDataInvalid, 459 | FileIsTooBig, 460 | WrongFileIdentifier, 461 | GroupDeactivated, 462 | PhotoAsInputFileRequired, 463 | InvalidStickersSet, 464 | NoStickerInRequest, 465 | TooMuchStickersInSet, 466 | ChatAdminRequired, 467 | NeedAdministratorRightsInTheChannel, 468 | NotEnoughRightsToPinMessage, 469 | MethodNotAvailableInPrivateChats, 470 | CantDemoteChatCreator, 471 | CantRestrictSelf, 472 | NotEnoughRightsToRestrict, 473 | PhotoDimensions, 474 | UnavailableMembers, 475 | TypeOfFileMismatch, 476 | WrongRemoteFileIdSpecified, 477 | PaymentProviderInvalid, 478 | CurrencyTotalAmountInvalid, 479 | WebhookRequireHTTPS, 480 | BadWebhookPort, 481 | BadWebhookAddrInfo, 482 | BadWebhookNoAddressAssociatedWithHostname, 483 | CantParseUrl, 484 | UnsupportedUrlProtocol, 485 | CantParseEntities, 486 | ResultIdDuplicate, 487 | BotDomainInvalid, 488 | MethodIsNotAvailable, 489 | CantRestrictChatOwner, 490 | UserIsAnAdministratorOfTheChat, 491 | MethodNotKnown, 492 | ConflictError, 493 | TerminatedByOtherGetUpdates, 494 | CantGetUpdates, 495 | ForbiddenError, 496 | BotKicked, 497 | BotBlocked, 498 | UserDeactivated, 499 | CantInitiateConversation, 500 | CantTalkWithBots 501 | } 502 | } 503 | -------------------------------------------------------------------------------- /docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # Changelog 3 | 4 | # Opengram 0.5.0 5 | 6 | * 🤖 Bots API version increased from 6.8 to 6.9 7 | * Updated JSDoc annotations 8 | * Added `user_shared` and `chat_shared` subtype for `message` 9 | * Updated dependencies 10 | 11 | # Opengram 0.4.1 12 | 13 | * 🤖 Bots API version increased from 6.7 to 6.8 14 | * Added new method `unpinAllGeneralForumTopicMessages` for Telegram and Context classes 15 | * Updated JSDoc annotations 16 | * Updated dependencies 17 | 18 | # Opengram 0.3.1 19 | 20 | * 🤖 Bots API version increased from 6.6 to 6.7 21 | *
22 | ⚠️ BREAKING handlerTimeout behavior changed 23 | 24 | Previously in **Opengram 0.1.0 - 0.2.0-beta.1**, was added `TimeoutError`, which throwed if middleware chain executes 25 | more then `handlerTimeout`. 26 | 27 | From now, **Opengram** by default wait only 2000 ms before get new updates via polling or close webhook connectionю 28 | 29 | After **Opengram 0.3.0** handler timeout can be configured in 3 modes: 30 | ```js 31 | // For webhook closes webhook connection immedialtely 32 | // For polling - doesn't wait for updates processing and get new updates immediately 33 | const bot = new Opengram('...', { handlerTimeout: 0 }) 34 | 35 | // For webhook - waits N milliseconds and closes connection 36 | // For polling - waits N milliseconds and get new updates if prev not processed completely 37 | const bot = new Opengram('...', { handlerTimeout: 2000 }) 38 | 39 | // For webhook - waits for full update processing (but not recommened, because telegram repeat update after some timeout) 40 | // For polling - waits and get new updates only if all prev processed completely 41 | const bot = new Opengram('...', { handlerTimeout: Infinity }) 42 | ``` 43 | > ⚠️⚠️⚠️ **If you run your bot on serverless, you need to specify timeout in milliseconds or pass `Infinity` to prevent stopping code execution after closing webhook connection** 44 |
45 | *
46 | ⚠️ BREAKING ctx.botInfo now not available, use ctx.me (for context) and bot.username (for bot instance) to get bot username 47 | 48 | `getMe` called for every update, if `bot.username` not exists. 49 | 50 | If you use `bot.handleUpdate(s)`, set `bot.username` or pass `username` into `Opengram` constructor options, to prevent many `getMe` calls and problems with API Limits, example: 51 | 52 | ```javascript 53 | const bot = new Opengram(token, { username: 'botusername' }) // Via Opengram options 54 | bot.username = 'botusername' // Via username setter 55 | // ... 56 | bot.handleUpdate(...) 57 | // ... 58 | ``` 59 | 60 |
61 | 62 | *
63 | ❌ Add exception classes for common bot API errors. 64 | 65 | Now you can check what error occurred, **Opengram** provides ~82 classes for Bots API exceptions, which 66 | extends from common base exception types: 67 | * `BadRequest` - **HTTP 400** 68 | * `ConflictError` **HTTP 409** 69 | * `ForbiddenError` - **HTTP 403** 70 | 71 | Every basic exception class extends `TelegramError` class. 72 | 73 | For example, if you want to check is `sendMessage` returns error like this: 74 | 75 | ```js 76 | { 77 | response: { 78 | ok: false, 79 | error_code: 403, 80 | description: 'Forbidden: bot was blocked by the user' 81 | } 82 | } 83 | ``` 84 | You can use: 85 | 86 | ```js 87 | const { TelegramError, Exceptions: { BotBlocked, ForbiddenError } } = require('opengram') 88 | 89 | try { 90 | await ctx.telegram.sendMessage(...) 91 | } catch (err) { 92 | console.log(err instanceof TelegramError) // true 93 | console.log(err instanceof ForbiddenError) // true 94 | console.log(err instanceof BotBlocked) // true 95 | } 96 | ``` 97 | 98 | All unknown errors that don't match the http code or don't have a class can be checked as `TelegramError`: 99 | 100 | ```js 101 | bot.catch((err, ctx) => { 102 | if (err instanceof TelegramError) { 103 | console.log('Telegram returns error: ', err) 104 | return 105 | } 106 | 107 | throw err // throw unknown errors 108 | }) 109 | ``` 110 |
111 | 112 | *
113 | 📝 Added escape methods for Markdown, MarkdownV2, HTML to Markup class 114 | 115 | Now you can use this methods for escaping user input data and etc, example: 116 | ```js 117 | const { Markup, Markup: { HTML, md, mdv2} } = require('opengram') 118 | 119 | //... 120 | // Using methods 121 | bot.on('message', ctx => ctx.reply('User name:: ' + Markup.escapeHTML(ctx.from.first_name))) 122 | bot.on('message', ctx => ctx.reply('*User name:*: ' + Markup.escapeMarkdownV2(ctx.from.first_name))) 123 | bot.on('message', ctx => ctx.reply('*User name:*: ' + Markup.escapeMarkdown(ctx.from.first_name))) 124 | 125 | // Using template strings 126 | bot.on('message', ctx => ctx.reply(HTML`User name:: ${ctx.from.first_name}`)) 127 | bot.on('message', ctx => ctx.reply(md`*User name:*: ${ctx.from.first_name}`)) 128 | bot.on('message', ctx => ctx.reply(mdv2`*User name:*: ${ctx.from.first_name}`)) 129 | ``` 130 | 131 |
132 | --- 133 | 134 | # Opengram 0.1.0 - 0.2.0-beta.1 135 | 136 | #### Bots API (5.0-6.6) 137 | 138 | 139 | ##### 5.0 140 | 141 | * Added extra parameter's argument for `deleteWebhook` 142 | * ⚠️ BREAKING Changed setWebhook method arguments 143 | 144 | ```javascript 145 | // Previously the syntax was 146 | setWebhook (url, certificate, maxConnections, allowedUpdates) 147 | //Now: (check docs for more info) 148 | setWebhook (webhookOptions, extra) 149 | ``` 150 | 151 | * Added `dropPendingUpdates` , `ip_address` options for `Opengram#launch` method. 152 | * Added extra parameter's argument for `unbanChatMember`, `unbanChatMember`, `editMessageLiveLocation` 153 | * Added method `unpinAllChatMessages` 154 | 155 |
156 | 5.1 157 | 158 | * Added support for `my_chat_member`, `chat_member`, `message_auto_delete_timer_changed`, `voice_chat_started`, `voice_chat_ended`, `voice_chat_participants_invited` events 159 | * Added `createChatInviteLink`, `editChatInviteLink`, `revokeChatInviteLink` methods 160 | * Added `myChatMember`, `chatMember` getters for Context 161 |
162 | 163 |
164 | 5.2 165 | 166 | * Added support for `voice_chat_participants_invited`, `voice_chat_scheduled` event 167 |
168 | 169 |
170 | 5.3 171 | 172 | * Added `inputFieldPlaceholder` for Markup class 173 | * `deleteMyCommands`, `banChatMember`(`kickChatMember` marked as deprecated), `getChatMemberCount` (`getChatMembersCount` marked ad deprecated) 174 | * Added extra parameter's argument for `setMyCommands`, `getMyCommands` 175 |
176 | 177 |
178 | 5.4 179 | 180 | * Added support for `chat_join_request` event 181 | * Added `chatJoinRequest` getter for Context 182 | * Added `approveChatJoinRequest`, `declineChatJoinRequest` methods 183 |
184 | 185 |
186 | 5.5 187 | 188 | * Added `banChatSenderChat`, `unbanChatSenderChat` method 189 | * Added support for `spoiler` entity 190 |
191 | 192 |
193 | 5.6 194 | 195 | * Added the parameter protect_content to the methods `sendMessage`, `sendPhoto`, `sendVideo`, `sendAnimation`, `sendAudio`, 196 | `sendDocument`, `sendSticker`, `sendVideoNote`, `sendVoice`, `sendLocation`, `sendVenue`, `sendContact`, `sendPoll`, 197 | `sendDice`, `sendInvoice`, `sendGame`, `sendMediaGroup`, `copyMessage`, `forwardMessage`, `reply`, `replyWithHTML`, 198 | `replyWithMarkdown`, `replyWithMarkdownV2` to allow sending messages with protected content to any chat. 199 | * Added support for spoiler entities 200 |
201 | 202 |
203 | 5.7 204 | 205 | * Added the parameter webm_sticker to the methods `createNewStickerSet` and `addStickerToSet`. 206 |
207 | 208 |
209 | 6.0 210 | 211 | * Added web app button for keyboard & inline-keyboard 212 | * Added `answerWebAppQuery` for sending an answer to a Web App query, which originated from an inline button of the 'web_app' type. 213 | * Added event `web_app_data` 214 | * Added methods `setChatMenuButton` and `getChatMenuButton` for managing the behavior of the bots menu button in private chats. 215 | * Added methods `setMyDefaultAdministratorRights` and `getMyDefaultAdministratorRights` for managing the bots default administrator rights. 216 | * Added support for t.me links that can be used to add the bot to groups and channels as an administrator. 217 |
218 | 219 |
220 | 6.1 221 | 222 | * Added the method `createInvoiceLink` to generate an HTTP link for an invoice. 223 | * Added `secret_token` support for webhooks. 224 |
225 | 226 |
227 | 6.2 228 | 229 | * Added support for `custom_emoji` message entity 230 | * Added the method `getCustomEmojiStickers`. 231 |
232 | 233 |
234 | 6.3 235 | 236 | * Added auto reference `message_thread_id` for context methods `reply`, `replyWithPhoto`, `replyWithMediaGroup`, 237 | `replyWithAudio`, `replyWithDice`, `replyWithDocument`, `replyWithSticker`, `replyWithVideo`, `replyWithAnimation`, 238 | `replyWithVideoNote`, `replyWithInvoice`, `replyWithGame`, `replyWithVoice`, `replyWithPoll`, `replyWithQuiz`, 239 | `replyWithChatAction`, `replyWithLocation`, `replyWithVenue`, `replyWithContact` 240 | * Added the methods `createForumTopic`, `editForumTopic`, `closeForumTopic`, `reopenForumTopic`, `deleteForumTopic`, 241 | `unpinAllForumTopicMessages`, and `getForumTopicIconStickers` for forum topic management. 242 |
243 | 244 |
245 | 6.4 246 | 247 | * Added auto reference `message_thread_id` for context method `sendChatAction` 248 |
249 | 250 |
251 | 6.5 252 | 253 | * Added keyboard buttons `userRequest`, `botRequest`, `groupRequest`, `channelRequest` 254 |
255 | 256 |
257 | 6.6 258 | 259 | * Added methods `setMyDescription`, `getMyDescription`, `setMyShortDescription`, `getMyShortDescription` 260 | 261 | * Added the method setCustomEmojiStickerSetThumbnail for editing the thumbnail of custom emoji sticker sets created by the bot. 262 | * Added the method setStickerSetTitle for editing the title of sticker sets created by the bot. 263 | * Added the method deleteStickerSet for complete deletion of a given sticker set that was created by the bot. 264 | * Added the method setStickerEmojiList for changing the list of emoji associated with a sticker. 265 | * Added the method setStickerKeywords for changing the search keywords assigned to a sticker. 266 | * Added the method setStickerMaskPosition for changing the mask position of a mask sticker. 267 | * Added `setStickerSetThumbnail`, `setStickerSetThumb` now **deprecated** 268 |
269 | 270 | #### Other 271 | 272 | #### 2.3.1 273 | 274 | Added `max_connections` for `Opengram#launch` 275 | 276 | #### 4.5 277 | 278 | Added `MarkdownV2` support for `Extra` 279 | 280 | #### Opengram 281 | 282 | ⚠️ BREAKING 283 | 284 | Now `ctx.botInfo` **required**. **(⚠️ `ctx.botInfo` removed in Opengram 0.3.0 check changelog)** 285 | 286 | Previously `ctx.botInfo` / `ctx.me` only was available when a bot started with `Opengram.launch()` or when you passed a bot username in `Opengram` options.  287 | 288 | Now `getMe` called for every update, if `ctx.botInfo` not exists. 289 | 290 | If you use `bot.handleUpdate(s)`, you should add Opengram.botInfo data, to prevent many calls `getMe` and problems with API Limits, example: 291 | ```javascript 292 | const bot = new Opengram(token, {}) 293 | bot.context.botInfo = { username: 'mybot' } 294 | // ... 295 | bot.handleUpdate(...) 296 | // ... 297 | ``` 298 | --- 299 | 300 | **⚠️ BREAKING** 301 | 302 | Fully remove support for passing keyboard directly to extra parameters, it was not fully removed for `editMessageCaption`, `editMessageMedia`, but removed for others in Telegraf 3.38, now it is not available: 303 | 304 | ```js 305 | // Before that, you could do this 306 | ctx.editMessageCaption( 307 | 'Forgetting is like a wound. The wound may heal, but it has already left a - scar.', 308 | Markup.inlinekeybaord(...) 309 | ) 310 | 311 | // Now only: 312 | ctx.editMessageCaption( 313 | 'Forgetting is like a wound. The wound may heal, but it has already left a - scar.', 314 | Markup.inlinekeybaord(...).extra() 315 | ) 316 | 317 | // Or 318 | 319 | ctx.editMessageCaption( 320 | 'Forgetting is like a wound. The wound may heal, but it has already left a - scar.', 321 | Extra.markup(Markup.inlinekeybaord(...)) 322 | ) 323 | ``` 324 | 325 | --- 326 | 327 | * Added `entities`, `captionEntities` methods for adding entities with `Extra` 328 | * Added enter middleware for Wizard scenes, now you can use `scene.enter(ctx => ...)` like in **Base** scenes 329 | * Fixed `formatHTML` with emoji 330 | * Fixed `formatHTML` to work with HTML-reserved characters 331 | * Fix `telegram.getFileLink` with local bot API instances 332 | * **⚠️ BREAKING** Webhook methods blacklist replaced with a whitelist 333 | * Fixed infinity recursion in scenes. For example: 334 | 335 | ```javascript 336 | // Enter calling leave, and it's emit new leave event and get into recursion 337 | scene.leave(ctx => ctx.scene.enter('name')) 338 | ``` 339 | 340 | --- 341 | 342 | Rewrite handlerTimeout. **(⚠️ `handlerTimeout` behavior changed, `TimeoutError` removed in Opengram 0.3.0 check changelog)** 343 | 344 | Now `TimeoutError` an error thrown when timeout. Previously, when a timeout, when the timeout expired, long-polling doesn't await processing all updates and gets new updates. `handlerTimeout` is part of long-polling backpressure, when your handler processed very long time it's **very bad**. For webhook, **it can create some problems with webhook reply** and etc. It was decided to toughen up this design to **avoid bigger problems.** **However, of course, you can handle this error in** `bot.catch` **like any other.** 345 | 346 | --- 347 | 348 | * Added test environment support, see [Bots API 6.0 changes](https://core.telegram.org/bots/webapps#using-bots-in-the-test-environment) for mare information 349 | * Added `apiPrefix` option for using with [TDLight](https://github.com/tdlight-team/tdlight) 350 | * Fixed `stopCallback` calling twice, in some cases it was being called twice 351 | * **⚠️ BREAKING** Changed default webhook path `/telegraf/...` to `/opengram/...` 352 | * Fixed error swallowing when starting the bot. Sometimes, when `Opengram.launch()` calls the API method and gets an error, it was displayed in the console, and the bot would freeze, it would not receive an update, was not really launched and did not crash - "Bot started, but not answers to events" Commit for more info - 90c2012 353 | * Fixed `Composer.match` for channel updates. Now `Composer::hears`, `Composer::action`, `Composer::inlineQuery` works for channels too 354 | * Added `TelegramError` export 355 | * Added redact for hide token from error messages 356 | * Fixed `startPayload` for commands with username 357 | * Wizard Scenes now extends `BaseScene` 358 | * Added `Scenes` object to exports 359 | * Added `isTelegramError` and exported 360 | * Added `Composer.customEmoji` 361 | * Added `anyMessage`, `anyText`, `anyEntities` getters in OpengramContext 362 | * Added warning when accessing session object after save 363 | * Added warning accessing session object when session key not available 364 | 365 | #### General 366 | * JSDoc annotated code 367 | * Update to Bot API 6.6 368 | * Added support for cancelling requests via `AbortSignal` in `Telegram.callApi()` / `Context` & `Telegram` methods 369 | * Add `attachmentAgent` option to provide an agent that is used for fetching files from the web before they are sent to Telegram (previously done through the same `agent` as used to connect to Telegram itself) 370 | * Sessions rewrote and refactored 371 | * Increased tests coverage 372 | * Code / performance improvements 373 | * Fixed a lot of bugs 374 | -------------------------------------------------------------------------------- /src/core/network/client.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('opengram:client') 2 | const crypto = require('crypto') 3 | const fetch = require('node-fetch').default 4 | const fs = require('fs') 5 | const https = require('https') 6 | const path = require('path') 7 | const util = require('util') 8 | const { TelegramError } = require('../error') 9 | const MultipartStream = require('./multipart-stream') 10 | const { compactOptions } = require('../helpers/compact') 11 | const { matchExceptionType, exceptionsHTTPCodes } = require('../exeptionsList') 12 | const { Exceptions } = require('../exceptions') 13 | const { isStream } = MultipartStream 14 | 15 | const WEBHOOK_REPLY_METHOD_ALLOWLIST = new Set([ 16 | 'setWebhook', 'deleteWebhook', 'sendChatAction', 'answerInlineQuery', 'setChatPermissions', 'banChatMember', 17 | 'promoteChatMember', 'restrictChatMember', 'banChatSenderChat', 'unbanChatSenderChat', 'unpinAllGeneralForumTopicMessages', 18 | 'setChatAdministratorCustomTitle', 'setChatPhoto', 'deleteChatPhoto', 'setChatTitle', 'setChatDescription', 19 | 'pinChatMessage', 'unpinChatMessage', 'unpinAllChatMessages', 'setChatMenuButton', 'setMyDefaultAdministratorRights', 20 | 'leaveChat', 'unbanChatMember', 'answerCallbackQuery', 'answerShippingQuery', 'answerPreCheckoutQuery', 21 | 'deleteMessage', 'setChatStickerSet', 'deleteChatStickerSet', 'editForumTopic', 'editGeneralForumTopic', 22 | 'closeGeneralForumTopic', 'reopenGeneralForumTopic', 'hideGeneralForumTopic', 'unhideGeneralForumTopic', 23 | 'closeForumTopic', 'reopenForumTopic', 'deleteForumTopic', 'unpinAllForumTopicMessages', 'createNewStickerSet', 24 | 'addStickerToSet', 'setStickerPositionInSet', 'setStickerSetThumb', 'deleteStickerFromSet', 'setMyCommands', 25 | 'deleteMyCommands', 'setPassportDataErrors', 'approveChatJoinRequest', 'declineChatJoinRequest', 'setMyName', 26 | 'setMyDescription', 'setMyShortDescription', 'setCustomEmojiStickerSetThumbnail', 'setStickerSetTitle', 27 | 'deleteStickerSet', 'setStickerEmojiList', 'setStickerKeywords', 'setStickerMaskPosition' 28 | ]) 29 | 30 | const DEFAULT_EXTENSIONS = { 31 | audio: 'mp3', 32 | photo: 'jpg', 33 | sticker: 'webp', 34 | video: 'mp4', 35 | animation: 'mp4', 36 | video_note: 'mp4', 37 | voice: 'ogg' 38 | } 39 | 40 | const DEFAULT_OPTIONS = { 41 | apiRoot: 'https://api.telegram.org', 42 | apiPrefix: 'bot', 43 | webhookReply: true, 44 | agent: new https.Agent({ 45 | keepAlive: true, 46 | keepAliveMsecs: 10000 47 | }), 48 | attachmentAgent: undefined, 49 | testEnv: false 50 | } 51 | 52 | /** @type {WebhookResponse} */ 53 | const WEBHOOK_REPLY_STUB = { 54 | webhook: true, 55 | details: 'https://core.telegram.org/bots/api#making-requests-when-getting-updates' 56 | } 57 | 58 | function createError (err, on) { 59 | const type = matchExceptionType(err) 60 | if (type) { 61 | return new Exceptions[type](err, on) 62 | } 63 | 64 | if (err.error_code) { 65 | const type = exceptionsHTTPCodes[err.error_code] 66 | 67 | if (type) { 68 | return new Exceptions[type](err, on) 69 | } 70 | } 71 | 72 | return new TelegramError(err, on) 73 | } 74 | 75 | // eslint-disable-next-line jsdoc/require-throws 76 | /** 77 | * Hides bot token in request errors 78 | * 79 | * @private 80 | * @param {object} error JSON to parse 81 | * @return {object} 82 | */ 83 | function redactToken (error) { 84 | error.message = error.message.replace( 85 | /(\d+):[^/]+\//, 86 | '/$1:[REDACTED]/' 87 | ) 88 | throw error 89 | } 90 | 91 | /** 92 | * Parsing JSON without error throw if invalid 93 | * 94 | * @private 95 | * @param {string} text JSON to parse 96 | * @return {object|void} 97 | */ 98 | function safeJSONParse (text) { 99 | try { 100 | return JSON.parse(text) 101 | } catch (err) { 102 | debug('JSON parse failed', err) 103 | } 104 | } 105 | 106 | /** 107 | * Checks objects for media in props 108 | * 109 | * @private 110 | * @param {object} payload Parameters object to check 111 | * @return {boolean} 112 | */ 113 | function includesMedia (payload) { 114 | return Object.keys(payload).some( 115 | (key) => { 116 | const value = payload[key] 117 | if (Array.isArray(value)) { 118 | return value.some(({ media }) => media && typeof media === 'object' && (media.source || media.url)) 119 | } 120 | return (typeof value === 'object') && ( 121 | value.source || 122 | value.url || 123 | (typeof value.media === 'object' && (value.media.source || value.media.url)) 124 | ) 125 | } 126 | ) 127 | } 128 | 129 | /** 130 | * Creates config object for API calls which not contains media with given payload 131 | * 132 | * @private 133 | * @param {object} payload Parameters object 134 | * @return {Promise<{headers: {'content-type': string, connection: string}, method: string, compress: boolean, body: 135 | * string}>} 136 | */ 137 | function buildJSONConfig (payload) { 138 | return Promise.resolve({ 139 | method: 'POST', 140 | compress: true, 141 | headers: { 'content-type': 'application/json', connection: 'keep-alive' }, 142 | body: JSON.stringify(payload) 143 | }) 144 | } 145 | 146 | const FORM_DATA_JSON_FIELDS = [ 147 | 'results', 148 | 'reply_markup', 149 | 'mask_position', 150 | 'shipping_options', 151 | 'errors' 152 | ] 153 | 154 | /** 155 | * Creates config object for API calls which contains media with given payload 156 | * 157 | * @private 158 | * @param {object} payload Parameters object 159 | * @param {http.Agent} [agent] HTTP Agent 160 | * @return {Promise<{headers: {'content-type': string, connection: string}, method: string, compress: boolean, body: 161 | * MultipartStream}>} 162 | */ 163 | async function buildFormDataConfig (payload, agent) { 164 | for (const field of FORM_DATA_JSON_FIELDS) { 165 | if (field in payload && typeof payload[field] !== 'string') { 166 | payload[field] = JSON.stringify(payload[field]) 167 | } 168 | } 169 | const boundary = crypto.randomBytes(32).toString('hex') 170 | const formData = new MultipartStream(boundary) 171 | const tasks = Object.keys(payload) 172 | .map((key) => attachFormValue(formData, key, payload[key], agent)) 173 | await Promise.all(tasks) 174 | 175 | return { 176 | method: 'POST', 177 | compress: true, 178 | headers: { 'content-type': `multipart/form-data; boundary=${boundary}`, connection: 'keep-alive' }, 179 | body: formData 180 | } 181 | } 182 | 183 | /** 184 | * Used to attach primitive values & media to form 185 | * 186 | * @param {MultipartStream} form MultipartStream instance 187 | * @param {*} id Form field name 188 | * @param {string|boolean|number|object} value Value to attach 189 | * @param {http.Agent} [agent] HTTP Agent 190 | * @return {Promise} 191 | */ 192 | async function attachFormValue (form, id, value, agent) { 193 | if (!value) { 194 | return 195 | } 196 | 197 | const valueType = typeof value 198 | 199 | if (valueType === 'string' || valueType === 'boolean' || valueType === 'number') { 200 | form.addPart({ 201 | headers: { 'content-disposition': `form-data; name="${id}"` }, 202 | body: `${value}` 203 | }) 204 | return 205 | } 206 | 207 | if (id === 'thumb') { 208 | const attachmentId = crypto.randomBytes(16).toString('hex') 209 | await attachFormMedia(form, value, attachmentId, agent) 210 | form.addPart({ 211 | headers: { 'content-disposition': `form-data; name="${id}"` }, 212 | body: `attach://${attachmentId}` 213 | }) 214 | return 215 | } 216 | 217 | if (Array.isArray(value)) { 218 | const items = await Promise.all( 219 | value.map(async item => { 220 | if (typeof item.media !== 'object') { 221 | return item 222 | } 223 | const attachmentId = crypto.randomBytes(16).toString('hex') 224 | await attachFormMedia(form, item.media, attachmentId, agent) 225 | return { ...item, media: `attach://${attachmentId}` } 226 | }) 227 | ) 228 | 229 | form.addPart({ 230 | headers: { 'content-disposition': `form-data; name="${id}"` }, 231 | body: JSON.stringify(items) 232 | }) 233 | 234 | return 235 | } 236 | 237 | if (typeof value.media !== 'undefined' && typeof value.type !== 'undefined') { 238 | const attachmentId = crypto.randomBytes(16).toString('hex') 239 | await attachFormMedia(form, value.media, attachmentId, agent) 240 | form.addPart({ 241 | headers: { 'content-disposition': `form-data; name="${id}"` }, 242 | body: JSON.stringify({ 243 | ...value, 244 | media: `attach://${attachmentId}` 245 | }) 246 | }) 247 | return 248 | } 249 | return attachFormMedia(form, value, id, agent) 250 | } 251 | 252 | /** 253 | * @typedef {object} FileToAttach 254 | * @property {string} [url] URL of file 255 | * @property {string} filename Name of file 256 | * @property {Stream|string|Buffer} source Path to file / Stream / Buffer 257 | */ 258 | 259 | /** 260 | * Used to attach media to form 261 | * 262 | * @param {MultipartStream} form MultipartStream instance 263 | * @param {string|boolean|number|FileToAttach} media Value to attach 264 | * @param {*} id Form field name 265 | * @param {http.Agent} [agent] HTTP Agent 266 | * @return {Promise} 267 | */ 268 | async function attachFormMedia (form, media, id, agent) { 269 | let fileName = media.filename || `${id}.${DEFAULT_EXTENSIONS[id] || 'dat'}` 270 | if (media.url !== undefined) { 271 | const res = await fetch(media.url, { agent }) 272 | form.addPart({ 273 | headers: { 'content-disposition': `form-data; name="${id}"; filename="${fileName}"` }, 274 | body: res.body 275 | }) 276 | return 277 | } 278 | 279 | if (media.source) { 280 | if (fs.existsSync(media.source)) { 281 | fileName = media.filename || path.basename(media.source) 282 | media.source = fs.createReadStream(media.source) 283 | } 284 | 285 | if (isStream(media.source) || Buffer.isBuffer(media.source)) { 286 | form.addPart({ 287 | headers: { 'content-disposition': `form-data; name="${id}"; filename="${fileName}"` }, 288 | body: media.source 289 | }) 290 | } 291 | } 292 | } 293 | 294 | /** 295 | * Checking if response object belongs to KoaJs 296 | * 297 | * @private 298 | * @param {http.ServerResponse} response Response object 299 | * @return {boolean} 300 | */ 301 | function isKoaResponse (response) { 302 | return typeof response.set === 'function' && typeof response.header === 'object' 303 | } 304 | 305 | /** 306 | * @typedef {object} AnswerToWebhookOptions 307 | * @property {http.Agent} [attachmentAgent] HTTP Agent used for attachments 308 | */ 309 | 310 | /** 311 | * Answers to webhook 312 | * 313 | * @private 314 | * @param {http.ServerResponse} response Server response object 315 | * @param {object} payload Payload for API request 316 | * @param {object} options Options 317 | * @return {Promise} 318 | */ 319 | async function answerToWebhook (response, payload = {}, options) { 320 | if (!includesMedia(payload)) { 321 | if (isKoaResponse(response)) { 322 | response.body = payload 323 | return WEBHOOK_REPLY_STUB 324 | } 325 | 326 | if (!response.headersSent) { 327 | response.setHeader('content-type', 'application/json') 328 | } 329 | 330 | const responseEnd = util.promisify(response.end) 331 | 332 | // Function.length returns count of arguments 333 | // If arguments count equals 2, callback not available, return immediately 334 | if (response.end.length === 2) { 335 | response.end(JSON.stringify(payload), 'utf-8') 336 | return WEBHOOK_REPLY_STUB 337 | } 338 | 339 | // If callback available, wait 340 | await responseEnd.call(response, JSON.stringify(payload), 'utf-8') 341 | return WEBHOOK_REPLY_STUB 342 | } 343 | 344 | const { headers, body } = await buildFormDataConfig(payload, options.attachmentAgent) 345 | 346 | if (isKoaResponse(response)) { 347 | Object.keys(headers).forEach(key => response.set(key, headers[key])) 348 | response.body = body 349 | return WEBHOOK_REPLY_STUB 350 | } 351 | 352 | if (!response.headersSent) { 353 | Object.keys(headers).forEach(key => response.setHeader(key, headers[key])) 354 | } 355 | 356 | await new Promise(resolve => { 357 | response.on('finish', resolve) 358 | body.pipe(response) 359 | }) 360 | 361 | return WEBHOOK_REPLY_STUB 362 | } 363 | 364 | /** 365 | * The API client class implements a raw api call via http requests & webhook reply 366 | */ 367 | class ApiClient { 368 | /** 369 | * @param {string} token Bot token 370 | * @param {TelegramOptions} [options] Options 371 | * @param {http.ServerResponse} [webhookResponse] Response object from HTTP server for reply via webhook if enabled 372 | */ 373 | constructor (token, options, webhookResponse) { 374 | this.token = token 375 | this.options = { 376 | ...DEFAULT_OPTIONS, 377 | ...compactOptions(options) 378 | } 379 | 380 | if (this.options.apiRoot.startsWith('http://')) { 381 | this.options.agent = null 382 | } 383 | 384 | this.response = webhookResponse 385 | } 386 | 387 | /** 388 | * Setter for webhookReply 389 | * 390 | * Use this property to control reply via webhook feature. 391 | * 392 | * @param {boolean} enable Value 393 | * @return {void} 394 | */ 395 | set webhookReply (enable) { 396 | this.options.webhookReply = enable 397 | } 398 | 399 | /** 400 | * Getter for webhookReply 401 | * 402 | * Use this property to control reply via webhook feature. 403 | * 404 | * @return {boolean} 405 | */ 406 | get webhookReply () { 407 | return this.options.webhookReply 408 | } 409 | 410 | /** 411 | * @typedef {object} CallApiExtra 412 | * @property {AbortSignal} signal Optional `AbortSignal` to cancel the request 413 | */ 414 | 415 | /** 416 | * Method for direct call telegram bots api methods 417 | * 418 | * Takes an optional `AbortSignal` object that allows to cancel the API call if desired. 419 | * 420 | * For example: 421 | * ```js 422 | * const controller = new AbortController(); 423 | * const signal = controller.signal; 424 | * ctx.telegram.callApi ('getMe', {}, { signal }) 425 | * .then(console.log) 426 | * .catch(err => { 427 | * if (err instanceof AbortError) { 428 | * console.log('API call aborted') 429 | * } else throw err 430 | * }) 431 | * 432 | * controller.abort(); // Abort request 433 | * ``` 434 | * [Read more about request aborts](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal) 435 | * 436 | * @param {string} method Telegram API method name 437 | * @param {object} [data] Object with method parameters 438 | * @param {CallApiExtra} [extra] Extra parameters 439 | * @return {Promise} 440 | */ 441 | async callApi (method, data = {}, extra = {}) { 442 | const { token, options, response, responseEnd } = this 443 | 444 | const payload = Object.keys(data) 445 | .filter((key) => typeof data[key] !== 'undefined' && data[key] !== null) 446 | .reduce((acc, key) => ({ ...acc, [key]: data[key] }), {}) 447 | 448 | if (options.webhookReply && response && !responseEnd && WEBHOOK_REPLY_METHOD_ALLOWLIST.has(method)) { 449 | debug('Call via webhook', method, payload) 450 | this.responseEnd = true 451 | return await answerToWebhook(response, { method, ...payload }, options) 452 | } 453 | 454 | if (!token) { 455 | throw createError({ error_code: 401, description: 'Bot Token is required' }) 456 | } 457 | 458 | debug('HTTP call', method, payload) 459 | const config = includesMedia(payload) 460 | ? await buildFormDataConfig({ method, ...payload }, options.attachmentAgent) 461 | : await buildJSONConfig(payload) 462 | 463 | const apiUrl = new URL( 464 | `./${this.options.apiPrefix}${token}${options.testEnv ? '/test' : ''}/${method}`, 465 | options.apiRoot 466 | ) 467 | config.agent = options.agent 468 | config.signal = extra.signal 469 | const res = await fetch(apiUrl, config).catch(redactToken) 470 | 471 | if (res.status >= 500) { 472 | const errorPayload = { 473 | error_code: res.status, 474 | description: res.statusText 475 | } 476 | throw createError(errorPayload, { method, payload }) 477 | } 478 | 479 | const text = await res.text() 480 | const responseData = safeJSONParse(text) ?? { 481 | error_code: 500, 482 | description: 'Unsupported http response from Telegram', 483 | response: text 484 | } 485 | 486 | if (!responseData.ok) { 487 | debug('API call failed', responseData) 488 | throw createError(responseData, { method, payload }) 489 | } 490 | return responseData.result 491 | } 492 | } 493 | 494 | module.exports = ApiClient 495 | -------------------------------------------------------------------------------- /test/opengram.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const { session } = require('../') 3 | const { createBot } = require('./utils') 4 | 5 | class MockResponse { 6 | constructor () { 7 | this.writableEnded = false 8 | this.statusCode = 200 9 | } 10 | 11 | setHeader () {} 12 | end (body, encoding, cb) { 13 | this.writableEnded = true 14 | this.body = body 15 | cb && cb() 16 | } 17 | } 18 | 19 | class MockRequest { 20 | constructor (path, method, headers, data) { 21 | this._path = path 22 | this._method = method 23 | this._headers = headers 24 | this._data = data 25 | } 26 | 27 | get url () { 28 | return this._path 29 | } 30 | 31 | get headers () { 32 | return this._headers 33 | } 34 | 35 | get body () { 36 | return this._data 37 | } 38 | 39 | get method () { 40 | return this._method 41 | } 42 | } 43 | 44 | const BaseTextMessage = { 45 | chat: { id: 1 }, 46 | text: 'foo' 47 | } 48 | 49 | const UpdateTypes = [ 50 | { type: 'shipping_query', prop: 'shippingQuery', update: { shipping_query: {} } }, 51 | { type: 'message', prop: 'message', update: { message: BaseTextMessage } }, 52 | { type: 'edited_message', prop: 'editedMessage', update: { edited_message: BaseTextMessage } }, 53 | { type: 'callback_query', prop: 'callbackQuery', update: { callback_query: { message: BaseTextMessage } } }, 54 | { type: 'inline_query', prop: 'inlineQuery', update: { inline_query: {} } }, 55 | { type: 'channel_post', prop: 'channelPost', update: { channel_post: BaseTextMessage } }, 56 | { type: 'pre_checkout_query', prop: 'preCheckoutQuery', update: { pre_checkout_query: {} } }, 57 | { type: 'edited_channel_post', prop: 'editedChannelPost', update: { edited_channel_post: {} } }, 58 | { type: 'chosen_inline_result', prop: 'chosenInlineResult', update: { chosen_inline_result: {} } }, 59 | { type: 'poll', prop: 'poll', update: { poll: {} } }, 60 | { type: 'poll_answer', prop: 'pollAnswer', update: { poll_answer: {} } }, 61 | { type: 'my_chat_member', prop: 'myChatMember', update: { my_chat_member: {} } }, 62 | { type: 'chat_member', prop: 'chatMember', update: { chat_member: {} } }, 63 | { type: 'chat_join_request', prop: 'chatJoinRequest', update: { chat_join_request: {} } } 64 | ] 65 | 66 | UpdateTypes.forEach((update) => { 67 | test('should provide update payload for ' + update.type, async t => { 68 | const bot = createBot() 69 | bot.on(update.type, (ctx) => { 70 | t.true(update.prop in ctx) 71 | t.true('telegram' in ctx) 72 | t.true('updateType' in ctx) 73 | t.true('chat' in ctx) 74 | t.true('from' in ctx) 75 | t.true('state' in ctx) 76 | t.is(ctx.updateType, update.type) 77 | }) 78 | await bot.handleUpdate(update.update) 79 | }) 80 | }) 81 | 82 | test('should provide update payload for text', async t => { 83 | const bot = createBot() 84 | bot.on('text', (ctx) => { 85 | t.true('telegram' in ctx) 86 | t.true('updateType' in ctx) 87 | t.true('updateSubTypes' in ctx) 88 | t.true('chat' in ctx) 89 | t.true('from' in ctx) 90 | t.true('state' in ctx) 91 | t.is(ctx.updateType, 'message') 92 | }) 93 | await bot.handleUpdate({ message: BaseTextMessage }) 94 | }) 95 | 96 | test('should provide shortcuts for `message` update', async t => { 97 | const bot = createBot() 98 | bot.on('message', (ctx) => { 99 | t.true('anyMessage' in ctx) 100 | t.true('anyText' in ctx) 101 | t.true('anyEntities' in ctx) 102 | t.true('reply' in ctx) 103 | t.true('setPassportDataErrors' in ctx) 104 | t.true('replyWithPhoto' in ctx) 105 | t.true('replyWithMarkdown' in ctx) 106 | t.true('replyWithMarkdownV2' in ctx) 107 | t.true('replyWithHTML' in ctx) 108 | t.true('replyWithAudio' in ctx) 109 | t.true('replyWithDice' in ctx) 110 | t.true('replyWithDocument' in ctx) 111 | t.true('replyWithInvoice' in ctx) 112 | t.true('replyWithSticker' in ctx) 113 | t.true('replyWithVideo' in ctx) 114 | t.true('replyWithAnimation' in ctx) 115 | t.true('replyWithVideoNote' in ctx) 116 | t.true('replyWithVoice' in ctx) 117 | t.true('replyWithPoll' in ctx) 118 | t.true('replyWithQuiz' in ctx) 119 | t.true('stopPoll' in ctx) 120 | t.true('replyWithChatAction' in ctx) 121 | t.true('replyWithLocation' in ctx) 122 | t.true('replyWithVenue' in ctx) 123 | t.true('replyWithContact' in ctx) 124 | t.true('replyWithGame' in ctx) 125 | t.true('replyWithMediaGroup' in ctx) 126 | t.true('setChatPermissions' in ctx) 127 | t.true('banChatMember' in ctx) 128 | t.true('kickChatMember' in ctx) 129 | t.true('unbanChatMember' in ctx) 130 | t.true('promoteChatMember' in ctx) 131 | t.true('restrictChatMember' in ctx) 132 | t.true('getChat' in ctx) 133 | t.true('exportChatInviteLink' in ctx) 134 | t.true('banChatSenderChat' in ctx) 135 | t.true('unbanChatSenderChat' in ctx) 136 | t.true('setChatAdministratorCustomTitle' in ctx) 137 | t.true('setChatPhoto' in ctx) 138 | t.true('deleteChatPhoto' in ctx) 139 | t.true('setChatTitle' in ctx) 140 | t.true('setChatDescription' in ctx) 141 | t.true('pinChatMessage' in ctx) 142 | t.true('unpinChatMessage' in ctx) 143 | t.true('unpinAllChatMessages' in ctx) 144 | t.true('unpinAllGeneralForumTopicMessages' in ctx) 145 | t.true('leaveChat' in ctx) 146 | t.true('getChatAdministrators' in ctx) 147 | t.true('getChatMember' in ctx) 148 | t.true('getChatMembersCount' in ctx) 149 | t.true('getChatMemberCount' in ctx) 150 | t.true('setChatStickerSet' in ctx) 151 | t.true('deleteChatStickerSet' in ctx) 152 | t.true('getStickerSet' in ctx) 153 | t.true('uploadStickerFile' in ctx) 154 | t.true('createNewStickerSet' in ctx) 155 | t.true('addStickerToSet' in ctx) 156 | t.true('getMyCommands' in ctx) 157 | t.true('setMyCommands' in ctx) 158 | t.true('deleteMyCommands' in ctx) 159 | t.true('setStickerPositionInSet' in ctx) 160 | t.true('deleteStickerFromSet' in ctx) 161 | t.true('setStickerSetThumb' in ctx) 162 | t.true('editMessageText' in ctx) 163 | t.true('editMessageCaption' in ctx) 164 | t.true('editMessageMedia' in ctx) 165 | t.true('editMessageReplyMarkup' in ctx) 166 | t.true('editMessageLiveLocation' in ctx) 167 | t.true('stopMessageLiveLocation' in ctx) 168 | t.true('forwardMessage' in ctx) 169 | t.true('copyMessage' in ctx) 170 | t.true('createChatInviteLink' in ctx) 171 | t.true('editChatInviteLink' in ctx) 172 | t.true('revokeChatInviteLink' in ctx) 173 | t.true('approveChatJoinRequest' in ctx) 174 | t.true('declineChatJoinRequest' in ctx) 175 | t.true('getCustomEmojiStickers' in ctx) 176 | t.true('createInvoiceLink' in ctx) 177 | t.true('editGeneralForumTopic' in ctx) 178 | t.true('closeGeneralForumTopic' in ctx) 179 | t.true('createInvoiceLink' in ctx) 180 | t.true('hideGeneralForumTopic' in ctx) 181 | t.true('unhideGeneralForumTopic' in ctx) 182 | }) 183 | await bot.handleUpdate({ message: BaseTextMessage }) 184 | }) 185 | 186 | test('should provide shortcuts for `callback_query` update', async t => { 187 | const bot = createBot() 188 | bot.on('callback_query', (ctx) => { 189 | t.true('anyMessage' in ctx) 190 | t.true('anyText' in ctx) 191 | t.true('anyEntities' in ctx) 192 | t.true('answerCbQuery' in ctx) 193 | t.true('answerGameQuery' in ctx) 194 | t.true('reply' in ctx) 195 | t.true('replyWithMarkdown' in ctx) 196 | t.true('replyWithHTML' in ctx) 197 | t.true('setPassportDataErrors' in ctx) 198 | t.true('replyWithPhoto' in ctx) 199 | t.true('replyWithAudio' in ctx) 200 | t.true('replyWithMediaGroup' in ctx) 201 | t.true('replyWithDice' in ctx) 202 | t.true('replyWithDocument' in ctx) 203 | t.true('replyWithInvoice' in ctx) 204 | t.true('replyWithSticker' in ctx) 205 | t.true('replyWithVideo' in ctx) 206 | t.true('replyWithAnimation' in ctx) 207 | t.true('replyWithVideoNote' in ctx) 208 | t.true('replyWithVoice' in ctx) 209 | t.true('replyWithPoll' in ctx) 210 | t.true('replyWithQuiz' in ctx) 211 | t.true('stopPoll' in ctx) 212 | t.true('replyWithChatAction' in ctx) 213 | t.true('replyWithLocation' in ctx) 214 | t.true('replyWithVenue' in ctx) 215 | t.true('replyWithContact' in ctx) 216 | t.true('banChatMember' in ctx) 217 | t.true('kickChatMember' in ctx) 218 | t.true('unbanChatMember' in ctx) 219 | t.true('promoteChatMember' in ctx) 220 | t.true('restrictChatMember' in ctx) 221 | t.true('getChat' in ctx) 222 | t.true('exportChatInviteLink' in ctx) 223 | t.true('banChatSenderChat' in ctx) 224 | t.true('unbanChatSenderChat' in ctx) 225 | t.true('setChatAdministratorCustomTitle' in ctx) 226 | t.true('setChatPhoto' in ctx) 227 | t.true('deleteChatPhoto' in ctx) 228 | t.true('setChatTitle' in ctx) 229 | t.true('setChatDescription' in ctx) 230 | t.true('pinChatMessage' in ctx) 231 | t.true('unpinChatMessage' in ctx) 232 | t.true('unpinAllChatMessages' in ctx) 233 | t.true('unpinAllGeneralForumTopicMessages' in ctx) 234 | t.true('leaveChat' in ctx) 235 | t.true('getChatAdministrators' in ctx) 236 | t.true('getChatMember' in ctx) 237 | t.true('getChatMembersCount' in ctx) 238 | t.true('getChatMemberCount' in ctx) 239 | t.true('setChatStickerSet' in ctx) 240 | t.true('setChatMenuButton' in ctx) 241 | t.true('getChatMenuButton' in ctx) 242 | t.true('setMyDefaultAdministratorRights' in ctx) 243 | t.true('getMyDefaultAdministratorRights' in ctx) 244 | t.true('deleteChatStickerSet' in ctx) 245 | t.true('deleteMessage' in ctx) 246 | t.true('uploadStickerFile' in ctx) 247 | t.true('createNewStickerSet' in ctx) 248 | t.true('addStickerToSet' in ctx) 249 | t.true('getMyCommands' in ctx) 250 | t.true('setMyCommands' in ctx) 251 | t.true('deleteMyCommands' in ctx) 252 | t.true('setStickerPositionInSet' in ctx) 253 | t.true('deleteStickerFromSet' in ctx) 254 | t.true('editMessageText' in ctx) 255 | t.true('editMessageCaption' in ctx) 256 | t.true('editMessageMedia' in ctx) 257 | t.true('editMessageReplyMarkup' in ctx) 258 | t.true('editMessageLiveLocation' in ctx) 259 | t.true('stopMessageLiveLocation' in ctx) 260 | t.true('forwardMessage' in ctx) 261 | t.true('copyMessage' in ctx) 262 | t.true('createChatInviteLink' in ctx) 263 | t.true('editChatInviteLink' in ctx) 264 | t.true('revokeChatInviteLink' in ctx) 265 | t.true('approveChatJoinRequest' in ctx) 266 | t.true('declineChatJoinRequest' in ctx) 267 | t.true('getCustomEmojiStickers' in ctx) 268 | t.true('createInvoiceLink' in ctx) 269 | t.true('createForumTopic' in ctx) 270 | t.true('editForumTopic' in ctx) 271 | t.true('closeForumTopic' in ctx) 272 | t.true('reopenForumTopic' in ctx) 273 | t.true('deleteForumTopic' in ctx) 274 | t.true('unpinAllForumTopicMessages' in ctx) 275 | t.true('editGeneralForumTopic' in ctx) 276 | t.true('closeGeneralForumTopic' in ctx) 277 | t.true('createInvoiceLink' in ctx) 278 | t.true('hideGeneralForumTopic' in ctx) 279 | t.true('unhideGeneralForumTopic' in ctx) 280 | }) 281 | await bot.handleUpdate({ callback_query: BaseTextMessage }) 282 | }) 283 | 284 | test('should provide shortcuts for `shipping_query` update', async t => { 285 | const bot = createBot() 286 | bot.on('shipping_query', (ctx) => { 287 | t.true('answerShippingQuery' in ctx) 288 | }) 289 | await bot.handleUpdate({ shipping_query: BaseTextMessage }) 290 | }) 291 | 292 | test('should provide shortcuts for `pre_checkout_query` update', async t => { 293 | const bot = createBot() 294 | bot.on('pre_checkout_query', (ctx) => { 295 | t.true('answerPreCheckoutQuery' in ctx) 296 | }) 297 | await bot.handleUpdate({ pre_checkout_query: BaseTextMessage }) 298 | }) 299 | 300 | test('should provide chat and sender info', async t => { 301 | const bot = createBot() 302 | bot.on(['text', 'message'], ctx => { 303 | t.is(ctx.from.id, 42) 304 | t.is(ctx.chat.id, 1) 305 | }) 306 | await bot.handleUpdate({ message: { ...BaseTextMessage, from: { id: 42 } } }) 307 | }) 308 | 309 | test('should provide shortcuts for `inline_query` update', async t => { 310 | const bot = createBot() 311 | bot.on('inline_query', ctx => { 312 | t.true('answerInlineQuery' in ctx) 313 | }) 314 | await bot.handleUpdate({ inline_query: BaseTextMessage }) 315 | }) 316 | 317 | test('should provide subtype for `channel_post` update', async t => { 318 | const bot = createBot(undefined, { channelMode: true }) 319 | bot.on('text', ctx => { 320 | t.is(ctx.channelPost.text, 'foo') 321 | }) 322 | await bot.handleUpdate({ channel_post: BaseTextMessage }) 323 | }) 324 | 325 | test('should share state', async t => { 326 | const bot = createBot() 327 | bot.on('message', (ctx, next) => { 328 | ctx.state.answer = 41 329 | return next() 330 | }, (ctx, next) => { 331 | ctx.state.answer++ 332 | return next() 333 | }, ctx => { 334 | t.is(ctx.state.answer, 42) 335 | }) 336 | await bot.handleUpdate({ message: BaseTextMessage }) 337 | }) 338 | 339 | test('should store session state', t => { 340 | const bot = createBot() 341 | bot.use(session()) 342 | bot.hears('calc', ctx => { 343 | t.true('session' in ctx) 344 | t.true('counter' in ctx.session) 345 | t.is(ctx.session.counter, 2) 346 | }) 347 | bot.on('message', ctx => { 348 | t.true('session' in ctx) 349 | ctx.session.counter = ctx.session.counter || 0 350 | ctx.session.counter++ 351 | }) 352 | return bot.handleUpdate({ message: { ...BaseTextMessage, from: { id: 42 }, chat: { id: 42 } } }) 353 | .then(() => bot.handleUpdate({ message: { ...BaseTextMessage, from: { id: 42 }, chat: { id: 42 } } })) 354 | .then(() => bot.handleUpdate({ message: { ...BaseTextMessage, from: { id: 100500 }, chat: { id: 42 } } })) 355 | .then(() => bot.handleUpdate({ message: { ...BaseTextMessage, from: { id: 42 }, chat: { id: 42 }, text: 'calc' } })) 356 | }) 357 | 358 | test('should store session state with custom store', t => { 359 | const bot = createBot() 360 | const dummyStore = {} 361 | bot.use(session({ 362 | store: { 363 | get: (key) => new Promise((resolve) => setTimeout(resolve, 100, dummyStore[key])), 364 | set: (key, value) => { 365 | return new Promise((resolve) => setTimeout(resolve, 100)).then(() => { 366 | dummyStore[key] = value 367 | }) 368 | } 369 | } 370 | })) 371 | bot.hears('calc', ctx => { 372 | t.true('session' in ctx) 373 | t.true('counter' in ctx.session) 374 | t.is(dummyStore['42:42'].session.counter, 2) 375 | }) 376 | bot.on('message', ctx => { 377 | t.true('session' in ctx) 378 | ctx.session.counter = ctx.session.counter || 0 379 | ctx.session.counter++ 380 | }) 381 | return bot.handleUpdate({ message: { ...BaseTextMessage, from: { id: 42 }, chat: { id: 42 } } }) 382 | .then(() => bot.handleUpdate({ message: { ...BaseTextMessage, from: { id: 42 }, chat: { id: 42 } } })) 383 | .then(() => bot.handleUpdate({ message: { ...BaseTextMessage, from: { id: 100500 }, chat: { id: 42 } } })) 384 | .then(() => bot.handleUpdate({ message: { ...BaseTextMessage, from: { id: 42 }, chat: { id: 42 }, text: 'calc' } })) 385 | }) 386 | 387 | test('should throw error on read / write session when key not defined', async t => { 388 | await t.throwsAsync( 389 | new Promise((resolve, reject) => { 390 | const bot = createBot() 391 | bot.use(session()) 392 | bot.on('inline_query', ctx => { 393 | try { 394 | ctx.session.abc = 1 395 | } catch (err) { 396 | reject(err) 397 | } 398 | }) 399 | 400 | bot.handleUpdate({ 401 | update_id: 10000, 402 | inline_query: { 403 | id: 134567890097, 404 | from: { last_name: 'a', type: 'private', id: 1111111, first_name: 'b', username: 'roxy migurdia' }, 405 | query: 'Code Geass characters', 406 | offset: '' 407 | } 408 | }) 409 | }) 410 | ) 411 | }) 412 | 413 | test('should work with context extensions', async t => { 414 | await t.notThrowsAsync( 415 | new Promise(resolve => { 416 | const bot = createBot() 417 | bot.context.db = { 418 | getUser: () => undefined 419 | } 420 | bot.on('message', ctx => { 421 | t.true('db' in ctx) 422 | t.true('getUser' in ctx.db) 423 | resolve() 424 | }) 425 | bot.handleUpdate({ message: BaseTextMessage }) 426 | }) 427 | ) 428 | }) 429 | 430 | test('should handle webhook response', async t => { 431 | const bot = createBot(undefined, { 432 | telegram: { 433 | webhookReply: true 434 | } 435 | }) 436 | bot.use(async ctx => { 437 | const result = await ctx.replyWithChatAction('typing') 438 | t.is(result.webhook, true) 439 | }) 440 | const res = new MockResponse() 441 | await bot.handleUpdate({ message: BaseTextMessage }, res) 442 | t.true(res.writableEnded) 443 | t.deepEqual(JSON.parse(res.body), { 444 | method: 'sendChatAction', 445 | chat_id: 1, 446 | action: 'typing' 447 | }) 448 | }) 449 | 450 | test('should respect webhookReply option', async t => { 451 | const bot = createBot(undefined, { telegram: { webhookReply: false } }) 452 | bot.catch(err => { throw err }) // Disable log 453 | bot.on('message', async ctx => ctx.replyWithChatAction('typing')) 454 | const res = new MockResponse() 455 | await t.throwsAsync(bot.handleUpdate({ message: BaseTextMessage }, res)) 456 | t.true(res.writableEnded) 457 | t.is(res.body, undefined) 458 | }) 459 | 460 | test('should respect webhookReply runtime change', async t => { 461 | const bot = createBot(undefined, { telegram: { webhookReply: true } }) 462 | bot.webhookReply = false 463 | bot.catch((err) => { throw err }) // Disable log 464 | bot.on('message', async ctx => ctx.replyWithChatAction('typing')) 465 | const res = new MockResponse() 466 | // Throws cause Bot Token is required for http call 467 | await t.throwsAsync(bot.handleUpdate({ message: BaseTextMessage }, res)) 468 | 469 | t.true(res.writableEnded) 470 | t.is(res.body, undefined) 471 | }) 472 | 473 | test('should respect webhookReply runtime change (per request)', async t => { 474 | const bot = createBot() 475 | bot.catch((err) => { throw err }) // Disable log 476 | bot.on('message', async (ctx) => { 477 | ctx.webhookReply = false 478 | return ctx.reply(':)') 479 | }) 480 | const res = new MockResponse() 481 | await t.throwsAsync(bot.handleUpdate({ message: BaseTextMessage }, res)) 482 | t.true(res.writableEnded) 483 | t.is(res.body, undefined) 484 | }) 485 | 486 | test('should deterministically generate `secretPathComponent`', (t) => { 487 | const foo = createBot('123:foo') 488 | const bar = createBot('123:bar') 489 | t.deepEqual(foo.secretPathComponent(), foo.secretPathComponent()) 490 | t.deepEqual(bar.secretPathComponent(), bar.secretPathComponent()) 491 | t.notDeepEqual(foo.secretPathComponent(), bar.secretPathComponent()) 492 | }) 493 | 494 | test('should redact secret part of token when throw api calling error', async t => { 495 | const token = '123456789:SOMETOKEN1SOMETOKEN2SOMETOKEN' 496 | const bot = createBot(token, { 497 | telegram: { 498 | apiRoot: 'http://notexists' 499 | } 500 | }) 501 | const error = await t.throwsAsync(bot.telegram.callApi('test')) 502 | t.regex(error.message, /http:\/\/notexists\/bot\/123456789:\[REDACTED]\/test/) 503 | t.notRegex(error.message, new RegExp(token)) 504 | }) 505 | 506 | test('should redact secret part of token when throw api calling error when using apiPrefix', async t => { 507 | const token = '123456789:SOMETOKEN1SOMETOKEN2SOMETOKEN' 508 | const bot = createBot(token, { 509 | telegram: { 510 | apiRoot: 'http://notexists', 511 | apiPrefix: 'someprefix' 512 | } 513 | }) 514 | const error = await t.throwsAsync(bot.telegram.callApi('test')) 515 | t.regex(error.message, /http:\/\/notexists\/someprefix\/123456789:\[REDACTED]\/test/) 516 | t.notRegex(error.message, new RegExp(token)) 517 | }) 518 | 519 | test('should restrict access with wrong path', async t => { 520 | const bot = createBot() 521 | return new Promise((resolve, reject) => { 522 | bot.start(reject) 523 | const callback = bot.webhookCallback({ path: '/anime' }) 524 | const res = new MockResponse() 525 | const req = new MockRequest('/anime1', 'POST', {}, { 526 | message: { 527 | text: '/start payload', 528 | entities: [{ type: 'bot_command', offset: 0, length: 6 }] 529 | } 530 | }) 531 | 532 | callback(req, res) 533 | .then(() => t.is(res.statusCode, 403) && resolve()) 534 | }) 535 | }) 536 | 537 | test('should restrict access with wrong secret', async t => { 538 | const bot = createBot() 539 | return new Promise((resolve, reject) => { 540 | bot.start(reject) 541 | const callback = bot.webhookCallback({ path: '/anime', secret: 'wrong' }) 542 | const res = new MockResponse() 543 | const req = new MockRequest('/anime', 'POST', { 'x-telegram-bot-api-secret-token': '1234567890' }, { 544 | message: { 545 | text: '/start payload', 546 | entities: [{ type: 'bot_command', offset: 0, length: 6 }] 547 | } 548 | }) 549 | 550 | callback(req, res) 551 | .then(() => t.is(res.statusCode, 403) && resolve()) 552 | }) 553 | }) 554 | 555 | test('should handle webhook update with secret', async t => { 556 | const bot = createBot() 557 | t.plan(2) 558 | return new Promise((resolve) => { 559 | bot.on('message', ctx => t.is(ctx.message.text, '/start')) 560 | const callback = bot.webhookCallback({ path: '/anime', secret: '1234567890' }) 561 | const res = new MockResponse() 562 | const req = new MockRequest('/anime', 'POST', { 'x-telegram-bot-api-secret-token': '1234567890' }, { 563 | message: { 564 | text: '/start', 565 | entities: [{ type: 'bot_command', offset: 0, length: 6 }] 566 | } 567 | }) 568 | 569 | callback(req, res) 570 | .then(() => t.is(res.statusCode, 200) && resolve()) 571 | }) 572 | }) 573 | 574 | test('should handle webhook update with path', async t => { 575 | const bot = createBot() 576 | t.plan(2) 577 | return new Promise((resolve) => { 578 | bot.on('message', ctx => t.is(ctx.message.text, '/start')) 579 | const callback = bot.webhookCallback({ path: '/anime' }) 580 | const res = new MockResponse() 581 | const req = new MockRequest('/anime', 'POST', {}, { 582 | message: { 583 | text: '/start', 584 | entities: [{ type: 'bot_command', offset: 0, length: 6 }] 585 | } 586 | }) 587 | 588 | callback(req, res) 589 | .then(() => t.is(res.statusCode, 200) && resolve()) 590 | }) 591 | }) 592 | --------------------------------------------------------------------------------