├── .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
",
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 |
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 | [](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 |
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