├── docs ├── .nojekyll ├── CNAME ├── header.png ├── telegraf.png ├── examples │ ├── hello-bot-module.js │ ├── deeplink-bot.js │ ├── fastify-webhook-bot.js │ ├── proxy-bot.js │ ├── custom-context-bot.js │ ├── live-location-bot.js │ ├── echo-bot.js │ ├── login-bot.js │ ├── webhook-bot.js │ ├── game-bot.js │ ├── express-webhook-bot.js │ ├── koa-webhook-bot.js │ ├── poll-bot.js │ ├── inline-bot.js │ ├── scenes-bot.js │ ├── echo-bot-module.js │ ├── wizard-bot.js │ ├── shop-bot.js │ ├── example-bot.js │ ├── media-bot.js │ └── keyboard-bot.js ├── theme │ ├── assets │ │ └── css │ │ │ └── custom.css │ └── layouts │ │ └── default.hbs └── index.html ├── .eslintignore ├── .npmrc ├── .mailmap ├── .prettierrc.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── Feature_request.md │ └── Bug_report.md ├── workflows │ └── node.js.yml └── PULL_REQUEST_TEMPLATE.md ├── .travis.yml ├── .editorconfig ├── src ├── index.ts ├── core │ ├── network │ │ ├── error.ts │ │ ├── multipart-stream.ts │ │ ├── webhook.ts │ │ └── client.ts │ └── replicators.ts ├── session.ts ├── scenes │ ├── wizard │ │ ├── context.ts │ │ └── index.ts │ ├── base.ts │ └── context.ts ├── types.ts ├── util.ts ├── router.ts ├── stage.ts ├── extra.ts ├── telegram-types.ts ├── markup.js ├── telegraf.ts ├── context.ts └── composer.ts ├── tsconfig.json ├── .gitignore ├── .eslintrc ├── LICENSE ├── test ├── extra.js ├── markup.js ├── telegraf.js └── composer.js ├── package.json ├── bin └── telegraf ├── readme.md └── code_of_conduct.md /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | telegraf.js.org -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Wojciech Pawlik 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /docs/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syrup/telegraf/develop/docs/header.png -------------------------------------------------------------------------------- /docs/telegraf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syrup/telegraf/develop/docs/telegraf.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [dotcypress] 4 | open_collective: telegraf 5 | -------------------------------------------------------------------------------- /.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 run typecheck 11 | - npm test 12 | -------------------------------------------------------------------------------- /docs/examples/hello-bot-module.js: -------------------------------------------------------------------------------- 1 | // Modules documentation: https://telegraf.js.org/#/?id=telegraf-modules 2 | // $> telegraf -t `BOT TOKEN` hello-bot-module.js 3 | module.exports = ({ reply }) => reply('Hello!') 4 | -------------------------------------------------------------------------------- /docs/examples/deeplink-bot.js: -------------------------------------------------------------------------------- 1 | // https://core.telegram.org/bots#deep-linking 2 | const Telegraf = require('telegraf') 3 | 4 | const bot = new Telegraf(process.env.BOT_TOKEN) 5 | bot.start((ctx) => ctx.reply(`Deep link payload: ${ctx.startPayload}`)) 6 | bot.launch() 7 | -------------------------------------------------------------------------------- /.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 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [*.txt] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { BaseScene } from './scenes/base' 2 | export { Composer } from './composer' 3 | export { Context } from './context' 4 | export { Middleware } from './types' 5 | export { Router } from './router' 6 | export { session } from './session' 7 | export { Stage } from './stage' 8 | export { Telegraf } from './telegraf' 9 | export { WizardScene } from './scenes/wizard' 10 | -------------------------------------------------------------------------------- /docs/examples/fastify-webhook-bot.js: -------------------------------------------------------------------------------- 1 | const Telegraf = require('telegraf') 2 | const fastifyApp = require('fastify')() 3 | 4 | const bot = new Telegraf(process.env.BOT_TOKEN) 5 | 6 | bot.on('text', ({ reply }) => reply('Hello')) 7 | 8 | // Set telegram webhook 9 | // npm install -g localtunnel && lt --port 3000 10 | bot.telegram.setWebhook('https://-------.localtunnel.me/secret-path') 11 | 12 | fastifyApp.use(bot.webhookCallback('/secret-path')) 13 | fastifyApp.listen(3000, () => { 14 | console.log('Example app listening on port 3000!') 15 | }) 16 | -------------------------------------------------------------------------------- /docs/examples/proxy-bot.js: -------------------------------------------------------------------------------- 1 | const Telegraf = require('telegraf') 2 | const HttpsProxyAgent = require('https-proxy-agent') 3 | 4 | const { BOT_TOKEN, HTTPS_PROXY_HOST, HTTPS_PROXY_PORT } = process.env 5 | 6 | const agent = new HttpsProxyAgent({ 7 | host: HTTPS_PROXY_HOST, 8 | port: HTTPS_PROXY_PORT 9 | }) 10 | 11 | const bot = new Telegraf(BOT_TOKEN, { telegram: { agent } }) 12 | bot.start((ctx) => ctx.reply('Hello')) 13 | bot.help((ctx) => ctx.reply('Help message')) 14 | bot.command('photo', (ctx) => ctx.replyWithPhoto({ url: 'https://picsum.photos/200/300/?random' })) 15 | bot.launch() 16 | -------------------------------------------------------------------------------- /src/core/network/error.ts: -------------------------------------------------------------------------------- 1 | interface ErrorPayload { 2 | error_code: number 3 | description: string 4 | parameters?: {} 5 | } 6 | class TelegramError extends Error { 7 | code: number 8 | response: ErrorPayload 9 | description: string 10 | parameters?: {} 11 | constructor(payload: ErrorPayload, readonly on = {}) { 12 | super(`${payload.error_code}: ${payload.description}`) 13 | this.code = payload.error_code 14 | this.response = payload 15 | this.description = payload.description 16 | this.parameters = payload.parameters 17 | } 18 | } 19 | 20 | export = TelegramError 21 | -------------------------------------------------------------------------------- /docs/examples/custom-context-bot.js: -------------------------------------------------------------------------------- 1 | const Telegraf = require('telegraf') 2 | 3 | class CustomContext extends Telegraf.Context { 4 | constructor (update, telegram, options) { 5 | console.log('Creating contexy for %j', update) 6 | super(update, telegram, options) 7 | } 8 | 9 | reply (...args) { 10 | console.log('reply called with args: %j', args) 11 | return super.reply(...args) 12 | } 13 | } 14 | 15 | const bot = new Telegraf(process.env.BOT_TOKEN, { contextType: CustomContext }) 16 | bot.start((ctx) => ctx.reply('Hello')) 17 | bot.help((ctx) => ctx.reply('Help message')) 18 | bot.launch() 19 | -------------------------------------------------------------------------------- /docs/examples/live-location-bot.js: -------------------------------------------------------------------------------- 1 | const Telegraf = require('telegraf') 2 | 3 | function sendLiveLocation (ctx) { 4 | let lat = 42.0 5 | let lon = 42.0 6 | ctx.replyWithLocation(lat, lon, { live_period: 60 }).then((message) => { 7 | const timer = setInterval(() => { 8 | lat += Math.random() * 0.001 9 | lon += Math.random() * 0.001 10 | ctx.telegram.editMessageLiveLocation(lat, lon, message.chat.id, message.message_id).catch(() => clearInterval(timer)) 11 | }, 1000) 12 | }) 13 | } 14 | 15 | const bot = new Telegraf(process.env.BOT_TOKEN) 16 | bot.start(sendLiveLocation) 17 | bot.launch() 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "allowJs": true, 6 | "declaration": true, 7 | "declarationDir": "typings/", 8 | "outDir": "lib/", 9 | "target": "ES2019", 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true 12 | }, 13 | "typedocOptions": { 14 | "exclude": ["typings/test.ts"], 15 | "excludeExternals": true, 16 | "includeDeclarations": true, 17 | "mode": "file", 18 | "out": "telegraf-docs", 19 | "readme": "docs/README.md", 20 | "theme": "docs/theme/" 21 | }, 22 | "include": ["src/"] 23 | } 24 | -------------------------------------------------------------------------------- /docs/examples/echo-bot.js: -------------------------------------------------------------------------------- 1 | const Telegraf = require('telegraf') 2 | const Extra = require('telegraf/extra') 3 | const Markup = require('telegraf/markup') 4 | 5 | const keyboard = Markup.inlineKeyboard([ 6 | Markup.urlButton('❤️', 'http://telegraf.js.org'), 7 | Markup.callbackButton('Delete', 'delete') 8 | ]) 9 | 10 | const bot = new Telegraf(process.env.BOT_TOKEN) 11 | bot.start((ctx) => ctx.reply('Hello')) 12 | bot.help((ctx) => ctx.reply('Help message')) 13 | bot.on('message', (ctx) => ctx.telegram.sendCopy(ctx.chat.id, ctx.message, Extra.markup(keyboard))) 14 | bot.action('delete', ({ deleteMessage }) => deleteMessage()) 15 | bot.launch() 16 | -------------------------------------------------------------------------------- /docs/examples/login-bot.js: -------------------------------------------------------------------------------- 1 | const Telegraf = require('telegraf') 2 | const Extra = require('telegraf/extra') 3 | const Markup = require('telegraf/markup') 4 | 5 | const keyboard = Markup.inlineKeyboard([ 6 | Markup.loginButton('Login', 'http://domain.tld/hash', { 7 | bot_username: 'my_bot', 8 | request_write_access: 'true' 9 | }), 10 | Markup.urlButton('❤️', 'http://telegraf.js.org'), 11 | Markup.callbackButton('Delete', 'delete') 12 | ]) 13 | 14 | const bot = new Telegraf(process.env.BOT_TOKEN) 15 | bot.start((ctx) => ctx.reply('Hello', Extra.markup(keyboard))) 16 | bot.action('delete', ({ deleteMessage }) => deleteMessage()) 17 | bot.launch() 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /docs/examples/webhook-bot.js: -------------------------------------------------------------------------------- 1 | // npm install -g localtunnel && lt --port 3000 2 | const Telegraf = require('telegraf') 3 | 4 | const bot = new Telegraf(process.env.BOT_TOKEN) 5 | bot.command('image', (ctx) => ctx.replyWithPhoto({ url: 'https://picsum.photos/200/300/?random' })) 6 | bot.on('text', ({ replyWithHTML }) => replyWithHTML('Hello')) 7 | 8 | // Start webhook directly 9 | // bot.startWebhook('/secret-path', null, 3000) 10 | // bot.telegram.setWebhook('https://---.localtunnel.me/secret-path') 11 | 12 | // Start webhook via launch method (preffered) 13 | bot.launch({ 14 | webhook: { 15 | domain: 'https://---.localtunnel.me', 16 | port: 3000 17 | } 18 | }) 19 | -------------------------------------------------------------------------------- /docs/examples/game-bot.js: -------------------------------------------------------------------------------- 1 | const Telegraf = require('telegraf') 2 | const Extra = require('telegraf/extra') 3 | const Markup = require('telegraf/markup') 4 | 5 | const gameShortName = 'your-game' 6 | const gameUrl = 'https://your-game.tld' 7 | 8 | const markup = Extra.markup( 9 | Markup.inlineKeyboard([ 10 | Markup.gameButton('🎮 Play now!'), 11 | Markup.urlButton('Telegraf help', 'http://telegraf.js.org') 12 | ]) 13 | ) 14 | 15 | const bot = new Telegraf(process.env.BOT_TOKEN) 16 | bot.start(({ replyWithGame }) => replyWithGame(gameShortName)) 17 | bot.command('foo', ({ replyWithGame }) => replyWithGame(gameShortName, markup)) 18 | bot.gameQuery(({ answerGameQuery }) => answerGameQuery(gameUrl)) 19 | bot.launch() 20 | -------------------------------------------------------------------------------- /docs/examples/express-webhook-bot.js: -------------------------------------------------------------------------------- 1 | const Telegraf = require('telegraf') 2 | const express = require('express') 3 | 4 | const bot = new Telegraf(process.env.BOT_TOKEN) 5 | // Set the bot response 6 | bot.on('text', ({ replyWithHTML }) => replyWithHTML('Hello')) 7 | 8 | // Set telegram webhook 9 | // npm install -g localtunnel && lt --port 3000 10 | bot.telegram.setWebhook('https://----.localtunnel.me/secret-path') 11 | 12 | const app = express() 13 | app.get('/', (req, res) => res.send('Hello World!')) 14 | // Set the bot API endpoint 15 | app.use(bot.webhookCallback('/secret-path')) 16 | app.listen(3000, () => { 17 | console.log('Example app listening on port 3000!') 18 | }) 19 | 20 | // No need to call bot.launch() 21 | -------------------------------------------------------------------------------- /.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 | # lock scripts 24 | package-lock.json 25 | yarn.lock 26 | 27 | # Compiled binary addons (http://nodejs.org/api/addons.html) 28 | build/Release 29 | 30 | # Dependency directory 31 | node_modules 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | .env 39 | .vscode 40 | package-lock.json 41 | /lib 42 | /telegraf-docs/ 43 | /typings/ 44 | -------------------------------------------------------------------------------- /.github/workflows/node.js.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: [ develop ] 9 | pull_request: 10 | {} 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x, 14.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm install --ignore-scripts 28 | - run: npm test 29 | - run: npm run lint 30 | -------------------------------------------------------------------------------- /docs/examples/koa-webhook-bot.js: -------------------------------------------------------------------------------- 1 | const Telegraf = require('telegraf') 2 | const Koa = require('koa') 3 | const koaBody = require('koa-body') 4 | 5 | const bot = new Telegraf(process.env.BOT_TOKEN) 6 | // First reply will be served via webhook response, 7 | // but messages order not guaranteed due to `koa` pipeline design. 8 | // Details: https://github.com/telegraf/telegraf/issues/294 9 | bot.command('image', (ctx) => ctx.replyWithPhoto({ url: 'https://picsum.photos/200/300/?random' })) 10 | bot.on('text', ({ reply }) => reply('Hello')) 11 | 12 | // Set telegram webhook 13 | // npm install -g localtunnel && lt --port 3000 14 | bot.telegram.setWebhook('https://-----.localtunnel.me/secret-path') 15 | 16 | const app = new Koa() 17 | app.use(koaBody()) 18 | app.use((ctx, next) => ctx.method === 'POST' || ctx.url === '/secret-path' 19 | ? bot.handleUpdate(ctx.request.body, ctx.response) 20 | : next() 21 | ) 22 | app.listen(3000) 23 | -------------------------------------------------------------------------------- /docs/examples/poll-bot.js: -------------------------------------------------------------------------------- 1 | const Telegraf = require('telegraf') 2 | const { Extra, Markup } = Telegraf 3 | 4 | const keyboard = Markup.keyboard([ 5 | Markup.pollRequestButton('Create poll', 'regular'), 6 | Markup.pollRequestButton('Create quiz', 'quiz') 7 | ]) 8 | 9 | const bot = new Telegraf(process.env.BOT_TOKEN) 10 | 11 | bot.on('poll', (ctx) => console.log('Poll update', ctx.poll)) 12 | bot.on('poll_answer', (ctx) => console.log('Poll answer', ctx.pollAnswer)) 13 | 14 | bot.start((ctx) => ctx.reply('supported commands: /poll /quiz', Extra.markup(keyboard))) 15 | 16 | bot.command('poll', (ctx) => 17 | ctx.replyWithPoll( 18 | 'Your favorite math constant', 19 | ['x', 'e', 'π', 'φ', 'γ'], 20 | { is_anonymous: false } 21 | ) 22 | ) 23 | bot.command('quiz', (ctx) => 24 | ctx.replyWithQuiz( 25 | '2b|!2b', 26 | ['True', 'False'], 27 | { correct_option_id: 0 } 28 | ) 29 | ) 30 | 31 | bot.launch() 32 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["standard-with-typescript"], 3 | "parserOptions": { 4 | "project": "./tsconfig.json" 5 | }, 6 | "overrides": [ 7 | { 8 | "files": "*.ts", 9 | "extends": ["plugin:prettier/recommended", "prettier/@typescript-eslint"], 10 | "rules": { 11 | "sort-imports": ["warn", { "ignoreCase": true }], 12 | "@typescript-eslint/ban-ts-comment": "warn", 13 | "@typescript-eslint/explicit-function-return-type": "off", 14 | "@typescript-eslint/no-explicit-any": "warn", 15 | "@typescript-eslint/no-non-null-assertion": "warn", 16 | "@typescript-eslint/promise-function-async": "off", 17 | "@typescript-eslint/strict-boolean-expressions": "warn" 18 | } 19 | } 20 | ], 21 | "rules": { 22 | "no-warning-comments": "warn" 23 | }, 24 | "ignorePatterns": ["/lib/", "/typings/"], 25 | "reportUnusedDisableDirectives": true, 26 | "plugins": ["ava"] 27 | } 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | ## Context 8 | 9 | Please provide any relevant information about your setup. This is important in case the issue is not reproducible except for under certain conditions. 10 | 11 | * Telegraf.js Version: 12 | * Node.js Version: 13 | * Operating System: 14 | 15 | ## Expected Behavior 16 | 17 | Please describe the behavior you are expecting 18 | 19 | ## Current Behavior 20 | 21 | What is the current behavior? 22 | 23 | ## Failure Information (for bugs) 24 | 25 | Please help provide information about the failure if this is a bug. If it is not a bug, please remove the rest of this template. 26 | 27 | ### Steps to Reproduce 28 | 29 | Please provide detailed steps for reproducing the issue. 30 | 31 | 1. step 1 32 | 2. step 2 33 | 3. you get it... 34 | 35 | ### Failure Logs 36 | 37 | Please include any relevant log snippets or files here. 38 | -------------------------------------------------------------------------------- /src/session.ts: -------------------------------------------------------------------------------- 1 | import { Context } from './context' 2 | import { Middleware } from './types' 3 | 4 | export function session({ 5 | ttl = Infinity, 6 | store = new Map(), 7 | getSessionKey = (ctx: Context) => 8 | ctx.from && ctx.chat && `${ctx.from.id}:${ctx.chat.id}`, 9 | } = {}): Middleware.ExtFn { 10 | const ttlMs = ttl * 1000 11 | 12 | return async (ctx, next) => { 13 | const key = getSessionKey(ctx) 14 | if (key == null) { 15 | return await next(ctx) 16 | } 17 | const now = Date.now() 18 | const entry = store.get(key) 19 | const ctx2 = Object.assign(ctx, { 20 | session: entry == null || entry.expires < now ? undefined : entry.session, 21 | }) 22 | await next(ctx2) 23 | if (ctx2.session == null) { 24 | store.delete(key) 25 | } else { 26 | store.set(key, { session: ctx2.session, expires: now + ttlMs }) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /docs/examples/inline-bot.js: -------------------------------------------------------------------------------- 1 | const Telegraf = require('telegraf') 2 | const Markup = require('telegraf/markup') 3 | const fetch = require('node-fetch') 4 | 5 | const bot = new Telegraf(process.env.BOT_TOKEN) 6 | 7 | bot.on('inline_query', async ({ inlineQuery, answerInlineQuery }) => { 8 | const apiUrl = `http://recipepuppy.com/api/?q=${inlineQuery.query}` 9 | const response = await fetch(apiUrl) 10 | const { results } = await response.json() 11 | const recipes = results 12 | .filter(({ thumbnail }) => thumbnail) 13 | .map(({ title, href, thumbnail }) => ({ 14 | type: 'article', 15 | id: thumbnail, 16 | title: title, 17 | description: title, 18 | thumb_url: thumbnail, 19 | input_message_content: { 20 | message_text: title 21 | }, 22 | reply_markup: Markup.inlineKeyboard([ 23 | Markup.urlButton('Go to recipe', href) 24 | ]) 25 | })) 26 | return answerInlineQuery(recipes) 27 | }) 28 | 29 | bot.on('chosen_inline_result', ({ chosenInlineResult }) => { 30 | console.log('chosen inline result', chosenInlineResult) 31 | }) 32 | 33 | bot.launch() 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2019 Vitaly Domnikov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/scenes/wizard/context.ts: -------------------------------------------------------------------------------- 1 | import Context from '../../context' 2 | import { Middleware } from '../../types' 3 | import SceneContext from '../context' 4 | 5 | class WizardContext> { 6 | readonly state: any 7 | cursor: number 8 | constructor( 9 | private readonly ctx: TContext, 10 | private readonly steps: ReadonlyArray> 11 | ) { 12 | this.state = ctx.scene.state 13 | this.cursor = ctx.scene.session.cursor || 0 14 | } 15 | 16 | get step() { 17 | return this.cursor >= 0 && this.steps[this.cursor] 18 | } 19 | 20 | selectStep(index: number) { 21 | this.cursor = index 22 | this.ctx.scene.session.cursor = index 23 | return this 24 | } 25 | 26 | next() { 27 | return this.selectStep(this.cursor + 1) 28 | } 29 | 30 | back() { 31 | return this.selectStep(this.cursor - 1) 32 | } 33 | } 34 | 35 | // eslint-disable-next-line 36 | namespace WizardContext { 37 | export interface Extension> { 38 | wizard: WizardContext 39 | } 40 | export type Extended< 41 | TContext extends SceneContext.Extended 42 | > = TContext & Extension 43 | } 44 | 45 | export = WizardContext 46 | -------------------------------------------------------------------------------- /docs/theme/assets/css/custom.css: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | code.language-bash::before { 3 | content: '$'; 4 | margin-left: 5px; 5 | display: inline-block; 6 | margin-right: 10px; 7 | user-select: none; 8 | } 9 | 10 | .tsd-navigation [href='classes/apiclient.html'], 11 | .tsd-navigation .tsd-kind-interface, 12 | .tsd-navigation .tsd-kind-type-alias { 13 | display: none; 14 | } 15 | 16 | a > h2:hover::after, 17 | a > h3:hover::after, 18 | a > h4:hover::after, 19 | a > h5:hover::after { 20 | content: '🔗'; 21 | opacity: 0.2; 22 | } 23 | 24 | a { 25 | color: #e74625; 26 | } 27 | 28 | .hljs-keyword { 29 | color: #07a; 30 | } 31 | 32 | .hljs-subst > .hljs-keyword { 33 | color: inherit; 34 | } 35 | 36 | nav .tsd-kind-class, 37 | .tsd-legend > .tsd-kind-constructor, 38 | .tsd-legend > .tsd-kind-method, 39 | .tsd-index-list > .tsd-kind-class, 40 | .tsd-index-list > .tsd-kind-constructor, 41 | .tsd-index-list > .tsd-kind-method, 42 | .tsd-kind-constructor > .tsd-signature::before, 43 | .tsd-kind-method > .tsd-signature::before { 44 | filter: hue-rotate(-199.8deg); 45 | } 46 | 47 | nav .tsd-kind-class .tsd-kind-property { 48 | filter: hue-rotate(199.8deg); 49 | } 50 | 51 | .tsd-index-list .tsd-kind-type-alias > a, 52 | .tsd-index-list .tsd-kind-function > a { 53 | color: #b54dff; 54 | } 55 | -------------------------------------------------------------------------------- /docs/examples/scenes-bot.js: -------------------------------------------------------------------------------- 1 | const Telegraf = require('telegraf') 2 | const session = require('telegraf/session') 3 | const Stage = require('telegraf/stage') 4 | const Scene = require('telegraf/scenes/base') 5 | 6 | // Handler factoriess 7 | const { enter, leave } = Stage 8 | 9 | // Greeter scene 10 | const greeterScene = new Scene('greeter') 11 | greeterScene.enter((ctx) => ctx.reply('Hi')) 12 | greeterScene.leave((ctx) => ctx.reply('Bye')) 13 | greeterScene.hears('hi', enter('greeter')) 14 | greeterScene.on('message', (ctx) => ctx.replyWithMarkdown('Send `hi`')) 15 | 16 | // Echo scene 17 | const echoScene = new Scene('echo') 18 | echoScene.enter((ctx) => ctx.reply('echo scene')) 19 | echoScene.leave((ctx) => ctx.reply('exiting echo scene')) 20 | echoScene.command('back', leave()) 21 | echoScene.on('text', (ctx) => ctx.reply(ctx.message.text)) 22 | echoScene.on('message', (ctx) => ctx.reply('Only text messages please')) 23 | 24 | const bot = new Telegraf(process.env.BOT_TOKEN) 25 | const stage = new Stage([greeterScene, echoScene], { ttl: 10 }) 26 | bot.use(session()) 27 | bot.use(stage.middleware()) 28 | bot.command('greeter', (ctx) => ctx.scene.enter('greeter')) 29 | bot.command('echo', (ctx) => ctx.scene.enter('echo')) 30 | bot.on('message', (ctx) => ctx.reply('Try /echo or /greeter')) 31 | bot.launch() 32 | -------------------------------------------------------------------------------- /src/core/network/multipart-stream.ts: -------------------------------------------------------------------------------- 1 | import { hasPropType } from '../../util' 2 | import SandwichStream from 'sandwich-stream' 3 | import stream from 'stream' 4 | const CRNL = '\r\n' 5 | 6 | interface Part { 7 | headers: { [key: string]: string } 8 | body: NodeJS.ReadStream | NodeJS.ReadableStream | string 9 | } 10 | 11 | class MultipartStream extends SandwichStream { 12 | constructor(boundary: string) { 13 | super({ 14 | head: `--${boundary}${CRNL}`, 15 | tail: `${CRNL}--${boundary}--`, 16 | separator: `${CRNL}--${boundary}${CRNL}`, 17 | }) 18 | } 19 | 20 | addPart(part: Part) { 21 | const partStream = new stream.PassThrough() 22 | for (const key in part.headers) { 23 | const header = part.headers[key] 24 | partStream.write(`${key}:${header}${CRNL}`) 25 | } 26 | partStream.write(CRNL) 27 | if (MultipartStream.isStream(part.body)) { 28 | part.body.pipe(partStream) 29 | } else { 30 | partStream.end(part.body) 31 | } 32 | this.add(partStream) 33 | } 34 | 35 | static isStream( 36 | stream: unknown 37 | ): stream is { pipe: MultipartStream['pipe'] } { 38 | return ( 39 | stream && 40 | typeof stream === 'object' && 41 | hasPropType(stream, 'pipe', 'function') 42 | ) 43 | } 44 | } 45 | 46 | export = MultipartStream 47 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import Context from './context' 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-namespace 6 | export namespace Middleware { 7 | /* 8 | next's parameter is in a contravariant position, and thus, trying to type it 9 | prevents assigning `MiddlewareFn` 10 | to `MiddlewareFn`. 11 | Middleware passing the parameter should be a separate type instead. 12 | */ 13 | export type Fn = ( 14 | ctx: TContext, 15 | next: () => Promise 16 | ) => Promise 17 | export interface Obj { 18 | middleware: () => Fn 19 | } 20 | export type ExtFn = < 21 | TContext extends BaseContext 22 | >( 23 | ctx: TContext, 24 | next: (ctx: Extension & TContext) => Promise 25 | ) => Promise 26 | export type Ext< 27 | BaseContext extends Context, 28 | Extension extends object 29 | > = ExtFn 30 | } 31 | export type Middleware = 32 | | Middleware.Fn 33 | | Middleware.Obj 34 | 35 | export type NonemptyReadonlyArray = readonly [T, ...T[]] 36 | 37 | // prettier-ignore 38 | export type Tail = T extends [unknown, ...infer U] ? U : never 39 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. 4 | 5 | Fixes # (issue) 6 | 7 | ## Type of change 8 | 9 | Please delete options that are not relevant. 10 | 11 | - [ ] Documentation (typos, code examples or any documentation update) 12 | - [ ] Bug fix (non-breaking change which fixes an issue) 13 | - [ ] New feature (non-breaking change which adds functionality) 14 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 15 | - [ ] This change requires a documentation update 16 | 17 | # How Has This Been Tested? 18 | 19 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration 20 | 21 | - [ ] Test A 22 | - [ ] Test B 23 | 24 | **Test Configuration**: 25 | * Node.js Version: 26 | * Operating System: 27 | 28 | # Checklist: 29 | 30 | - [ ] My code follows the style guidelines of this project 31 | - [ ] I have performed a self-review of my own code 32 | - [ ] I have made corresponding changes to the documentation 33 | - [ ] My changes generate no new warnings 34 | - [ ] I have added tests that prove my fix is effective or that my feature works 35 | - [ ] New and existing unit tests pass locally with my changes 36 | -------------------------------------------------------------------------------- /docs/examples/echo-bot-module.js: -------------------------------------------------------------------------------- 1 | // Modules documentation: https://telegraf.js.org/#/?id=telegraf-modules 2 | // $> telegraf -t `BOT TOKEN` echo-bot-module.js 3 | const Composer = require('telegraf/composer') 4 | const Extra = require('telegraf/extra') 5 | const Markup = require('telegraf/markup') 6 | 7 | const keyboard = Markup.inlineKeyboard([ 8 | Markup.urlButton('❤️', 'http://telegraf.js.org'), 9 | Markup.callbackButton('Delete', 'delete') 10 | ]) 11 | 12 | const bot = new Composer() 13 | bot.start((ctx) => ctx.replyWithDice()) 14 | bot.settings(async (ctx) => { 15 | await ctx.setMyCommands([ 16 | { 17 | command: '/foo', 18 | description: 'foo description' 19 | }, 20 | { 21 | command: '/bar', 22 | description: 'bar description' 23 | }, 24 | { 25 | command: '/baz', 26 | description: 'baz description' 27 | } 28 | ]) 29 | return ctx.reply('Ok') 30 | }) 31 | bot.help(async (ctx) => { 32 | const commands = await ctx.getMyCommands() 33 | const info = commands.reduce((acc, val) => `${acc}/${val.command} - ${val.description}\n`, '') 34 | return ctx.reply(info) 35 | }) 36 | bot.action('delete', ({ deleteMessage }) => deleteMessage()) 37 | bot.on('dice', (ctx) => ctx.reply(`Value: ${ctx.message.dice.value}`)) 38 | bot.on('message', (ctx) => ctx.telegram.sendCopy(ctx.chat.id, ctx.message, Extra.markup(keyboard))) 39 | 40 | module.exports = bot 41 | -------------------------------------------------------------------------------- /src/core/network/webhook.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'http' 2 | import d from 'debug' 3 | import { Update } from 'typegram' 4 | const debug = d('telegraf:webhook') 5 | 6 | export = function ( 7 | hookPath: string, 8 | updateHandler: (update: Update, res: http.ServerResponse) => Promise, 9 | errorHandler: (err: SyntaxError) => void 10 | ) { 11 | return ( 12 | req: http.IncomingMessage, 13 | res: http.ServerResponse, 14 | next?: () => void 15 | ): void => { 16 | debug('Incoming request', req.method, req.url) 17 | if (req.method !== 'POST' || req.url !== hookPath) { 18 | if (typeof next === 'function') { 19 | return next() 20 | } 21 | res.statusCode = 403 22 | return res.end() 23 | } 24 | let body = '' 25 | req.on('data', (chunk: string) => { 26 | body += chunk.toString() 27 | }) 28 | req.on('end', () => { 29 | let update: Update 30 | try { 31 | update = JSON.parse(body) 32 | } catch (error) { 33 | res.writeHead(415) 34 | res.end() 35 | return errorHandler(error) 36 | } 37 | updateHandler(update, res) 38 | .then(() => { 39 | if (!res.finished) { 40 | res.end() 41 | } 42 | }) 43 | .catch((err: unknown) => { 44 | debug('Webhook error', err) 45 | res.writeHead(500) 46 | res.end() 47 | }) 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /docs/examples/wizard-bot.js: -------------------------------------------------------------------------------- 1 | const Telegraf = require('telegraf') 2 | const Composer = require('telegraf/composer') 3 | const session = require('telegraf/session') 4 | const Stage = require('telegraf/stage') 5 | const Markup = require('telegraf/markup') 6 | const WizardScene = require('telegraf/scenes/wizard') 7 | 8 | const stepHandler = new Composer() 9 | stepHandler.action('next', (ctx) => { 10 | ctx.reply('Step 2. Via inline button') 11 | return ctx.wizard.next() 12 | }) 13 | stepHandler.command('next', (ctx) => { 14 | ctx.reply('Step 2. Via command') 15 | return ctx.wizard.next() 16 | }) 17 | stepHandler.use((ctx) => ctx.replyWithMarkdown('Press `Next` button or type /next')) 18 | 19 | const superWizard = new WizardScene('super-wizard', 20 | (ctx) => { 21 | ctx.reply('Step 1', Markup.inlineKeyboard([ 22 | Markup.urlButton('❤️', 'http://telegraf.js.org'), 23 | Markup.callbackButton('➡️ Next', 'next') 24 | ]).extra()) 25 | return ctx.wizard.next() 26 | }, 27 | stepHandler, 28 | (ctx) => { 29 | ctx.reply('Step 3') 30 | return ctx.wizard.next() 31 | }, 32 | (ctx) => { 33 | ctx.reply('Step 4') 34 | return ctx.wizard.next() 35 | }, 36 | (ctx) => { 37 | ctx.reply('Done') 38 | return ctx.scene.leave() 39 | } 40 | ) 41 | 42 | const bot = new Telegraf(process.env.BOT_TOKEN) 43 | const stage = new Stage([superWizard], { default: 'super-wizard' }) 44 | bot.use(session()) 45 | bot.use(stage.middleware()) 46 | bot.launch() 47 | -------------------------------------------------------------------------------- /src/scenes/base.ts: -------------------------------------------------------------------------------- 1 | import Composer from '../composer' 2 | import { Middleware } from '../types' 3 | import TelegrafContext from '../context' 4 | const { compose } = Composer 5 | 6 | interface SceneOptions { 7 | ttl?: number 8 | enterHandler: Middleware.Fn 9 | leaveHandler: Middleware.Fn 10 | } 11 | 12 | export class BaseScene extends Composer< 13 | TContext 14 | > { 15 | id: string 16 | options: SceneOptions 17 | enterHandler: Middleware.Fn 18 | leaveHandler: Middleware.Fn 19 | constructor(id: string, options: SceneOptions) { 20 | const opts = { 21 | handlers: [], 22 | enterHandlers: [], 23 | leaveHandlers: [], 24 | ...options, 25 | } 26 | super(...opts.handlers) 27 | this.id = id 28 | this.options = opts 29 | this.enterHandler = compose(opts.enterHandlers) 30 | this.leaveHandler = compose(opts.leaveHandlers) 31 | } 32 | 33 | set ttl(value: number | undefined) { 34 | this.options.ttl = value 35 | } 36 | 37 | get ttl() { 38 | return this.options.ttl 39 | } 40 | 41 | enter(...fns: Array>) { 42 | this.enterHandler = compose([this.enterHandler, ...fns]) 43 | return this 44 | } 45 | 46 | leave(...fns: Array>) { 47 | this.leaveHandler = compose([this.leaveHandler, ...fns]) 48 | return this 49 | } 50 | 51 | enterMiddleware() { 52 | return this.enterHandler 53 | } 54 | 55 | leaveMiddleware() { 56 | return this.leaveHandler 57 | } 58 | } 59 | 60 | export default BaseScene 61 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | interface Mapping { 2 | string: string 3 | number: number 4 | bigint: bigint 5 | boolean: boolean 6 | symbol: symbol 7 | undefined: undefined 8 | object: object 9 | function: Function 10 | } 11 | 12 | /** 13 | * Checks if a given object has a property with a given name. 14 | * 15 | * Example invocation: 16 | * ```js 17 | * let obj = { 'foo': 'bar', 'baz': () => {} } 18 | * hasProp(obj, 'foo') // true 19 | * hasProp(obj, 'baz') // true 20 | * hasProp(obj, 'abc') // false 21 | * ``` 22 | * 23 | * @param obj An object to test 24 | * @param prop The name of the property 25 | */ 26 | export function hasProp( 27 | obj: O | undefined, 28 | prop: K 29 | ): obj is O & Record { 30 | return obj !== undefined && prop in obj 31 | } 32 | /** 33 | * Checks if a given object has a property with a given name. 34 | * Furthermore performs a `typeof` check on the property if it exists. 35 | * 36 | * Example invocation: 37 | * ```js 38 | * let obj = { 'foo': 'bar', 'baz': () => {} } 39 | * hasPropType(obj, 'foo', 'string') // true 40 | * hasPropType(obj, 'baz', 'function') // true 41 | * hasPropType(obj, 'abc', 'number') // false 42 | * ``` 43 | * 44 | * @param obj An object to test 45 | * @param prop The name of the property 46 | * @param type The type the property is expected to have 47 | */ 48 | export function hasPropType< 49 | O extends {}, 50 | K extends PropertyKey, 51 | T extends keyof Mapping, 52 | V extends Mapping[T] 53 | >(obj: O | undefined, prop: K, type: T): obj is O & Record { 54 | // eslint-disable-next-line valid-typeof 55 | return hasProp(obj, prop) && type === typeof obj[prop] 56 | } 57 | -------------------------------------------------------------------------------- /docs/theme/layouts/default.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{#ifCond model.name '==' project.name}}{{project.name}}{{else}}{{model.name}} | {{project.name}}{{/ifCond}} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | {{> header}} 19 | 20 |
21 |
22 |
23 | {{{contents}}} 24 |
25 | 42 |
43 |
44 | 45 | {{> footer}} 46 | 47 |
48 | 49 | 50 | 51 | {{> analytics}} 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /docs/examples/shop-bot.js: -------------------------------------------------------------------------------- 1 | const Telegraf = require('telegraf') 2 | const Markup = require('telegraf/markup') 3 | 4 | const invoice = { 5 | provider_token: process.env.PROVIDER_TOKEN, 6 | start_parameter: 'time-machine-sku', 7 | title: 'Working Time Machine', 8 | description: 'Want to visit your great-great-great-grandparents? Make a fortune at the races? Shake hands with Hammurabi and take a stroll in the Hanging Gardens? Order our Working Time Machine today!', 9 | currency: 'usd', 10 | photo_url: 'https://img.clipartfest.com/5a7f4b14461d1ab2caaa656bcee42aeb_future-me-fredo-and-pidjin-the-webcomic-time-travel-cartoon_390-240.png', 11 | is_flexible: true, 12 | prices: [ 13 | { label: 'Working Time Machine', amount: 4200 }, 14 | { label: 'Gift wrapping', amount: 1000 } 15 | ], 16 | payload: { 17 | coupon: 'BLACK FRIDAY' 18 | } 19 | } 20 | 21 | const shippingOptions = [ 22 | { 23 | id: 'unicorn', 24 | title: 'Unicorn express', 25 | prices: [{ label: 'Unicorn', amount: 2000 }] 26 | }, 27 | { 28 | id: 'slowpoke', 29 | title: 'Slowpoke mail', 30 | prices: [{ label: 'Slowpoke', amount: 100 }] 31 | } 32 | ] 33 | 34 | const replyOptions = Markup.inlineKeyboard([ 35 | Markup.payButton('💸 Buy'), 36 | Markup.urlButton('❤️', 'http://telegraf.js.org') 37 | ]).extra() 38 | 39 | const bot = new Telegraf(process.env.BOT_TOKEN) 40 | bot.start(({ replyWithInvoice }) => replyWithInvoice(invoice)) 41 | bot.command('buy', ({ replyWithInvoice }) => replyWithInvoice(invoice, replyOptions)) 42 | bot.on('shipping_query', ({ answerShippingQuery }) => answerShippingQuery(true, shippingOptions)) 43 | bot.on('pre_checkout_query', ({ answerPreCheckoutQuery }) => answerPreCheckoutQuery(true)) 44 | bot.on('successful_payment', () => console.log('Woohoo')) 45 | bot.launch() 46 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import { Middleware, NonemptyReadonlyArray } from './types' 4 | import Composer from './composer' 5 | import Context from './context' 6 | 7 | type RouteFn = ( 8 | ctx: TContext 9 | ) => { 10 | route: string 11 | context?: Partial 12 | state?: Partial 13 | } | null 14 | 15 | export class Router 16 | implements Middleware.Obj { 17 | private otherwiseHandler: Middleware = Composer.passThru() 18 | 19 | constructor( 20 | private readonly routeFn: RouteFn, 21 | public handlers = new Map>() 22 | ) { 23 | if (typeof routeFn !== 'function') { 24 | throw new Error('Missing routing function') 25 | } 26 | } 27 | 28 | on(route: string, ...fns: NonemptyReadonlyArray>) { 29 | if (fns.length === 0) { 30 | throw new TypeError('At least one handler must be provided') 31 | } 32 | this.handlers.set(route, Composer.compose(fns)) 33 | return this 34 | } 35 | 36 | otherwise(...fns: NonemptyReadonlyArray>) { 37 | if (fns.length === 0) { 38 | throw new TypeError('At least one otherwise handler must be provided') 39 | } 40 | this.otherwiseHandler = Composer.compose(fns) 41 | return this 42 | } 43 | 44 | middleware() { 45 | return Composer.lazy((ctx) => { 46 | return Promise.resolve(this.routeFn(ctx)).then((result) => { 47 | if (result == null) { 48 | return this.otherwiseHandler 49 | } 50 | Object.assign(ctx, result.context) 51 | Object.assign(ctx.state, result.state) 52 | return this.handlers.get(result.route) ?? this.otherwiseHandler 53 | }) 54 | }) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/scenes/wizard/index.ts: -------------------------------------------------------------------------------- 1 | import Composer from '../../composer' 2 | import Context from '../../context' 3 | import { Middleware } from '../../types' 4 | import SceneContext from '../context' 5 | import WizardContext from './context' 6 | const { compose, unwrap } = Composer 7 | 8 | export class WizardScene> 9 | extends Composer 10 | implements Middleware.Obj> { 11 | options: any 12 | leaveHandler: Middleware.Fn 13 | constructor( 14 | readonly id: string, 15 | options: Middleware.Fn | ReadonlyArray>, 16 | ...steps: Array> 17 | ) { 18 | super() 19 | this.options = 20 | typeof options === 'function' 21 | ? { steps: [options, ...steps], leaveHandlers: [] } 22 | : { steps: steps, leaveHandlers: [], ...options } 23 | this.leaveHandler = compose(this.options.leaveHandlers) 24 | } 25 | 26 | set ttl(value: number | undefined) { 27 | this.options.ttl = value 28 | } 29 | 30 | get ttl() { 31 | return this.options.ttl 32 | } 33 | 34 | leave(...fns: Array>) { 35 | this.leaveHandler = compose([this.leaveHandler, ...fns]) 36 | return this 37 | } 38 | 39 | leaveMiddleware() { 40 | return this.leaveHandler 41 | } 42 | 43 | middleware() { 44 | return Composer.compose>([ 45 | (ctx, next) => { 46 | const wizard = new WizardContext(ctx, this.options.steps) 47 | return next(Object.assign(ctx, { wizard })) 48 | }, 49 | super.middleware(), 50 | (ctx, next) => { 51 | if (ctx.wizard.step === false) { 52 | ctx.wizard.selectStep(0) 53 | return ctx.scene.leave() 54 | } 55 | return unwrap(ctx.wizard.step)(ctx, next) 56 | }, 57 | ]) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Telegraf: Modern Telegram Bot Framework for Node.js 5 | 6 | 7 | 8 | 9 | 10 | 11 | 44 | 45 | 46 |
47 | 48 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /docs/examples/example-bot.js: -------------------------------------------------------------------------------- 1 | const Telegraf = require('telegraf') 2 | const Extra = require('telegraf/extra') 3 | const session = require('telegraf/session') 4 | const { reply, fork } = Telegraf 5 | 6 | const randomPhoto = 'https://picsum.photos/200/300/?random' 7 | 8 | const sayYoMiddleware = fork((ctx) => ctx.reply('yo')) 9 | 10 | const bot = new Telegraf(process.env.BOT_TOKEN) 11 | 12 | // // Register session middleware 13 | bot.use(session()) 14 | 15 | // Register logger middleware 16 | bot.use((ctx, next) => { 17 | const start = new Date() 18 | return next().then(() => { 19 | const ms = new Date() - start 20 | console.log('response time %sms', ms) 21 | }) 22 | }) 23 | 24 | // Login widget events 25 | bot.on('connected_website', ({ reply }) => reply('Website connected')) 26 | 27 | // Telegram passport events 28 | bot.on('passport_data', ({ reply }) => reply('Telegram password connected')) 29 | 30 | // Random location on some text messages 31 | bot.on('text', ({ replyWithLocation }, next) => { 32 | if (Math.random() > 0.2) { 33 | return next() 34 | } 35 | return Promise.all([ 36 | replyWithLocation((Math.random() * 180) - 90, (Math.random() * 180) - 90), 37 | next() 38 | ]) 39 | }) 40 | 41 | // Text messages handling 42 | bot.hears('Hey', sayYoMiddleware, (ctx) => { 43 | ctx.session.heyCounter = ctx.session.heyCounter || 0 44 | ctx.session.heyCounter++ 45 | return ctx.replyWithMarkdown(`_Hey counter:_ ${ctx.session.heyCounter}`) 46 | }) 47 | 48 | // Command handling 49 | bot.command('answer', sayYoMiddleware, (ctx) => { 50 | console.log(ctx.message) 51 | return ctx.reply('*42*', Extra.markdown()) 52 | }) 53 | 54 | bot.command('cat', ({ replyWithPhoto }) => replyWithPhoto(randomPhoto)) 55 | 56 | // Streaming photo, in case Telegram doesn't accept direct URL 57 | bot.command('cat2', ({ replyWithPhoto }) => replyWithPhoto({ url: randomPhoto })) 58 | 59 | // Look ma, reply middleware factory 60 | bot.command('foo', reply('http://coub.com/view/9cjmt')) 61 | 62 | // Wow! RegEx 63 | bot.hears(/reverse (.+)/, ({ match, reply }) => reply(match[1].split('').reverse().join(''))) 64 | 65 | // Launch bot 66 | bot.launch() 67 | -------------------------------------------------------------------------------- /src/stage.ts: -------------------------------------------------------------------------------- 1 | import BaseScene from './scenes/base' 2 | import Composer from './composer' 3 | import Context from './context' 4 | import { Middleware } from './types' 5 | import SceneContext from './scenes/context' 6 | 7 | export class Stage 8 | extends Composer> 9 | implements Middleware.Obj { 10 | options: SceneContext.Options 11 | scenes: Map> 12 | constructor( 13 | scenes: ReadonlyArray> = [], 14 | options?: SceneContext.Options 15 | ) { 16 | super() 17 | this.options = { 18 | sessionName: 'session', 19 | ...options, 20 | } 21 | this.scenes = new Map() 22 | scenes.forEach((scene) => this.register(scene)) 23 | } 24 | 25 | register(...scenes: Array>) { 26 | scenes.forEach((scene) => { 27 | if (!scene || !scene.id || typeof scene.middleware !== 'function') { 28 | throw new Error('telegraf: Unsupported scene') 29 | } 30 | this.scenes.set(scene.id, scene) 31 | }) 32 | return this 33 | } 34 | 35 | middleware() { 36 | const handler = Composer.compose< 37 | TContext, 38 | SceneContext.Extension 39 | >([ 40 | (ctx, next) => { 41 | const scene = new SceneContext(ctx, this.scenes, this.options) 42 | return next(Object.assign(ctx, { scene })) 43 | }, 44 | super.middleware(), 45 | Composer.lazy((ctx) => ctx.scene.current ?? Composer.passThru()), 46 | ]) 47 | return Composer.optional( 48 | (ctx: any) => ctx[this.options.sessionName], 49 | handler 50 | ) 51 | } 52 | 53 | static enter(...args: Parameters['enter']>) { 54 | return (ctx: SceneContext.Extended) => ctx.scene.enter(...args) 55 | } 56 | 57 | static reenter(...args: Parameters['reenter']>) { 58 | return (ctx: SceneContext.Extended) => ctx.scene.reenter(...args) 59 | } 60 | 61 | static leave(...args: Parameters['leave']>) { 62 | return (ctx: SceneContext.Extended) => ctx.scene.leave(...args) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /test/extra.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const Extra = require('../lib/extra') 3 | const Markup = require('../lib/markup') 4 | 5 | test('should generate default options from contructor', (t) => { 6 | const extra = { ...new Extra({ parse_mode: 'LaTeX' }) } 7 | t.deepEqual(extra, { parse_mode: 'LaTeX' }) 8 | }) 9 | 10 | test('should generate default options', (t) => { 11 | const extra = { ...Extra.load({ parse_mode: 'LaTeX' }) } 12 | t.deepEqual(extra, { parse_mode: 'LaTeX' }) 13 | }) 14 | 15 | test('should generate inReplyTo options', (t) => { 16 | const extra = { ...Extra.inReplyTo(42) } 17 | t.deepEqual(extra, { reply_to_message_id: 42 }) 18 | }) 19 | 20 | test('should generate HTML options', (t) => { 21 | const extra = { ...Extra.HTML() } 22 | t.deepEqual(extra, { parse_mode: 'HTML' }) 23 | }) 24 | 25 | test('should generate Markdown options', (t) => { 26 | const extra = { ...Extra.markdown() } 27 | t.deepEqual(extra, { parse_mode: 'Markdown' }) 28 | }) 29 | 30 | test('should generate notifications options', (t) => { 31 | const extra = { ...Extra.notifications(false) } 32 | t.deepEqual(extra, { disable_notification: true }) 33 | }) 34 | 35 | test('should generate web preview options', (t) => { 36 | const extra = { ...Extra.webPreview(false) } 37 | t.deepEqual(extra, { disable_web_page_preview: true }) 38 | }) 39 | 40 | test('should generate markup options', (t) => { 41 | const extra = { ...Extra.markup(Markup.removeKeyboard()) } 42 | t.deepEqual(extra, { reply_markup: { remove_keyboard: true } }) 43 | }) 44 | 45 | test('should generate markup options in functional style', (t) => { 46 | const extra = { ...Extra.markdown().markup((markup) => markup.removeKeyboard()) } 47 | t.deepEqual(extra, { parse_mode: 'Markdown', reply_markup: { remove_keyboard: true } }) 48 | }) 49 | 50 | test('should generate caption options', (t) => { 51 | const extra = { ...Extra.markdown().caption('text') } 52 | t.deepEqual(extra, { parse_mode: 'Markdown', caption: 'text' }) 53 | }) 54 | 55 | test('should generate caption options from static method', (t) => { 56 | const extra = { ...Extra.caption('text') } 57 | t.deepEqual(extra, { caption: 'text' }) 58 | }) 59 | -------------------------------------------------------------------------------- /docs/examples/media-bot.js: -------------------------------------------------------------------------------- 1 | const Telegraf = require('telegraf') 2 | const Extra = require('telegraf/extra') 3 | const fs = require('fs') 4 | 5 | const AnimationUrl1 = 'https://media.giphy.com/media/ya4eevXU490Iw/giphy.gif' 6 | const AnimationUrl2 = 'https://media.giphy.com/media/LrmU6jXIjwziE/giphy.gif' 7 | 8 | const bot = new Telegraf(process.env.BOT_TOKEN) 9 | 10 | bot.command('local', (ctx) => ctx.replyWithPhoto({ source: '/cats/cat1.jpeg' })) 11 | bot.command('stream', (ctx) => ctx.replyWithPhoto({ source: fs.createReadStream('/cats/cat2.jpeg') })) 12 | bot.command('buffer', (ctx) => ctx.replyWithPhoto({ source: fs.readFileSync('/cats/cat3.jpeg') })) 13 | bot.command('pipe', (ctx) => ctx.replyWithPhoto({ url: 'https://picsum.photos/200/300/?random' })) 14 | bot.command('url', (ctx) => ctx.replyWithPhoto('https://picsum.photos/200/300/?random')) 15 | bot.command('animation', (ctx) => ctx.replyWithAnimation(AnimationUrl1)) 16 | bot.command('pipe_animation', (ctx) => ctx.replyWithAnimation({ url: AnimationUrl1 })) 17 | 18 | bot.command('caption', (ctx) => ctx.replyWithPhoto( 19 | 'https://picsum.photos/200/300/?random', 20 | Extra.caption('Caption *text*').markdown() 21 | )) 22 | 23 | bot.command('album', (ctx) => { 24 | ctx.replyWithMediaGroup([ 25 | { 26 | media: 'AgADBAADXME4GxQXZAc6zcjjVhXkE9FAuxkABAIQ3xv265UJKGYEAAEC', 27 | caption: 'From file_id', 28 | type: 'photo' 29 | }, 30 | { 31 | media: 'https://picsum.photos/200/500/', 32 | caption: 'From URL', 33 | type: 'photo' 34 | }, 35 | { 36 | media: { url: 'https://picsum.photos/200/300/?random' }, 37 | caption: 'Piped from URL', 38 | type: 'photo' 39 | }, 40 | { 41 | media: { source: '/cats/cat1.jpeg' }, 42 | caption: 'From file', 43 | type: 'photo' 44 | }, 45 | { 46 | media: { source: fs.createReadStream('/cats/cat2.jpeg') }, 47 | caption: 'From stream', 48 | type: 'photo' 49 | }, 50 | { 51 | media: { source: fs.readFileSync('/cats/cat3.jpeg') }, 52 | caption: 'From buffer', 53 | type: 'photo' 54 | } 55 | ]) 56 | }) 57 | 58 | bot.command('edit_media', (ctx) => ctx.replyWithAnimation(AnimationUrl1, Extra.markup((m) => 59 | m.inlineKeyboard([ 60 | m.callbackButton('Change media', 'swap_media') 61 | ]) 62 | ))) 63 | 64 | bot.action('swap_media', (ctx) => ctx.editMessageMedia({ 65 | type: 'animation', 66 | media: AnimationUrl2 67 | })) 68 | 69 | bot.launch() 70 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "telegraf", 3 | "version": "3.38.0", 4 | "description": "Modern Telegram Bot Framework", 5 | "license": "MIT", 6 | "author": "Vitaly Domnikov ", 7 | "homepage": "https://github.com/telegraf/telegraf#readme", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+ssh://git@github.com/telegraf/telegraf.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/telegraf/telegraf/issues" 14 | }, 15 | "main": "lib/index.js", 16 | "exports": { 17 | "import": "./lib/index.mjs", 18 | "require": "./lib/index.js" 19 | }, 20 | "files": [ 21 | "bin/*", 22 | "lib/**/*.js", 23 | "lib/index.mjs", 24 | "typings/**/*.d.ts" 25 | ], 26 | "bin": { 27 | "telegraf": "./bin/telegraf" 28 | }, 29 | "scripts": { 30 | "lint": "eslint src/", 31 | "pretest": "tsc", 32 | "test": "ava test/*", 33 | "typedoc": "typedoc", 34 | "clean": "git clean -fX lib/ typings/", 35 | "typecheck": "tsc --noEmit", 36 | "prepare": "tsc && mjs-entry src/index.ts >lib/index.mjs" 37 | }, 38 | "husky": { 39 | "hooks": { 40 | "pre-commit": "npm test && npm run lint" 41 | } 42 | }, 43 | "type": "commonjs", 44 | "engines": { 45 | "node": ">=12" 46 | }, 47 | "types": "./typings/index.d.ts", 48 | "dependencies": { 49 | "debug": "^4.0.1", 50 | "minimist": "^1.2.0", 51 | "module-alias": "^2.2.2", 52 | "node-fetch": "^2.2.0", 53 | "sandwich-stream": "^2.0.1", 54 | "typegram": "^2.2.1" 55 | }, 56 | "devDependencies": { 57 | "@types/debug": "^4.1.5", 58 | "@types/node": "^13.1.0", 59 | "@types/node-fetch": "^2.5.7", 60 | "@typescript-eslint/eslint-plugin": "^3.5.0", 61 | "@typescript-eslint/parser": "^3.5.0", 62 | "ava": "^3.0.0", 63 | "eslint": "^7.4.0", 64 | "eslint-config-prettier": "^6.11.0", 65 | "eslint-config-standard": "^14.1.0", 66 | "eslint-config-standard-with-typescript": "^18.0.2", 67 | "eslint-plugin-ava": "^10.0.0", 68 | "eslint-plugin-import": "^2.2.0", 69 | "eslint-plugin-node": "^11.0.0", 70 | "eslint-plugin-prettier": "^3.1.4", 71 | "eslint-plugin-promise": "^4.0.0", 72 | "eslint-plugin-standard": "^4.0.0", 73 | "husky": "^4.2.0", 74 | "mjs-entry": "gist:de6257751f54b3c66319bae8d2a8aea0", 75 | "prettier": "^2.0.5", 76 | "typedoc": "^0.17.4", 77 | "typescript": "4.0.2" 78 | }, 79 | "keywords": [ 80 | "telegraf", 81 | "telegram", 82 | "telegram bot api", 83 | "bot", 84 | "botapi", 85 | "bot framework" 86 | ] 87 | } 88 | -------------------------------------------------------------------------------- /bin/telegraf: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const debug = require('debug') 4 | const path = require('path') 5 | const parse = require('minimist') 6 | const { addAlias } = require('module-alias') 7 | const Telegraf = require('../') 8 | 9 | const log = debug('telegraf:cli') 10 | 11 | const help = () => { 12 | console.log(`Usage: telegraf [opts] 13 | -t Bot token [$BOT_TOKEN] 14 | -d Webhook domain 15 | -H Webhook host [0.0.0.0] 16 | -p Webhook port [$PORT or 3000] 17 | -s Stop on error 18 | -l Enable logs 19 | -h Show this help message`) 20 | } 21 | 22 | const args = parse(process.argv, { 23 | alias: { 24 | t: 'token', 25 | d: 'domain', 26 | H: 'host', 27 | h: 'help', 28 | l: 'logs', 29 | s: 'stop', 30 | p: 'port' 31 | }, 32 | boolean: ['h', 'l', 's'], 33 | default: { 34 | H: '0.0.0.0', 35 | p: process.env.PORT || 3000 36 | } 37 | }) 38 | 39 | if (args.help) { 40 | help() 41 | process.exit(0) 42 | } 43 | 44 | const token = args.token || process.env.BOT_TOKEN 45 | const domain = args.domain || process.env.BOT_DOMAIN 46 | if (!token) { 47 | console.error('Please supply Bot Token') 48 | help() 49 | process.exit(1) 50 | } 51 | 52 | let [, , file] = args._ 53 | 54 | if (!file) { 55 | try { 56 | const packageJson = require(path.resolve(process.cwd(), 'package.json')) 57 | file = packageJson.main || 'index.js' 58 | } catch (err) { 59 | } 60 | } 61 | 62 | if (!file) { 63 | console.error('Please supply a bot handler file.\n') 64 | help() 65 | process.exit(1) 66 | } 67 | 68 | if (file[0] !== '/') { 69 | file = path.resolve(process.cwd(), file) 70 | } 71 | 72 | let botHandler 73 | let httpHandler 74 | let tlsOptions 75 | 76 | try { 77 | if (args.logs) { 78 | debug.enable('telegraf:*') 79 | } 80 | addAlias('telegraf', path.join(__dirname, '../')) 81 | const mod = require(file) 82 | botHandler = mod.botHandler || mod 83 | httpHandler = mod.httpHandler 84 | tlsOptions = mod.tlsOptions 85 | } catch (err) { 86 | console.error(`Error importing ${file}`, err.stack) 87 | process.exit(1) 88 | } 89 | 90 | const config = {} 91 | if (domain) { 92 | config.webhook = { 93 | tlsOptions, 94 | host: args.host, 95 | port: args.port, 96 | domain: domain, 97 | cb: httpHandler 98 | } 99 | } 100 | 101 | const bot = new Telegraf(token) 102 | if (!args.stop) { 103 | bot.catch(log) 104 | } 105 | bot.use(botHandler) 106 | 107 | log(`Starting module ${file}`) 108 | bot.launch(config) 109 | -------------------------------------------------------------------------------- /src/extra.ts: -------------------------------------------------------------------------------- 1 | import { ExtraReplyMessage, ParseMode } from './telegram-types' 2 | import Markup from './markup' 3 | import { Message } from 'typegram' 4 | 5 | interface CaptionedExtra extends Omit { 6 | caption: string 7 | } 8 | 9 | class Extra { 10 | reply_to_message_id?: number 11 | disable_notification?: boolean 12 | disable_web_page_preview?: boolean 13 | reply_markup?: ExtraReplyMessage['reply_markup'] 14 | parse_mode?: ParseMode 15 | constructor(opts?: ExtraReplyMessage) { 16 | this.load(opts) 17 | } 18 | 19 | load(opts: ExtraReplyMessage = {}) { 20 | return Object.assign(this, opts) 21 | } 22 | 23 | inReplyTo(messageId: Message['message_id']) { 24 | this.reply_to_message_id = messageId 25 | return this 26 | } 27 | 28 | notifications(value = true) { 29 | this.disable_notification = !value 30 | return this 31 | } 32 | 33 | webPreview(value = true) { 34 | this.disable_web_page_preview = !value 35 | return this 36 | } 37 | 38 | markup( 39 | markup: 40 | | ExtraReplyMessage['reply_markup'] 41 | | ((m: Markup) => ExtraReplyMessage['reply_markup']) 42 | ) { 43 | if (typeof markup === 'function') { 44 | markup = markup(new Markup()) 45 | } 46 | this.reply_markup = markup != null ? { ...markup } : undefined 47 | return this 48 | } 49 | 50 | HTML(value = true) { 51 | this.parse_mode = value ? 'HTML' : undefined 52 | return this 53 | } 54 | 55 | markdown(value = true) { 56 | this.parse_mode = value ? 'Markdown' : undefined 57 | return this 58 | } 59 | 60 | caption(caption: string): CaptionedExtra { 61 | const me = (this as unknown) as CaptionedExtra 62 | me.caption = caption 63 | return me 64 | } 65 | 66 | static inReplyTo(messageId: number) { 67 | return new Extra().inReplyTo(messageId) 68 | } 69 | 70 | static notifications(value?: boolean) { 71 | return new Extra().notifications(value) 72 | } 73 | 74 | static webPreview(value?: boolean) { 75 | return new Extra().webPreview(value) 76 | } 77 | 78 | static load(opts: ExtraReplyMessage) { 79 | return new Extra(opts) 80 | } 81 | 82 | static markup(markup: ExtraReplyMessage['reply_markup']) { 83 | return new Extra().markup(markup) 84 | } 85 | 86 | static HTML(value?: boolean) { 87 | return new Extra().HTML(value) 88 | } 89 | 90 | static markdown(value?: boolean) { 91 | return new Extra().markdown(value) 92 | } 93 | 94 | static caption(caption: string) { 95 | return new Extra().caption(caption) 96 | } 97 | 98 | static readonly Markup = Markup 99 | } 100 | 101 | export = Extra 102 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ![Telegraf](docs/header.png) 2 | [![Bot API Version](https://img.shields.io/badge/Bot%20API-v4.8-f36caf.svg?style=flat-square)](https://core.telegram.org/bots/api) 3 | [![NPM Version](https://img.shields.io/npm/v/telegraf.svg?style=flat-square)](https://www.npmjs.com/package/telegraf) 4 | [![node](https://img.shields.io/node/v/telegraf.svg?style=flat-square)](https://www.npmjs.com/package/telegraf) 5 | [![Build Status](https://img.shields.io/travis/telegraf/telegraf.svg?branch=master&style=flat-square)](https://travis-ci.org/telegraf/telegraf) 6 | [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square)](http://standardjs.com/) 7 | [![Community Chat](https://img.shields.io/badge/Community-Chat-blueChat?style=flat-square&logo=telegram)](https://t.me/TelegrafJSChat) 8 | 9 | ## Introduction 10 | 11 | Bots are special [Telegram](https://telegram.org) accounts designed to handle messages automatically. 12 | Users can interact with bots by sending them command messages in private or group chats. 13 | These accounts serve as an interface for code running somewhere on your server. 14 | 15 | ### Features 16 | 17 | - Full [Telegram Bot API 4.8](https://core.telegram.org/bots/api) support 18 | - [Telegram Payment Platform](https://telegram.org/blog/payments) 19 | - [HTML5 Games](https://core.telegram.org/bots/api#games) 20 | - [Inline mode](https://core.telegram.org/bots/api#inline-mode) 21 | - Incredibly fast 22 | - [Firebase](https://firebase.google.com/products/functions/)/[Glitch](https://dashing-light.glitch.me)/[Heroku](https://devcenter.heroku.com/articles/getting-started-with-nodejs#introduction)/[AWS **λ**](https://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-handler.html)/Whatever ready 23 | - `http/https/fastify/Connect.js/express.js` compatible webhooks 24 | - Easy to extend 25 | - `TypeScript` typings 26 | 27 | ### Installation 28 | 29 | ``` 30 | $ npm install telegraf 31 | ``` 32 | or using `yarn`: 33 | ``` 34 | $ yarn add telegraf 35 | ``` 36 | 37 | ### Resources 38 | 39 | * [Developer docs](http://telegraf.js.org) 40 | * [Community chat](https://t.me/TelegrafJSChat) 41 | * [Community chat (Russian)](https://t.me/telegraf_ru) 42 | 43 | ### Examples 44 | 45 | ```js 46 | const { Telegraf } = require('telegraf') 47 | 48 | const bot = new Telegraf(process.env.BOT_TOKEN) 49 | bot.start((ctx) => ctx.reply('Welcome!')) 50 | bot.help((ctx) => ctx.reply('Send me a sticker')) 51 | bot.on('sticker', (ctx) => ctx.reply('👍')) 52 | bot.hears('hi', (ctx) => ctx.reply('Hey there')) 53 | bot.launch() 54 | ``` 55 | 56 | ```js 57 | const { Telegraf } = require('telegraf') 58 | 59 | const bot = new Telegraf(process.env.BOT_TOKEN) 60 | bot.command('oldschool', (ctx) => ctx.reply('Hello')) 61 | bot.command('modern', ({ reply }) => reply('Yo')) 62 | bot.command('hipster', Telegraf.reply('λ')) 63 | bot.launch() 64 | ``` 65 | 66 | There's some cool [examples too](docs/examples/). 67 | -------------------------------------------------------------------------------- /src/scenes/context.ts: -------------------------------------------------------------------------------- 1 | import BaseScene from './base' 2 | import Composer from '../composer' 3 | import Context from '../context' 4 | import d from 'debug' 5 | const debug = d('telegraf:scenes:context') 6 | 7 | const noop = () => Promise.resolve() 8 | const now = () => Math.floor(Date.now() / 1000) 9 | 10 | // eslint-disable-next-line @typescript-eslint/no-namespace 11 | namespace SceneContext { 12 | export interface Options { 13 | sessionName: string 14 | ttl?: number 15 | default?: any 16 | } 17 | } 18 | 19 | // eslint-disable-next-line no-redeclare 20 | class SceneContext { 21 | constructor( 22 | private readonly ctx: TContext, 23 | private readonly scenes: Map>, 24 | private readonly options: SceneContext.Options 25 | ) {} 26 | 27 | get session() { 28 | const sessionName = this.options.sessionName 29 | let session = (this.ctx as any)[sessionName].__scenes ?? {} 30 | if (session.expires < now()) { 31 | session = {} 32 | } 33 | ;(this.ctx as any)[sessionName].__scenes = session 34 | return session 35 | } 36 | 37 | get state() { 38 | this.session.state = this.session.state || {} 39 | return this.session.state 40 | } 41 | 42 | set state(value) { 43 | this.session.state = { ...value } 44 | } 45 | 46 | get current() { 47 | const sceneId = this.session.current || this.options.default 48 | return sceneId && this.scenes.has(sceneId) ? this.scenes.get(sceneId) : null 49 | } 50 | 51 | reset() { 52 | const sessionName = this.options.sessionName 53 | delete (this.ctx as any)[sessionName].__scenes 54 | } 55 | 56 | enter(sceneId: string, initialState: any, silent?: boolean) { 57 | if (!this.scenes.has(sceneId)) { 58 | throw new Error(`Can't find scene: ${sceneId}`) 59 | } 60 | const leave = silent ? noop() : this.leave() 61 | return leave.then(() => { 62 | debug('Enter scene', sceneId, initialState, silent) 63 | this.session.current = sceneId 64 | this.state = initialState 65 | const ttl = this.current?.ttl ?? this.options.ttl 66 | if (ttl) { 67 | this.session.expires = now() + ttl 68 | } 69 | if (!this.current || silent) { 70 | return Promise.resolve() 71 | } 72 | const handler = 73 | 'enterMiddleware' in this.current && 74 | typeof this.current.enterMiddleware === 'function' 75 | ? this.current.enterMiddleware() 76 | : this.current.middleware() 77 | return handler(this.ctx, noop) 78 | }) 79 | } 80 | 81 | reenter() { 82 | return this.enter(this.session.current, this.state) 83 | } 84 | 85 | async leave() { 86 | debug('Leave scene') 87 | const handler = 88 | this.current?.leaveMiddleware != null 89 | ? this.current.leaveMiddleware() 90 | : Composer.passThru() 91 | await handler(this.ctx, noop) 92 | return this.reset() 93 | } 94 | } 95 | 96 | // eslint-disable-next-line 97 | namespace SceneContext { 98 | export interface Extension { 99 | scene: SceneContext 100 | } 101 | export type Extended = TContext & 102 | Extension 103 | } 104 | 105 | export = SceneContext 106 | -------------------------------------------------------------------------------- /code_of_conduct.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at oss@vitaly.codes. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /docs/examples/keyboard-bot.js: -------------------------------------------------------------------------------- 1 | const Telegraf = require('telegraf') 2 | const Extra = require('telegraf/extra') 3 | const Markup = require('telegraf/markup') 4 | 5 | const bot = new Telegraf(process.env.BOT_TOKEN) 6 | 7 | bot.use(Telegraf.log()) 8 | 9 | bot.command('onetime', ({ reply }) => 10 | reply('One time keyboard', Markup 11 | .keyboard(['/simple', '/inline', '/pyramid']) 12 | .oneTime() 13 | .resize() 14 | .extra() 15 | ) 16 | ) 17 | 18 | bot.command('custom', ({ reply }) => { 19 | return reply('Custom buttons keyboard', Markup 20 | .keyboard([ 21 | ['🔍 Search', '😎 Popular'], // Row1 with 2 buttons 22 | ['☸ Setting', '📞 Feedback'], // Row2 with 2 buttons 23 | ['📢 Ads', '⭐️ Rate us', '👥 Share'] // Row3 with 3 buttons 24 | ]) 25 | .oneTime() 26 | .resize() 27 | .extra() 28 | ) 29 | }) 30 | 31 | bot.hears('🔍 Search', ctx => ctx.reply('Yay!')) 32 | bot.hears('📢 Ads', ctx => ctx.reply('Free hugs. Call now!')) 33 | 34 | bot.command('special', (ctx) => { 35 | return ctx.reply('Special buttons keyboard', Extra.markup((markup) => { 36 | return markup.resize() 37 | .keyboard([ 38 | markup.contactRequestButton('Send contact'), 39 | markup.locationRequestButton('Send location') 40 | ]) 41 | })) 42 | }) 43 | 44 | bot.command('pyramid', (ctx) => { 45 | return ctx.reply('Keyboard wrap', Extra.markup( 46 | Markup.keyboard(['one', 'two', 'three', 'four', 'five', 'six'], { 47 | wrap: (btn, index, currentRow) => currentRow.length >= (index + 1) / 2 48 | }) 49 | )) 50 | }) 51 | 52 | bot.command('simple', (ctx) => { 53 | return ctx.replyWithHTML('Coke or Pepsi?', Extra.markup( 54 | Markup.keyboard(['Coke', 'Pepsi']) 55 | )) 56 | }) 57 | 58 | bot.command('inline', (ctx) => { 59 | return ctx.reply('Coke or Pepsi?', Extra.HTML().markup((m) => 60 | m.inlineKeyboard([ 61 | m.callbackButton('Coke', 'Coke'), 62 | m.callbackButton('Pepsi', 'Pepsi') 63 | ]))) 64 | }) 65 | 66 | bot.command('random', (ctx) => { 67 | return ctx.reply('random example', 68 | Markup.inlineKeyboard([ 69 | Markup.callbackButton('Coke', 'Coke'), 70 | Markup.callbackButton('Dr Pepper', 'Dr Pepper', Math.random() > 0.5), 71 | Markup.callbackButton('Pepsi', 'Pepsi') 72 | ]).extra() 73 | ) 74 | }) 75 | 76 | bot.command('caption', (ctx) => { 77 | return ctx.replyWithPhoto({ url: 'https://picsum.photos/200/300/?random' }, 78 | Extra.load({ caption: 'Caption' }) 79 | .markdown() 80 | .markup((m) => 81 | m.inlineKeyboard([ 82 | m.callbackButton('Plain', 'plain'), 83 | m.callbackButton('Italic', 'italic') 84 | ]) 85 | ) 86 | ) 87 | }) 88 | 89 | bot.hears(/\/wrap (\d+)/, (ctx) => { 90 | return ctx.reply('Keyboard wrap', Extra.markup( 91 | Markup.keyboard(['one', 'two', 'three', 'four', 'five', 'six'], { 92 | columns: parseInt(ctx.match[1]) 93 | }) 94 | )) 95 | }) 96 | 97 | bot.action('Dr Pepper', (ctx, next) => { 98 | return ctx.reply('👍').then(() => next()) 99 | }) 100 | 101 | bot.action('plain', async (ctx) => { 102 | await ctx.answerCbQuery() 103 | await ctx.editMessageCaption('Caption', Markup.inlineKeyboard([ 104 | Markup.callbackButton('Plain', 'plain'), 105 | Markup.callbackButton('Italic', 'italic') 106 | ])) 107 | }) 108 | 109 | bot.action('italic', async (ctx) => { 110 | await ctx.answerCbQuery() 111 | await ctx.editMessageCaption('_Caption_', Extra.markdown().markup(Markup.inlineKeyboard([ 112 | Markup.callbackButton('Plain', 'plain'), 113 | Markup.callbackButton('* Italic *', 'italic') 114 | ]))) 115 | }) 116 | 117 | bot.action(/.+/, (ctx) => { 118 | return ctx.answerCbQuery(`Oh, ${ctx.match[0]}! Great choice`) 119 | }) 120 | 121 | bot.launch() 122 | -------------------------------------------------------------------------------- /src/core/replicators.ts: -------------------------------------------------------------------------------- 1 | import * as tt from '../telegram-types' 2 | import Markup from '../markup' 3 | const { formatHTML } = Markup 4 | 5 | export const copyMethods = { 6 | audio: 'sendAudio', 7 | contact: 'sendContact', 8 | document: 'sendDocument', 9 | location: 'sendLocation', 10 | photo: 'sendPhoto', 11 | sticker: 'sendSticker', 12 | text: 'sendMessage', 13 | venue: 'sendVenue', 14 | video: 'sendVideo', 15 | video_note: 'sendVideoNote', 16 | animation: 'sendAnimation', 17 | voice: 'sendVoice', 18 | poll: 'sendPoll', 19 | } as const 20 | 21 | export function text( 22 | message: tt.Message.TextMessage 23 | ): tt.MakeExtra<'sendMessage'> { 24 | return { 25 | reply_markup: message.reply_markup, 26 | parse_mode: 'HTML', 27 | text: formatHTML(message.text, message.entities), 28 | } 29 | } 30 | export function contact( 31 | message: tt.Message.ContactMessage 32 | ): tt.MakeExtra<'sendContact'> { 33 | return { 34 | reply_markup: message.reply_markup, 35 | phone_number: message.contact.phone_number, 36 | first_name: message.contact.first_name, 37 | last_name: message.contact.last_name, 38 | } 39 | } 40 | export function location( 41 | message: tt.Message.LocationMessage 42 | ): tt.MakeExtra<'sendLocation'> { 43 | return { 44 | reply_markup: message.reply_markup, 45 | latitude: message.location.latitude, 46 | longitude: message.location.longitude, 47 | } 48 | } 49 | export function venue( 50 | message: tt.Message.VenueMessage 51 | ): tt.MakeExtra<'sendVenue'> { 52 | return { 53 | reply_markup: message.reply_markup, 54 | latitude: message.venue.location.latitude, 55 | longitude: message.venue.location.longitude, 56 | title: message.venue.title, 57 | address: message.venue.address, 58 | foursquare_id: message.venue.foursquare_id, 59 | } 60 | } 61 | export function voice( 62 | message: tt.Message.VoiceMessage 63 | ): tt.MakeExtra<'sendVoice'> { 64 | return { 65 | reply_markup: message.reply_markup, 66 | voice: message.voice.file_id, 67 | duration: message.voice.duration, 68 | caption: formatHTML(message.caption, message.caption_entities), 69 | parse_mode: 'HTML', 70 | } 71 | } 72 | export function audio( 73 | message: tt.Message.AudioMessage 74 | ): tt.MakeExtra<'sendAudio'> { 75 | return { 76 | reply_markup: message.reply_markup, 77 | audio: message.audio.file_id, 78 | thumb: message.audio.thumb?.file_id, 79 | duration: message.audio.duration, 80 | performer: message.audio.performer, 81 | title: message.audio.title, 82 | caption: formatHTML(message.caption, message.caption_entities), 83 | parse_mode: 'HTML', 84 | } 85 | } 86 | export function video( 87 | message: tt.Message.VideoMessage 88 | ): tt.MakeExtra<'sendVideo'> { 89 | return { 90 | reply_markup: message.reply_markup, 91 | video: message.video.file_id, 92 | thumb: message.video.thumb?.file_id, 93 | caption: formatHTML(message.caption, message.caption_entities), 94 | parse_mode: 'HTML', 95 | duration: message.video.duration, 96 | width: message.video.width, 97 | height: message.video.height, 98 | } 99 | } 100 | export function document( 101 | message: tt.Message.DocumentMessage 102 | ): tt.MakeExtra<'sendDocument'> { 103 | return { 104 | reply_markup: message.reply_markup, 105 | document: message.document.file_id, 106 | caption: formatHTML(message.caption, message.caption_entities), 107 | parse_mode: 'HTML', 108 | } 109 | } 110 | export function sticker( 111 | message: tt.Message.StickerMessage 112 | ): tt.MakeExtra<'sendSticker'> { 113 | return { 114 | reply_markup: message.reply_markup, 115 | sticker: message.sticker.file_id, 116 | } 117 | } 118 | export function photo( 119 | message: tt.Message.PhotoMessage 120 | ): tt.MakeExtra<'sendPhoto'> { 121 | return { 122 | reply_markup: message.reply_markup, 123 | photo: message.photo[message.photo.length - 1].file_id, 124 | parse_mode: 'HTML', 125 | caption: formatHTML(message.caption, message.caption_entities), 126 | } 127 | } 128 | // eslint-disable-next-line @typescript-eslint/naming-convention 129 | export function video_note( 130 | message: tt.Message.VideoNoteMessage 131 | ): tt.MakeExtra<'sendVideoNote'> { 132 | return { 133 | reply_markup: message.reply_markup, 134 | video_note: message.video_note.file_id, 135 | thumb: message.video_note.thumb?.file_id, 136 | length: message.video_note.length, 137 | duration: message.video_note.duration, 138 | } 139 | } 140 | export function animation( 141 | message: tt.Message.AnimationMessage 142 | ): tt.MakeExtra<'sendAnimation'> { 143 | return { 144 | reply_markup: message.reply_markup, 145 | animation: message.animation.file_id, 146 | thumb: message.animation.thumb?.file_id, 147 | duration: message.animation.duration, 148 | } 149 | } 150 | export function poll( 151 | message: tt.Message.PollMessage 152 | ): tt.MakeExtra<'sendPoll'> { 153 | return { 154 | question: message.poll.question, 155 | type: message.poll.type, 156 | is_anonymous: message.poll.is_anonymous, 157 | allows_multiple_answers: message.poll.allows_multiple_answers, 158 | correct_option_id: message.poll.correct_option_id, 159 | options: message.poll.options.map(({ text }) => text), 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/telegram-types.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import * as TT from 'typegram' 4 | export * from 'typegram' 5 | 6 | export type ChatAction = TT.Opts<'sendChatAction'>['action'] 7 | 8 | export type ChatType = TT.Chat['type'] 9 | 10 | export type UpdateType = 11 | | 'callback_query' 12 | | 'channel_post' 13 | | 'chosen_inline_result' 14 | | 'edited_channel_post' 15 | | 'edited_message' 16 | | 'inline_query' 17 | | 'message' 18 | | 'pre_checkout_query' 19 | | 'shipping_query' 20 | | 'poll' 21 | | 'poll_answer' 22 | 23 | export type MessageSubTypes = 24 | | 'voice' 25 | | 'video_note' 26 | | 'video' 27 | | 'venue' 28 | | 'text' 29 | | 'supergroup_chat_created' 30 | | 'successful_payment' 31 | | 'sticker' 32 | | 'pinned_message' 33 | | 'photo' 34 | | 'new_chat_title' 35 | | 'new_chat_photo' 36 | | 'new_chat_members' 37 | | 'migrate_to_chat_id' 38 | | 'migrate_from_chat_id' 39 | | 'location' 40 | | 'left_chat_member' 41 | | 'invoice' 42 | | 'group_chat_created' 43 | | 'game' 44 | | 'document' 45 | | 'delete_chat_photo' 46 | | 'contact' 47 | | 'channel_chat_created' 48 | | 'audio' 49 | | 'passport_data' 50 | | 'connected_website' 51 | | 'animation' 52 | 53 | /** @deprecated use `InputMedia` */ 54 | export type MessageMedia = TT.InputMedia 55 | 56 | /** 57 | * Sending video notes by a URL is currently unsupported 58 | */ 59 | export type InputFileVideoNote = Exclude 60 | 61 | /** 62 | * Create an `Extra*` type from the arguments of a given method `M extends keyof Telegram` but `Omit`ting fields with key `K` from it. 63 | * 64 | * Note that `chat_id` may not be specified in `K` because it is `Omit`ted by default. 65 | */ 66 | export type MakeExtra< 67 | M extends keyof TT.Telegram, 68 | K extends keyof Omit, 'chat_id'> = never 69 | > = Omit, 'chat_id' | K> 70 | 71 | export type ExtraAddStickerToSet = MakeExtra< 72 | 'addStickerToSet', 73 | 'name' | 'user_id' 74 | > 75 | export type ExtraAnimation = MakeExtra<'sendAnimation', 'animation'> 76 | export type ExtraAnswerInlineQuery = MakeExtra< 77 | 'answerInlineQuery', 78 | 'inline_query_id' | 'results' 79 | > 80 | export type ExtraAudio = MakeExtra<'sendAudio', 'audio'> 81 | export type ExtraContact = MakeExtra< 82 | 'sendContact', 83 | 'phone_number' | 'first_name' 84 | > 85 | export type ExtraCreateNewStickerSet = MakeExtra< 86 | 'createNewStickerSet', 87 | 'name' | 'title' | 'user_id' 88 | > 89 | export type ExtraDice = MakeExtra<'sendDice'> 90 | export type ExtraDocument = MakeExtra<'sendDocument', 'document'> 91 | /** @deprecated */ 92 | export type ExtraEditMessage = ExtraReplyMessage 93 | export type ExtraEditMessageText = MakeExtra< 94 | 'editMessageText', 95 | 'message_id' | 'inline_message_id' 96 | > 97 | export type ExtraGame = MakeExtra<'sendGame', 'game_short_name'> 98 | export interface ExtraInvoice extends ExtraReplyMessage { 99 | /** 100 | * Inline keyboard. If empty, one 'Pay total price' button will be shown. If not empty, the first button must be a Pay button. 101 | */ 102 | reply_markup?: TT.InlineKeyboardMarkup 103 | 104 | /** 105 | * Does not exist, see https://core.telegram.org/bots/api#sendinvoice 106 | */ 107 | disable_web_page_preview?: never 108 | 109 | /** 110 | * Does not exist, see https://core.telegram.org/bots/api#sendinvoice 111 | */ 112 | parse_mode?: never 113 | } 114 | export type ExtraLocation = MakeExtra<'sendLocation', 'latitude' | 'longitude'> 115 | export type ExtraMediaGroup = MakeExtra<'sendMediaGroup', 'media'> 116 | export type ExtraPhoto = MakeExtra<'sendPhoto', 'photo'> 117 | export type ExtraPoll = MakeExtra<'sendPoll', 'question' | 'options' | 'type'> 118 | export type ExtraPromoteChatMember = MakeExtra<'promoteChatMember', 'user_id'> 119 | export type ExtraReplyMessage = MakeExtra<'sendMessage', 'text'> 120 | export type ExtraRestrictChatMember = MakeExtra<'restrictChatMember', 'user_id'> 121 | export type ExtraSticker = MakeExtra<'sendSticker', 'sticker'> 122 | export type ExtraStopPoll = MakeExtra<'stopPoll', 'message_id'> 123 | export type ExtraVenue = MakeExtra< 124 | 'sendVenue', 125 | 'latitude' | 'longitude' | 'title' | 'address' 126 | > 127 | export type ExtraVideo = MakeExtra<'sendVideo', 'video'> 128 | export type ExtraVideoNote = MakeExtra<'sendVideoNote', 'video_note'> 129 | export type ExtraVoice = MakeExtra<'sendVoice', 'voice'> 130 | 131 | export type IncomingMessage = TT.Message 132 | 133 | export interface NewInvoiceParameters { 134 | /** 135 | * Product name, 1-32 characters 136 | */ 137 | title: string 138 | 139 | /** 140 | * Product description, 1-255 characters 141 | */ 142 | description: string 143 | 144 | /** 145 | * Bot-defined invoice payload, 1-128 bytes. This will not be displayed to the user, use for your internal processes. 146 | */ 147 | payload: string 148 | 149 | /** 150 | * Payments provider token, obtained via Botfather 151 | */ 152 | provider_token: string 153 | 154 | /** 155 | * Unique deep-linking parameter that can be used to generate this invoice when used as a start parameter 156 | */ 157 | start_parameter: string 158 | 159 | /** 160 | * Three-letter ISO 4217 currency code, see more on currencies 161 | */ 162 | currency: string 163 | 164 | /** 165 | * Price breakdown, a list of components (e.g. product price, tax, discount, delivery cost, delivery tax, bonus, etc.) 166 | */ 167 | prices: TT.LabeledPrice[] 168 | 169 | /** 170 | * URL of the product photo for the invoice. Can be a photo of the goods or a marketing image for a service. People like it better when they see what they are paying for. 171 | */ 172 | photo_url?: string 173 | 174 | /** 175 | * Photo size 176 | */ 177 | photo_size?: number 178 | 179 | /** 180 | * Photo width 181 | */ 182 | photo_width?: number 183 | 184 | /** 185 | * Photo height 186 | */ 187 | photo_height?: number 188 | 189 | /** 190 | * Pass True, if you require the user's full name to complete the order 191 | */ 192 | need_name?: true 193 | 194 | /** 195 | * Pass True, if you require the user's phone number to complete the order 196 | */ 197 | need_phone_number?: true 198 | 199 | /** 200 | * Pass True, if you require the user's email to complete the order 201 | */ 202 | need_email?: true 203 | 204 | /** 205 | * Pass True, if you require the user's shipping address to complete the order 206 | */ 207 | need_shipping_address?: true 208 | 209 | /** 210 | * Pass True, if the final price depends on the shipping method 211 | */ 212 | is_flexible?: true 213 | } 214 | -------------------------------------------------------------------------------- /src/markup.js: -------------------------------------------------------------------------------- 1 | const escapeHTML = (string) => { 2 | return string 3 | .replace(/&/g, '&') 4 | .replace(//g, '>') 6 | .replace(/"/g, '"') 7 | } 8 | 9 | class Markup { 10 | forceReply (value = true) { 11 | this.force_reply = value 12 | return this 13 | } 14 | 15 | removeKeyboard (value = true) { 16 | this.remove_keyboard = value 17 | return this 18 | } 19 | 20 | selective (value = true) { 21 | this.selective = value 22 | return this 23 | } 24 | 25 | extra (options) { 26 | return { 27 | reply_markup: { ...this }, 28 | ...options 29 | } 30 | } 31 | 32 | keyboard (buttons, options) { 33 | const keyboard = buildKeyboard(buttons, { columns: 1, ...options }) 34 | if (keyboard && keyboard.length > 0) { 35 | this.keyboard = keyboard 36 | } 37 | return this 38 | } 39 | 40 | resize (value = true) { 41 | this.resize_keyboard = value 42 | return this 43 | } 44 | 45 | oneTime (value = true) { 46 | this.one_time_keyboard = value 47 | return this 48 | } 49 | 50 | inlineKeyboard (buttons, options) { 51 | const keyboard = buildKeyboard(buttons, { columns: buttons.length, ...options }) 52 | if (keyboard && keyboard.length > 0) { 53 | this.inline_keyboard = keyboard 54 | } 55 | return this 56 | } 57 | 58 | button (text, hide) { 59 | return Markup.button(text, hide) 60 | } 61 | 62 | contactRequestButton (text, hide) { 63 | return Markup.contactRequestButton(text, hide) 64 | } 65 | 66 | locationRequestButton (text, hide) { 67 | return Markup.locationRequestButton(text, hide) 68 | } 69 | 70 | urlButton (text, url, hide) { 71 | return Markup.urlButton(text, url, hide) 72 | } 73 | 74 | callbackButton (text, data, hide) { 75 | return Markup.callbackButton(text, data, hide) 76 | } 77 | 78 | switchToChatButton (text, value, hide) { 79 | return Markup.switchToChatButton(text, value, hide) 80 | } 81 | 82 | switchToCurrentChatButton (text, value, hide) { 83 | return Markup.switchToCurrentChatButton(text, value, hide) 84 | } 85 | 86 | gameButton (text, hide) { 87 | return Markup.gameButton(text, hide) 88 | } 89 | 90 | payButton (text, hide) { 91 | return Markup.payButton(text, hide) 92 | } 93 | 94 | loginButton (text, url, opts, hide) { 95 | return Markup.loginButton(text, url, opts, hide) 96 | } 97 | 98 | static removeKeyboard (value) { 99 | return new Markup().removeKeyboard(value) 100 | } 101 | 102 | static forceReply (value) { 103 | return new Markup().forceReply(value) 104 | } 105 | 106 | static keyboard (buttons, options) { 107 | return new Markup().keyboard(buttons, options) 108 | } 109 | 110 | static inlineKeyboard (buttons, options) { 111 | return new Markup().inlineKeyboard(buttons, options) 112 | } 113 | 114 | static resize (value = true) { 115 | return new Markup().resize(value) 116 | } 117 | 118 | static selective (value = true) { 119 | return new Markup().selective(value) 120 | } 121 | 122 | static oneTime (value = true) { 123 | return new Markup().oneTime(value) 124 | } 125 | 126 | static button (text, hide = false) { 127 | return { text: text, hide: hide } 128 | } 129 | 130 | static contactRequestButton (text, hide = false) { 131 | return { text: text, request_contact: true, hide: hide } 132 | } 133 | 134 | static locationRequestButton (text, hide = false) { 135 | return { text: text, request_location: true, hide: hide } 136 | } 137 | 138 | static pollRequestButton (text, type, hide = false) { 139 | return { text: text, request_poll: { type }, hide: hide } 140 | } 141 | 142 | static urlButton (text, url, hide = false) { 143 | return { text: text, url: url, hide: hide } 144 | } 145 | 146 | static callbackButton (text, data, hide = false) { 147 | return { text: text, callback_data: data, hide: hide } 148 | } 149 | 150 | static switchToChatButton (text, value, hide = false) { 151 | return { text: text, switch_inline_query: value, hide: hide } 152 | } 153 | 154 | static switchToCurrentChatButton (text, value, hide = false) { 155 | return { text: text, switch_inline_query_current_chat: value, hide: hide } 156 | } 157 | 158 | static gameButton (text, hide = false) { 159 | return { text: text, callback_game: {}, hide: hide } 160 | } 161 | 162 | static payButton (text, hide = false) { 163 | return { text: text, pay: true, hide: hide } 164 | } 165 | 166 | static loginButton (text, url, opts = {}, hide = false) { 167 | return { 168 | text: text, 169 | login_url: { ...opts, url: url }, 170 | hide: hide 171 | } 172 | } 173 | 174 | static formatHTML (text = '', entities = []) { 175 | const chars = text 176 | const available = [...entities] 177 | const opened = [] 178 | const result = [] 179 | for (let offset = 0; offset < chars.length; offset++) { 180 | while (true) { 181 | const index = available.findIndex((entity) => entity.offset === offset) 182 | if (index === -1) { 183 | break 184 | } 185 | const entity = available[index] 186 | switch (entity.type) { 187 | case 'bold': 188 | result.push('') 189 | break 190 | case 'italic': 191 | result.push('') 192 | break 193 | case 'code': 194 | result.push('') 195 | break 196 | case 'pre': 197 | if (entity.language) { 198 | result.push(`
`)
199 |             } else {
200 |               result.push('
')
201 |             }
202 |             break
203 |           case 'strikethrough':
204 |             result.push('')
205 |             break
206 |           case 'underline':
207 |             result.push('')
208 |             break
209 |           case 'text_mention':
210 |             result.push(``)
211 |             break
212 |           case 'text_link':
213 |             result.push(``)
214 |             break
215 |         }
216 |         opened.unshift(entity)
217 |         available.splice(index, 1)
218 |       }
219 | 
220 |       result.push(escapeHTML(chars[offset]))
221 | 
222 |       while (true) {
223 |         const index = opened.findIndex((entity) => entity.offset + entity.length - 1 === offset)
224 |         if (index === -1) {
225 |           break
226 |         }
227 |         const entity = opened[index]
228 |         switch (entity.type) {
229 |           case 'bold':
230 |             result.push('')
231 |             break
232 |           case 'italic':
233 |             result.push('')
234 |             break
235 |           case 'code':
236 |             result.push('')
237 |             break
238 |           case 'pre':
239 |             if (entity.language) {
240 |               result.push('
') 241 | } else { 242 | result.push('
') 243 | } 244 | break 245 | case 'strikethrough': 246 | result.push('') 247 | break 248 | case 'underline': 249 | result.push('') 250 | break 251 | case 'text_mention': 252 | case 'text_link': 253 | result.push('') 254 | break 255 | } 256 | opened.splice(index, 1) 257 | } 258 | } 259 | return result.join('') 260 | } 261 | } 262 | 263 | function buildKeyboard (buttons, options) { 264 | const result = [] 265 | if (!Array.isArray(buttons)) { 266 | return result 267 | } 268 | if (buttons.find(Array.isArray)) { 269 | return buttons.map(row => row.filter((button) => !button.hide)) 270 | } 271 | const wrapFn = options.wrap 272 | ? options.wrap 273 | : (btn, index, currentRow) => currentRow.length >= options.columns 274 | let currentRow = [] 275 | let index = 0 276 | for (const btn of buttons.filter((button) => !button.hide)) { 277 | if (wrapFn(btn, index, currentRow) && currentRow.length > 0) { 278 | result.push(currentRow) 279 | currentRow = [] 280 | } 281 | currentRow.push(btn) 282 | index++ 283 | } 284 | if (currentRow.length > 0) { 285 | result.push(currentRow) 286 | } 287 | return result 288 | } 289 | 290 | module.exports = Markup 291 | -------------------------------------------------------------------------------- /test/markup.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const Markup = require('../lib/markup') 3 | 4 | test('should generate removeKeyboard markup', (t) => { 5 | const markup = { ...Markup.removeKeyboard() } 6 | t.deepEqual(markup, { remove_keyboard: true }) 7 | }) 8 | 9 | test('should generate forceReply markup', (t) => { 10 | const markup = { ...Markup.forceReply() } 11 | t.deepEqual(markup, { force_reply: true }) 12 | }) 13 | 14 | test('should generate resizeKeyboard markup', (t) => { 15 | const markup = { ...Markup.keyboard([]).resize() } 16 | t.deepEqual(markup, { resize_keyboard: true }) 17 | }) 18 | 19 | test('should generate oneTimeKeyboard markup', (t) => { 20 | const markup = { ...Markup.keyboard([]).oneTime() } 21 | t.deepEqual(markup, { one_time_keyboard: true }) 22 | }) 23 | 24 | test('should generate selective hide markup', (t) => { 25 | const markup = { ...Markup.removeKeyboard().selective() } 26 | t.deepEqual(markup, { remove_keyboard: true, selective: true }) 27 | }) 28 | 29 | test('should generate selective one time keyboard markup', (t) => { 30 | const markup = { ...Markup.keyboard().selective().oneTime() } 31 | t.deepEqual(markup, { selective: true, one_time_keyboard: true }) 32 | }) 33 | 34 | test('should generate keyboard markup', (t) => { 35 | const markup = { ...Markup.keyboard([['one'], ['two', 'three']]) } 36 | t.deepEqual(markup, { 37 | keyboard: [ 38 | ['one'], 39 | ['two', 'three'] 40 | ] 41 | }) 42 | }) 43 | 44 | test('should generate keyboard markup with default setting', (t) => { 45 | const markup = { ...Markup.keyboard(['one', 'two', 'three']) } 46 | t.deepEqual(markup, { 47 | keyboard: [ 48 | ['one'], 49 | ['two'], 50 | ['three'] 51 | ] 52 | }) 53 | }) 54 | 55 | test('should generate keyboard markup with options', (t) => { 56 | const markup = { ...Markup.keyboard(['one', 'two', 'three'], { columns: 3 }) } 57 | t.deepEqual(markup, { 58 | keyboard: [ 59 | ['one', 'two', 'three'] 60 | ] 61 | }) 62 | }) 63 | 64 | test('should generate keyboard markup with custom columns', (t) => { 65 | const markup = { ...Markup.keyboard(['one', 'two', 'three', 'four'], { columns: 3 }) } 66 | t.deepEqual(markup, { 67 | keyboard: [ 68 | ['one', 'two', 'three'], 69 | ['four'] 70 | ] 71 | }) 72 | }) 73 | 74 | test('should generate keyboard markup with custom wrap fn', (t) => { 75 | const markup = { 76 | ...Markup.keyboard(['one', 'two', 'three', 'four'], { 77 | wrap: (btn, index, currentRow) => index % 2 !== 0 78 | }) 79 | } 80 | t.deepEqual(markup, { 81 | keyboard: [ 82 | ['one'], 83 | ['two', 'three'], 84 | ['four'] 85 | ] 86 | }) 87 | }) 88 | 89 | test('should generate inline keyboard markup with default setting', (t) => { 90 | const markup = { ...Markup.inlineKeyboard(['one', 'two', 'three', 'four']) } 91 | t.deepEqual(markup, { 92 | inline_keyboard: [[ 93 | 'one', 94 | 'two', 95 | 'three', 96 | 'four' 97 | ]] 98 | }) 99 | }) 100 | 101 | test('should generate extra from keyboard markup', (t) => { 102 | const markup = { ...Markup.inlineKeyboard(['one', 'two', 'three', 'four']).extra() } 103 | t.deepEqual(markup, { 104 | reply_markup: { 105 | inline_keyboard: [[ 106 | 'one', 107 | 'two', 108 | 'three', 109 | 'four' 110 | ]] 111 | } 112 | }) 113 | }) 114 | 115 | test('should generate standart button markup', (t) => { 116 | const markup = { ...Markup.button('foo') } 117 | t.deepEqual(markup, { text: 'foo', hide: false }) 118 | }) 119 | 120 | test('should generate cb button markup', (t) => { 121 | const markup = { ...Markup.callbackButton('foo', 'bar') } 122 | t.deepEqual(markup, { text: 'foo', callback_data: 'bar', hide: false }) 123 | }) 124 | 125 | test('should generate url button markup', (t) => { 126 | const markup = { ...Markup.urlButton('foo', 'https://bar.tld') } 127 | t.deepEqual(markup, { text: 'foo', url: 'https://bar.tld', hide: false }) 128 | }) 129 | 130 | test('should generate location request button markup', (t) => { 131 | const markup = { ...Markup.locationRequestButton('send location') } 132 | t.deepEqual(markup, { text: 'send location', request_location: true, hide: false }) 133 | }) 134 | 135 | test('should generate contact request button markup', (t) => { 136 | const markup = { ...Markup.contactRequestButton('send contact') } 137 | t.deepEqual(markup, { text: 'send contact', request_contact: true, hide: false }) 138 | }) 139 | 140 | test('should generate switch inline query button markup', (t) => { 141 | const markup = { ...Markup.switchToChatButton('play now', 'foo') } 142 | t.deepEqual(markup, { text: 'play now', switch_inline_query: 'foo', hide: false }) 143 | }) 144 | 145 | test('should generate switch inline query button markup for chat', (t) => { 146 | const markup = { ...Markup.switchToCurrentChatButton('play now', 'foo') } 147 | t.deepEqual(markup, { text: 'play now', switch_inline_query_current_chat: 'foo', hide: false }) 148 | }) 149 | 150 | test('should generate game button markup', (t) => { 151 | const markup = { ...Markup.gameButton('play') } 152 | t.deepEqual(markup, { text: 'play', callback_game: {}, hide: false }) 153 | }) 154 | 155 | test('should generate hidden game button markup', (t) => { 156 | const markup = { ...Markup.gameButton('play again', true) } 157 | t.deepEqual(markup, { text: 'play again', callback_game: {}, hide: true }) 158 | }) 159 | 160 | test('should generate markup', (t) => { 161 | const markup = Markup.formatHTML('strike', [ 162 | { 163 | offset: 0, 164 | length: 6, 165 | type: 'strikethrough' 166 | } 167 | ]) 168 | t.deepEqual(markup, 'strike') 169 | }) 170 | 171 | test('should generate multi markup', (t) => { 172 | const markup = Markup.formatHTML('strike bold', [ 173 | { 174 | offset: 0, 175 | length: 6, 176 | type: 'strikethrough' 177 | }, 178 | { 179 | offset: 7, 180 | length: 4, 181 | type: 'bold' 182 | } 183 | ]) 184 | t.deepEqual(markup, 'strike bold') 185 | }) 186 | 187 | test('should generate nested markup', (t) => { 188 | const markup = Markup.formatHTML('test', [ 189 | { 190 | offset: 0, 191 | length: 4, 192 | type: 'bold' 193 | }, 194 | { 195 | offset: 0, 196 | length: 4, 197 | type: 'strikethrough' 198 | } 199 | ]) 200 | t.deepEqual(markup, 'test') 201 | }) 202 | 203 | test('should generate nested multi markup', (t) => { 204 | const markup = Markup.formatHTML('strikeboldunder', [ 205 | { 206 | offset: 0, 207 | length: 15, 208 | type: 'strikethrough' 209 | }, 210 | { 211 | offset: 6, 212 | length: 9, 213 | type: 'bold' 214 | }, 215 | { 216 | offset: 10, 217 | length: 5, 218 | type: 'underline' 219 | } 220 | ]) 221 | t.deepEqual(markup, 'strikeboldunder') 222 | }) 223 | 224 | test('should generate nested multi markup 2', (t) => { 225 | const markup = Markup.formatHTML('×11 22 333× ×С123456× ×1 22 333×', [ 226 | { 227 | offset: 1, 228 | length: 9, 229 | type: 'bold' 230 | }, 231 | { 232 | offset: 1, 233 | length: 9, 234 | type: 'italic' 235 | }, 236 | { 237 | offset: 12, 238 | length: 7, 239 | type: 'italic' 240 | }, 241 | { 242 | offset: 19, 243 | length: 36, 244 | type: 'italic' 245 | }, 246 | { 247 | offset: 19, 248 | length: 8, 249 | type: 'bold' 250 | } 251 | ]) 252 | t.deepEqual(markup, '×11 22 333× ×С123456× ×1 22 333×') 253 | }) 254 | 255 | test('should generate correct HTML with emojis', (t) => { 256 | const markup = Markup.formatHTML('👨‍👩‍👧‍👦underline 👩‍👩‍👦‍👦bold 👨‍👨‍👦‍👦italic', [ 257 | { 258 | offset: 0, 259 | length: 20, 260 | type: 'underline' 261 | }, 262 | { 263 | offset: 21, 264 | length: 15, 265 | type: 'bold' 266 | }, 267 | { 268 | offset: 37, 269 | length: 17, 270 | type: 'italic' 271 | } 272 | ]) 273 | t.deepEqual(markup, '👨‍👩‍👧‍👦underline 👩‍👩‍👦‍👦bold 👨‍👨‍👦‍👦italic') 274 | }) 275 | 276 | test('should generate correct HTML with HTML-reserved characters', (t) => { 277 | const markup = Markup.formatHTML('123', [{ offset: 1, length: 3, type: 'underline' }]) 278 | t.deepEqual(markup, '<b>123</b>') 279 | }) 280 | -------------------------------------------------------------------------------- /src/telegraf.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'http' 2 | import * as https from 'https' 3 | import * as tt from './telegram-types' 4 | import ApiClient from './core/network/client' 5 | import Composer from './composer' 6 | import Context from './context' 7 | import crypto from 'crypto' 8 | import d from 'debug' 9 | import generateCallback from './core/network/webhook' 10 | import { promisify } from 'util' 11 | import Telegram from './telegram' 12 | import { TlsOptions } from 'tls' 13 | import { URL } from 'url' 14 | const debug = d('telegraf:core') 15 | 16 | const DEFAULT_OPTIONS: Telegraf.Options = { 17 | telegram: {}, 18 | retryAfter: 1, 19 | handlerTimeout: 0, 20 | channelMode: false, 21 | contextType: Context, 22 | } 23 | 24 | const noop = () => {} 25 | function always(x: T) { 26 | return () => x 27 | } 28 | const anoop = always(Promise.resolve()) 29 | const sleep = promisify(setTimeout) 30 | 31 | // eslint-disable-next-line @typescript-eslint/no-namespace 32 | namespace Telegraf { 33 | export interface Options { 34 | channelMode: boolean 35 | contextType: new ( 36 | ...args: ConstructorParameters 37 | ) => TContext 38 | handlerTimeout: number 39 | retryAfter: number 40 | telegram: Partial 41 | username?: string 42 | } 43 | 44 | export interface LaunchOptions { 45 | polling?: { 46 | timeout?: number 47 | 48 | /** Limits the number of updates to be retrieved in one call */ 49 | limit?: number 50 | 51 | /** List the types of updates you want your bot to receive */ 52 | allowedUpdates?: tt.UpdateType[] 53 | 54 | stopCallback?: () => void 55 | } 56 | webhook?: { 57 | /** Public domain for webhook. If domain is not specified, hookPath should contain a domain name as well (not only path component). */ 58 | domain?: string 59 | 60 | /** Webhook url path; will be automatically generated if not specified */ 61 | hookPath?: string 62 | 63 | host?: string 64 | port?: number 65 | 66 | /** TLS server options. Omit to use http. */ 67 | tlsOptions?: TlsOptions 68 | 69 | cb?: http.RequestListener 70 | } 71 | } 72 | } 73 | 74 | const allowedUpdates: tt.UpdateType[] | undefined = undefined 75 | 76 | // eslint-disable-next-line no-redeclare 77 | export class Telegraf extends Composer< 78 | TContext 79 | > { 80 | private readonly options: Telegraf.Options 81 | private webhookServer?: http.Server | https.Server 82 | public telegram: Telegram 83 | readonly context: Partial = {} 84 | private readonly polling = { 85 | allowedUpdates, 86 | limit: 100, 87 | offset: 0, 88 | started: false, 89 | stopCallback: noop, 90 | timeout: 30, 91 | } 92 | 93 | private handleError: (err: any, ctx: TContext) => void = (err) => { 94 | console.error() 95 | console.error((err.stack || err.toString()).replace(/^/gm, ' ')) 96 | console.error() 97 | throw err 98 | } 99 | 100 | constructor(token: string, options?: Partial>) { 101 | super() 102 | // @ts-expect-error 103 | this.options = { 104 | ...DEFAULT_OPTIONS, 105 | ...options, 106 | } 107 | this.telegram = new Telegram(token, this.options.telegram) 108 | } 109 | 110 | get token() { 111 | return this.telegram.token 112 | } 113 | 114 | set webhookReply(webhookReply: boolean) { 115 | this.telegram.webhookReply = webhookReply 116 | } 117 | 118 | get webhookReply() { 119 | return this.telegram.webhookReply 120 | } /* eslint brace-style: 0 */ 121 | 122 | catch(handler: (err: any, ctx: TContext) => void) { 123 | this.handleError = handler 124 | return this 125 | } 126 | 127 | webhookCallback(path = '/') { 128 | return generateCallback( 129 | path, 130 | (update: tt.Update, res: any) => this.handleUpdate(update, res), 131 | debug 132 | ) 133 | } 134 | 135 | private startPolling( 136 | timeout = 30, 137 | limit = 100, 138 | allowedUpdates: tt.UpdateType[] = [], 139 | stopCallback = noop 140 | ) { 141 | this.polling.timeout = timeout 142 | this.polling.limit = limit 143 | this.polling.allowedUpdates = allowedUpdates 144 | this.polling.stopCallback = stopCallback 145 | if (!this.polling.started) { 146 | this.polling.started = true 147 | this.fetchUpdates() 148 | } 149 | return this 150 | } 151 | 152 | private startWebhook( 153 | hookPath: string, 154 | tlsOptions?: TlsOptions, 155 | port?: number, 156 | host?: string, 157 | cb?: http.RequestListener 158 | ) { 159 | const webhookCb = this.webhookCallback(hookPath) 160 | const callback: http.RequestListener = 161 | cb && typeof cb === 'function' 162 | ? (req, res) => webhookCb(req, res, () => cb(req, res)) 163 | : webhookCb 164 | this.webhookServer = tlsOptions 165 | ? https.createServer(tlsOptions, callback) 166 | : http.createServer(callback) 167 | this.webhookServer.listen(port, host, () => { 168 | debug('Webhook listening on port: %s', port) 169 | }) 170 | return this 171 | } 172 | 173 | launch(config: Telegraf.LaunchOptions = {}) { 174 | debug('Connecting to Telegram') 175 | return this.telegram.getMe().then((botInfo) => { 176 | debug(`Launching @${botInfo.username}`) 177 | this.options.username = botInfo.username 178 | this.context.botInfo = botInfo 179 | if (!config.webhook) { 180 | const { timeout, limit, allowedUpdates, stopCallback } = 181 | config.polling ?? {} 182 | // prettier-ignore 183 | return this.telegram.deleteWebhook() 184 | .then(() => this.startPolling(timeout, limit, allowedUpdates, stopCallback)) 185 | .then(() => debug('Bot started with long-polling')) 186 | } 187 | // prettier-ignore 188 | if (typeof config.webhook.domain !== 'string' && typeof config.webhook.hookPath !== 'string') { 189 | throw new Error('Webhook domain or webhook path is required') 190 | } 191 | let domain = config.webhook.domain ?? '' 192 | if (domain.startsWith('https://') || domain.startsWith('http://')) { 193 | domain = new URL(domain).host 194 | } 195 | const hookPath = 196 | config.webhook.hookPath ?? 197 | `/telegraf/${crypto.randomBytes(32).toString('hex')}` 198 | const { port, host, tlsOptions, cb } = config.webhook 199 | this.startWebhook(hookPath, tlsOptions, port, host, cb) 200 | if (!domain) { 201 | debug('Bot started with webhook') 202 | return 203 | } 204 | return this.telegram 205 | .setWebhook(`https://${domain}${hookPath}`) 206 | .then(() => debug(`Bot started with webhook @ https://${domain}`)) 207 | }) 208 | } 209 | 210 | stop(cb = noop) { 211 | debug('Stopping bot...') 212 | return new Promise((resolve) => { 213 | if (this.webhookServer) { 214 | return this.webhookServer.close(resolve) 215 | } else if (!this.polling.started) { 216 | return resolve() 217 | } 218 | this.polling.stopCallback = resolve 219 | this.polling.started = false 220 | }).then(cb) 221 | } 222 | 223 | handleUpdates(updates: readonly tt.Update[]) { 224 | if (!Array.isArray(updates)) { 225 | return Promise.reject(new Error('Updates must be an array')) 226 | } 227 | // prettier-ignore 228 | const processAll = Promise.all(updates.map((update) => this.handleUpdate(update))) 229 | if (this.options.handlerTimeout === 0) { 230 | return processAll 231 | } 232 | return Promise.race([processAll, sleep(this.options.handlerTimeout)]) 233 | } 234 | 235 | async handleUpdate(update: tt.Update, webhookResponse?: any) { 236 | debug('Processing update', update.update_id) 237 | const tg = new Telegram(this.token, this.telegram.options, webhookResponse) 238 | const TelegrafContext = this.options.contextType 239 | const ctx = new TelegrafContext(update, tg, this.options) 240 | Object.assign(ctx, this.context) 241 | try { 242 | await this.middleware()(ctx, anoop) 243 | } catch (err) { 244 | return this.handleError(err, ctx) 245 | } 246 | } 247 | 248 | private fetchUpdates() { 249 | if (!this.polling.started) { 250 | this.polling.stopCallback() 251 | return 252 | } 253 | const { timeout, limit, offset, allowedUpdates } = this.polling 254 | this.telegram 255 | .getUpdates(timeout, limit, offset, allowedUpdates) 256 | .catch((err) => { 257 | if (err.code === 401 || err.code === 409) { 258 | throw err 259 | } 260 | const wait = err.parameters?.retry_after ?? this.options.retryAfter 261 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 262 | console.error(`Failed to fetch updates. Waiting: ${wait}s`, err.message) 263 | return sleep(wait * 1000, []) 264 | }) 265 | .then((updates) => 266 | this.polling.started 267 | ? this.handleUpdates(updates).then(() => updates) 268 | : [] 269 | ) 270 | .catch((err) => { 271 | console.error('Failed to process updates.', err) 272 | this.polling.started = false 273 | this.polling.offset = 0 274 | return [] 275 | }) 276 | .then((updates) => { 277 | if (updates.length > 0) { 278 | this.polling.offset = updates[updates.length - 1].update_id + 1 279 | } 280 | this.fetchUpdates() 281 | }) 282 | .catch(noop) 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /src/core/network/client.ts: -------------------------------------------------------------------------------- 1 | /* eslint @typescript-eslint/restrict-template-expressions: [ "error", { "allowNumber": true, "allowBoolean": true } ] */ 2 | import * as crypto from 'crypto' 3 | import * as fs from 'fs' 4 | import * as http from 'http' 5 | import * as https from 'https' 6 | import * as path from 'path' 7 | import fetch, { RequestInfo, RequestInit } from 'node-fetch' 8 | import { hasProp, hasPropType } from '../../util' 9 | import { Opts, Telegram } from 'typegram' 10 | import MultipartStream from './multipart-stream' 11 | import { ReadStream } from 'fs' 12 | import TelegramError from './error' 13 | // eslint-disable-next-line @typescript-eslint/no-var-requires 14 | const debug = require('debug')('telegraf:client') 15 | const { isStream } = MultipartStream 16 | 17 | const WEBHOOK_BLACKLIST = [ 18 | 'getChat', 19 | 'getChatAdministrators', 20 | 'getChatMember', 21 | 'getChatMembersCount', 22 | 'getFile', 23 | 'getFileLink', 24 | 'getGameHighScores', 25 | 'getMe', 26 | 'getUserProfilePhotos', 27 | 'getWebhookInfo', 28 | 'exportChatInviteLink', 29 | ] 30 | 31 | // eslint-disable-next-line @typescript-eslint/no-namespace 32 | namespace ApiClient { 33 | export interface Options { 34 | agent?: https.Agent | http.Agent 35 | apiRoot: string 36 | webhookReply: boolean 37 | } 38 | } 39 | 40 | const DEFAULT_EXTENSIONS = { 41 | audio: 'mp3', 42 | photo: 'jpg', 43 | sticker: 'webp', 44 | video: 'mp4', 45 | animation: 'mp4', 46 | video_note: 'mp4', 47 | voice: 'ogg', 48 | } 49 | 50 | const DEFAULT_OPTIONS = { 51 | apiRoot: 'https://api.telegram.org', 52 | webhookReply: true, 53 | agent: new https.Agent({ 54 | keepAlive: true, 55 | keepAliveMsecs: 10000, 56 | }), 57 | } 58 | 59 | const WEBHOOK_REPLY_STUB = { 60 | webhook: true, 61 | details: 62 | 'https://core.telegram.org/bots/api#making-requests-when-getting-updates', 63 | } as const 64 | 65 | function includesMedia(payload: Record) { 66 | return Object.values(payload).some((value) => { 67 | if (Array.isArray(value)) { 68 | return value.some( 69 | ({ media }) => 70 | media && typeof media === 'object' && (media.source || media.url) 71 | ) 72 | } 73 | return ( 74 | value && 75 | typeof value === 'object' && 76 | ((hasProp(value, 'source') && value.source) || 77 | (hasProp(value, 'url') && value.url) || 78 | (hasPropType(value, 'media', 'object') && 79 | ((hasProp(value.media, 'source') && value.media.source) || 80 | (hasProp(value.media, 'url') && value.media.url)))) 81 | ) 82 | }) 83 | } 84 | 85 | function replacer(_: unknown, value: unknown) { 86 | if (value == null) return undefined 87 | return value 88 | } 89 | 90 | function buildJSONConfig(payload: unknown): Promise { 91 | return Promise.resolve({ 92 | method: 'POST', 93 | compress: true, 94 | headers: { 'content-type': 'application/json', connection: 'keep-alive' }, 95 | body: JSON.stringify(payload, replacer), 96 | }) 97 | } 98 | 99 | const FORM_DATA_JSON_FIELDS = [ 100 | 'results', 101 | 'reply_markup', 102 | 'mask_position', 103 | 'shipping_options', 104 | 'errors', 105 | ] 106 | 107 | async function buildFormDataConfig( 108 | payload: Record, 109 | agent: RequestInit['agent'] 110 | ): Promise { 111 | for (const field of FORM_DATA_JSON_FIELDS) { 112 | if (hasProp(payload, field) && typeof payload[field] !== 'string') { 113 | payload[field] = JSON.stringify(payload[field]) 114 | } 115 | } 116 | const boundary = crypto.randomBytes(32).toString('hex') 117 | const formData = new MultipartStream(boundary) 118 | const tasks = Object.keys(payload).map((key) => 119 | attachFormValue(formData, key, payload[key], agent) 120 | ) 121 | await Promise.all(tasks) 122 | return { 123 | method: 'POST', 124 | compress: true, 125 | headers: { 126 | 'content-type': `multipart/form-data; boundary=${boundary}`, 127 | connection: 'keep-alive', 128 | }, 129 | body: formData, 130 | } 131 | } 132 | 133 | async function attachFormValue( 134 | form: MultipartStream, 135 | id: string, 136 | value: unknown, 137 | agent: RequestInit['agent'] 138 | ) { 139 | if (value == null) { 140 | return 141 | } 142 | if ( 143 | typeof value === 'string' || 144 | typeof value === 'boolean' || 145 | typeof value === 'number' 146 | ) { 147 | form.addPart({ 148 | headers: { 'content-disposition': `form-data; name="${id}"` }, 149 | body: `${value}`, 150 | }) 151 | return 152 | } 153 | if (id === 'thumb') { 154 | const attachmentId = crypto.randomBytes(16).toString('hex') 155 | await attachFormMedia(form, value as FormMedia, attachmentId, agent) 156 | return form.addPart({ 157 | headers: { 'content-disposition': `form-data; name="${id}"` }, 158 | body: `attach://${attachmentId}`, 159 | }) 160 | } 161 | if (Array.isArray(value)) { 162 | const items = await Promise.all( 163 | value.map(async (item) => { 164 | if (typeof item.media !== 'object') { 165 | return await Promise.resolve(item) 166 | } 167 | const attachmentId = crypto.randomBytes(16).toString('hex') 168 | await attachFormMedia(form, item.media, attachmentId, agent) 169 | return { ...item, media: `attach://${attachmentId}` } 170 | }) 171 | ) 172 | return form.addPart({ 173 | headers: { 'content-disposition': `form-data; name="${id}"` }, 174 | body: JSON.stringify(items), 175 | }) 176 | } 177 | if ( 178 | value && 179 | typeof value === 'object' && 180 | hasProp(value, 'media') && 181 | hasProp(value, 'type') && 182 | typeof value.media !== 'undefined' && 183 | typeof value.type !== 'undefined' 184 | ) { 185 | const attachmentId = crypto.randomBytes(16).toString('hex') 186 | await attachFormMedia(form, value.media as FormMedia, attachmentId, agent) 187 | return form.addPart({ 188 | headers: { 'content-disposition': `form-data; name="${id}"` }, 189 | body: JSON.stringify({ 190 | ...value, 191 | media: `attach://${attachmentId}`, 192 | }), 193 | }) 194 | } 195 | return await attachFormMedia(form, value as FormMedia, id, agent) 196 | } 197 | 198 | interface FormMedia { 199 | filename?: string 200 | url?: RequestInfo 201 | source?: string 202 | } 203 | async function attachFormMedia( 204 | form: MultipartStream, 205 | media: FormMedia, 206 | id: string, 207 | agent: RequestInit['agent'] 208 | ) { 209 | let fileName = 210 | media.filename ?? 211 | `${id}.${(DEFAULT_EXTENSIONS as { [key: string]: string })[id] || 'dat'}` 212 | if (media.url) { 213 | const res = await fetch(media.url, { agent }) 214 | return form.addPart({ 215 | headers: { 216 | 'content-disposition': `form-data; name="${id}"; filename="${fileName}"`, 217 | }, 218 | body: res.body, 219 | }) 220 | } 221 | if (media.source) { 222 | let mediaSource: string | ReadStream = media.source 223 | if (fs.existsSync(media.source)) { 224 | fileName = media.filename ?? path.basename(media.source) 225 | mediaSource = fs.createReadStream(media.source) 226 | } 227 | if (isStream(mediaSource) || Buffer.isBuffer(mediaSource)) { 228 | form.addPart({ 229 | headers: { 230 | 'content-disposition': `form-data; name="${id}"; filename="${fileName}"`, 231 | }, 232 | body: mediaSource, 233 | }) 234 | } 235 | } 236 | } 237 | 238 | function isKoaResponse(response: unknown): boolean { 239 | return ( 240 | typeof response === 'object' && 241 | response !== null && 242 | hasPropType(response, 'set', 'function') && 243 | hasPropType(response, 'header', 'object') 244 | ) 245 | } 246 | 247 | function answerToWebhook( 248 | response: Response, 249 | payload: Record, 250 | options: ApiClient.Options 251 | ): Promise { 252 | if (!includesMedia(payload)) { 253 | if (isKoaResponse(response)) { 254 | response.body = payload 255 | return Promise.resolve(WEBHOOK_REPLY_STUB) 256 | } 257 | if (!response.headersSent) { 258 | response.setHeader('content-type', 'application/json') 259 | } 260 | return new Promise((resolve) => { 261 | if (response.end.length === 2) { 262 | response.end(JSON.stringify(payload), 'utf-8') 263 | return resolve(WEBHOOK_REPLY_STUB) 264 | } 265 | response.end(JSON.stringify(payload), 'utf-8', () => 266 | resolve(WEBHOOK_REPLY_STUB) 267 | ) 268 | }) 269 | } 270 | 271 | return buildFormDataConfig(payload, options.agent).then( 272 | ({ headers = {}, body }) => { 273 | if (isKoaResponse(response)) { 274 | for (const [key, value] of Object.entries(headers)) { 275 | response.set(key, value) 276 | } 277 | response.body = body 278 | return Promise.resolve(WEBHOOK_REPLY_STUB) 279 | } 280 | if (!response.headersSent) { 281 | for (const [key, value] of Object.entries(headers)) { 282 | response.set(key, value) 283 | } 284 | } 285 | return new Promise((resolve) => { 286 | response.on('finish', () => resolve(WEBHOOK_REPLY_STUB)) 287 | // @ts-expect-error 288 | body.pipe(response) 289 | }) 290 | } 291 | ) 292 | } 293 | 294 | // TODO: what is actually the type of this? 295 | type Response = any 296 | // eslint-disable-next-line no-redeclare 297 | class ApiClient { 298 | readonly options: ApiClient.Options 299 | private responseEnd = false 300 | 301 | constructor( 302 | readonly token: string, 303 | options?: Partial, 304 | private readonly response?: Response 305 | ) { 306 | this.token = token 307 | this.options = { 308 | ...DEFAULT_OPTIONS, 309 | ...options, 310 | } 311 | if (this.options.apiRoot.startsWith('http://')) { 312 | this.options.agent = undefined 313 | } 314 | } 315 | 316 | set webhookReply(enable: boolean) { 317 | this.options.webhookReply = enable 318 | } 319 | 320 | get webhookReply() { 321 | return this.options.webhookReply 322 | } 323 | 324 | callApi( 325 | method: M, 326 | payload: Opts 327 | ): Promise> { 328 | const { token, options, response, responseEnd } = this 329 | 330 | if ( 331 | options.webhookReply && 332 | response && 333 | !responseEnd && 334 | !WEBHOOK_BLACKLIST.includes(method) 335 | ) { 336 | debug('Call via webhook', method, payload) 337 | this.responseEnd = true 338 | // @ts-expect-error 339 | return answerToWebhook(response, { method, ...payload }, options) 340 | } 341 | 342 | if (!token) { 343 | throw new TelegramError({ 344 | error_code: 401, 345 | description: 'Bot Token is required', 346 | }) 347 | } 348 | 349 | debug('HTTP call', method, payload) 350 | const buildConfig = includesMedia(payload) 351 | ? buildFormDataConfig({ method, ...payload }, options.agent) 352 | : buildJSONConfig(payload) 353 | return buildConfig 354 | .then((config) => { 355 | const apiUrl = `${options.apiRoot}/bot${token}/${method}` 356 | config.agent = options.agent 357 | return fetch(apiUrl, config) 358 | }) 359 | .then((res) => res.json()) 360 | .then((data) => { 361 | if (!data.ok) { 362 | debug('API call failed', data) 363 | throw new TelegramError(data, { method, payload }) 364 | } 365 | return data.result 366 | }) 367 | } 368 | } 369 | 370 | export = ApiClient 371 | -------------------------------------------------------------------------------- /test/telegraf.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const { Telegraf, session } = require('../') 3 | 4 | const BaseTextMessage = { 5 | chat: { id: 1 }, 6 | text: 'foo' 7 | } 8 | 9 | const UpdateTypes = [ 10 | { type: 'shipping_query', prop: 'shippingQuery', update: { shipping_query: {} } }, 11 | { type: 'message', prop: 'message', update: { message: BaseTextMessage } }, 12 | { type: 'edited_message', prop: 'editedMessage', update: { edited_message: BaseTextMessage } }, 13 | { type: 'callback_query', prop: 'callbackQuery', update: { callback_query: { message: BaseTextMessage } } }, 14 | { type: 'inline_query', prop: 'inlineQuery', update: { inline_query: {} } }, 15 | { type: 'channel_post', prop: 'channelPost', update: { channel_post: BaseTextMessage } }, 16 | { type: 'pre_checkout_query', prop: 'preCheckoutQuery', update: { pre_checkout_query: {} } }, 17 | { type: 'edited_channel_post', prop: 'editedChannelPost', update: { edited_channel_post: {} } }, 18 | { type: 'chosen_inline_result', prop: 'chosenInlineResult', update: { chosen_inline_result: {} } } 19 | ] 20 | 21 | UpdateTypes.forEach((update) => { 22 | test.cb('should provide update payload for ' + update.type, (t) => { 23 | const bot = new Telegraf() 24 | bot.on(update.type, (ctx) => { 25 | t.true(update.prop in ctx) 26 | t.true('telegram' in ctx) 27 | t.true('updateType' in ctx) 28 | t.true('chat' in ctx) 29 | t.true('from' in ctx) 30 | t.true('state' in ctx) 31 | t.is(ctx.updateType, update.type) 32 | t.end() 33 | }) 34 | bot.handleUpdate(update.update) 35 | }) 36 | }) 37 | 38 | test.cb('should provide update payload for text', (t) => { 39 | const bot = new Telegraf() 40 | bot.on('text', (ctx) => { 41 | t.true('telegram' in ctx) 42 | t.true('updateType' in ctx) 43 | t.true('updateSubTypes' in ctx) 44 | t.true('chat' in ctx) 45 | t.true('from' in ctx) 46 | t.true('state' in ctx) 47 | t.is(ctx.updateType, 'message') 48 | t.end() 49 | }) 50 | bot.handleUpdate({ message: BaseTextMessage }) 51 | }) 52 | 53 | test.cb('should provide shortcuts for `message` update', (t) => { 54 | const bot = new Telegraf() 55 | bot.on('message', (ctx) => { 56 | t.true('reply' in ctx) 57 | t.true('replyWithPhoto' in ctx) 58 | t.true('replyWithMarkdown' in ctx) 59 | t.true('replyWithHTML' in ctx) 60 | t.true('replyWithAudio' in ctx) 61 | t.true('replyWithDice' in ctx) 62 | t.true('replyWithDocument' in ctx) 63 | t.true('replyWithInvoice' in ctx) 64 | t.true('replyWithSticker' in ctx) 65 | t.true('replyWithVideo' in ctx) 66 | t.true('replyWithAnimation' in ctx) 67 | t.true('replyWithVideoNote' in ctx) 68 | t.true('replyWithVoice' in ctx) 69 | t.true('replyWithChatAction' in ctx) 70 | t.true('replyWithLocation' in ctx) 71 | t.true('replyWithVenue' in ctx) 72 | t.true('replyWithContact' in ctx) 73 | t.true('replyWithGame' in ctx) 74 | t.true('replyWithMediaGroup' in ctx) 75 | t.true('setChatPermissions' in ctx) 76 | t.true('kickChatMember' in ctx) 77 | t.true('unbanChatMember' in ctx) 78 | t.true('promoteChatMember' in ctx) 79 | t.true('restrictChatMember' in ctx) 80 | t.true('getChat' in ctx) 81 | t.true('exportChatInviteLink' in ctx) 82 | t.true('setChatPhoto' in ctx) 83 | t.true('deleteChatPhoto' in ctx) 84 | t.true('setChatTitle' in ctx) 85 | t.true('setChatDescription' in ctx) 86 | t.true('pinChatMessage' in ctx) 87 | t.true('unpinChatMessage' in ctx) 88 | t.true('leaveChat' in ctx) 89 | t.true('getChatAdministrators' in ctx) 90 | t.true('getChatMember' in ctx) 91 | t.true('getChatMembersCount' in ctx) 92 | t.true('setChatStickerSet' in ctx) 93 | t.true('deleteChatStickerSet' in ctx) 94 | t.true('getStickerSet' in ctx) 95 | t.true('uploadStickerFile' in ctx) 96 | t.true('createNewStickerSet' in ctx) 97 | t.true('addStickerToSet' in ctx) 98 | t.true('setStickerPositionInSet' in ctx) 99 | t.true('deleteStickerFromSet' in ctx) 100 | t.true('setStickerSetThumb' in ctx) 101 | t.true('editMessageLiveLocation' in ctx) 102 | t.true('stopMessageLiveLocation' in ctx) 103 | t.true('forwardMessage' in ctx) 104 | t.end() 105 | }) 106 | bot.handleUpdate({ message: BaseTextMessage }) 107 | }) 108 | 109 | test.cb('should provide shortcuts for `callback_query` update', (t) => { 110 | const bot = new Telegraf() 111 | bot.on('callback_query', (ctx) => { 112 | t.true('answerCbQuery' in ctx) 113 | t.true('reply' in ctx) 114 | t.true('replyWithMarkdown' in ctx) 115 | t.true('replyWithHTML' in ctx) 116 | t.true('replyWithPhoto' in ctx) 117 | t.true('replyWithAudio' in ctx) 118 | t.true('replyWithMediaGroup' in ctx) 119 | t.true('replyWithDice' in ctx) 120 | t.true('replyWithDocument' in ctx) 121 | t.true('replyWithInvoice' in ctx) 122 | t.true('replyWithSticker' in ctx) 123 | t.true('replyWithVideo' in ctx) 124 | t.true('replyWithAnimation' in ctx) 125 | t.true('replyWithVideoNote' in ctx) 126 | t.true('replyWithVoice' in ctx) 127 | t.true('replyWithChatAction' in ctx) 128 | t.true('replyWithLocation' in ctx) 129 | t.true('replyWithVenue' in ctx) 130 | t.true('replyWithContact' in ctx) 131 | t.true('kickChatMember' in ctx) 132 | t.true('unbanChatMember' in ctx) 133 | t.true('promoteChatMember' in ctx) 134 | t.true('restrictChatMember' in ctx) 135 | t.true('getChat' in ctx) 136 | t.true('exportChatInviteLink' in ctx) 137 | t.true('setChatPhoto' in ctx) 138 | t.true('deleteChatPhoto' in ctx) 139 | t.true('setChatTitle' in ctx) 140 | t.true('setChatDescription' in ctx) 141 | t.true('pinChatMessage' in ctx) 142 | t.true('unpinChatMessage' in ctx) 143 | t.true('leaveChat' in ctx) 144 | t.true('getChatAdministrators' in ctx) 145 | t.true('getChatMember' in ctx) 146 | t.true('getChatMembersCount' in ctx) 147 | t.true('setChatStickerSet' in ctx) 148 | t.true('deleteChatStickerSet' in ctx) 149 | t.true('deleteMessage' in ctx) 150 | t.true('uploadStickerFile' in ctx) 151 | t.true('createNewStickerSet' in ctx) 152 | t.true('addStickerToSet' in ctx) 153 | t.true('setStickerPositionInSet' in ctx) 154 | t.true('deleteStickerFromSet' in ctx) 155 | t.true('editMessageLiveLocation' in ctx) 156 | t.true('stopMessageLiveLocation' in ctx) 157 | t.true('forwardMessage' in ctx) 158 | t.end() 159 | }) 160 | bot.handleUpdate({ callback_query: BaseTextMessage }) 161 | }) 162 | 163 | test.cb('should provide shortcuts for `shipping_query` update', (t) => { 164 | const bot = new Telegraf() 165 | bot.on('shipping_query', (ctx) => { 166 | t.true('answerShippingQuery' in ctx) 167 | t.end() 168 | }) 169 | bot.handleUpdate({ shipping_query: BaseTextMessage }) 170 | }) 171 | 172 | test.cb('should provide shortcuts for `pre_checkout_query` update', (t) => { 173 | const bot = new Telegraf() 174 | bot.on('pre_checkout_query', (ctx) => { 175 | t.true('answerPreCheckoutQuery' in ctx) 176 | t.end() 177 | }) 178 | bot.handleUpdate({ pre_checkout_query: BaseTextMessage }) 179 | }) 180 | 181 | test.cb('should provide chat and sender info', (t) => { 182 | const bot = new Telegraf() 183 | bot.on(['text', 'message'], (ctx) => { 184 | t.is(ctx.from.id, 42) 185 | t.is(ctx.chat.id, 1) 186 | t.end() 187 | }) 188 | bot.handleUpdate({ message: { ...BaseTextMessage, from: { id: 42 } } }) 189 | }) 190 | 191 | test.cb('should provide shortcuts for `inline_query` update', (t) => { 192 | const bot = new Telegraf() 193 | bot.on('inline_query', (ctx) => { 194 | t.true('answerInlineQuery' in ctx) 195 | t.end() 196 | }) 197 | bot.handleUpdate({ inline_query: BaseTextMessage }) 198 | }) 199 | 200 | test.cb('should provide subtype for `channel_post` update', (t) => { 201 | const bot = new Telegraf('', { channelMode: true }) 202 | bot.on('text', (ctx) => { 203 | t.is(ctx.channelPost.text, 'foo') 204 | t.end() 205 | }) 206 | bot.handleUpdate({ channel_post: BaseTextMessage }) 207 | }) 208 | 209 | test.cb('should share state', (t) => { 210 | const bot = new Telegraf() 211 | bot.on('message', (ctx, next) => { 212 | ctx.state.answer = 41 213 | return next() 214 | }, (ctx, next) => { 215 | ctx.state.answer++ 216 | return next() 217 | }, (ctx) => { 218 | t.is(ctx.state.answer, 42) 219 | t.end() 220 | }) 221 | bot.handleUpdate({ message: BaseTextMessage }) 222 | }) 223 | 224 | test('should store session state', (t) => { 225 | const bot = new Telegraf() 226 | bot.use(session()) 227 | bot.hears('calc', (ctx) => { 228 | t.true('session' in ctx) 229 | t.true('counter' in ctx.session) 230 | t.is(ctx.session.counter, 2) 231 | }) 232 | bot.on('message', (ctx) => { 233 | t.true('session' in ctx) 234 | if (ctx.session == null) ctx.session = { counter: 0 } 235 | ctx.session.counter++ 236 | }) 237 | return bot.handleUpdate({ message: { ...BaseTextMessage, from: { id: 42 }, chat: { id: 42 } } }) 238 | .then(() => bot.handleUpdate({ message: { ...BaseTextMessage, from: { id: 42 }, chat: { id: 42 } } })) 239 | .then(() => bot.handleUpdate({ message: { ...BaseTextMessage, from: { id: 100500 }, chat: { id: 42 } } })) 240 | .then(() => bot.handleUpdate({ message: { ...BaseTextMessage, from: { id: 42 }, chat: { id: 42 }, text: 'calc' } })) 241 | }) 242 | 243 | /* 244 | test('should store session state with custom store', (t) => { 245 | const bot = new Telegraf() 246 | const dummyStore = {} 247 | bot.use(session({ 248 | store: { 249 | get: (key) => new Promise((resolve) => setTimeout(resolve, 100, dummyStore[key])), 250 | set: (key, value) => { 251 | return new Promise((resolve) => setTimeout(resolve, 100)).then(() => { 252 | dummyStore[key] = value 253 | }) 254 | } 255 | } 256 | })) 257 | bot.hears('calc', (ctx) => { 258 | t.true('session' in ctx) 259 | t.true('counter' in ctx.session) 260 | t.is(ctx.session.counter, 2) 261 | }) 262 | bot.on('message', (ctx) => { 263 | t.true('session' in ctx) 264 | ctx.session.counter = ctx.session.counter || 0 265 | ctx.session.counter++ 266 | }) 267 | return bot.handleUpdate({ message: { ...BaseTextMessage, from: { id: 42 }, chat: { id: 42 } } }) 268 | .then(() => bot.handleUpdate({ message: { ...BaseTextMessage, from: { id: 42 }, chat: { id: 42 } } })) 269 | .then(() => bot.handleUpdate({ message: { ...BaseTextMessage, from: { id: 100500 }, chat: { id: 42 } } })) 270 | .then(() => bot.handleUpdate({ message: { ...BaseTextMessage, from: { id: 42 }, chat: { id: 42 }, text: 'calc' } })) 271 | }) 272 | */ 273 | 274 | test.cb('should work with context extensions', (t) => { 275 | const bot = new Telegraf() 276 | bot.context.db = { 277 | getUser: () => undefined 278 | } 279 | bot.on('message', (ctx) => { 280 | t.true('db' in ctx) 281 | t.true('getUser' in ctx.db) 282 | t.end() 283 | }) 284 | bot.handleUpdate({ message: BaseTextMessage }) 285 | }) 286 | 287 | test.cb('should handle webhook response', (t) => { 288 | const bot = new Telegraf() 289 | bot.on('message', async ({ reply }) => { 290 | const result = await reply(':)') 291 | t.deepEqual(result, { webhook: true }) 292 | }) 293 | const res = { 294 | setHeader: () => undefined, 295 | end: () => t.end() 296 | } 297 | bot.handleUpdate({ message: BaseTextMessage }, res) 298 | }) 299 | 300 | const resStub = { 301 | setHeader: () => undefined, 302 | end: () => undefined 303 | } 304 | 305 | test.cb('should respect webhookReply option', (t) => { 306 | const bot = new Telegraf(null, { telegram: { webhookReply: false } }) 307 | bot.catch((err) => { throw err }) // Disable log 308 | bot.on('message', ({ reply }) => reply(':)')) 309 | t.throwsAsync(bot.handleUpdate({ message: BaseTextMessage }, resStub)).then(() => t.end()) 310 | }) 311 | 312 | test.cb('should respect webhookReply runtime change', (t) => { 313 | const bot = new Telegraf() 314 | bot.webhookReply = false 315 | bot.catch((err) => { throw err }) // Disable log 316 | bot.on('message', (ctx) => ctx.reply(':)')) 317 | 318 | // Throws cause Bot Token is required for http call' 319 | t.throwsAsync(bot.handleUpdate({ message: BaseTextMessage }, resStub)).then(() => t.end()) 320 | }) 321 | 322 | test.cb('should respect webhookReply runtime change (per request)', (t) => { 323 | const bot = new Telegraf() 324 | bot.catch((err) => { throw err }) // Disable log 325 | bot.on('message', async (ctx) => { 326 | ctx.webhookReply = false 327 | return ctx.reply(':)') 328 | }) 329 | t.throwsAsync(bot.handleUpdate({ message: BaseTextMessage }, resStub)).then(() => t.end()) 330 | }) 331 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import * as tt from './telegram-types' 2 | import ApiClient from './core/network/client' 3 | import { Tail } from './types' 4 | import Telegram from './telegram' 5 | 6 | type Shorthand> = Tail< 7 | Parameters 8 | > 9 | 10 | const UpdateTypes = [ 11 | 'callback_query', 12 | 'channel_post', 13 | 'chosen_inline_result', 14 | 'edited_channel_post', 15 | 'edited_message', 16 | 'inline_query', 17 | 'shipping_query', 18 | 'pre_checkout_query', 19 | 'message', 20 | 'poll', 21 | 'poll_answer', 22 | ] as const 23 | 24 | const MessageSubTypes = [ 25 | 'voice', 26 | 'video_note', 27 | 'video', 28 | 'animation', 29 | 'venue', 30 | 'text', 31 | 'supergroup_chat_created', 32 | 'successful_payment', 33 | 'sticker', 34 | 'pinned_message', 35 | 'photo', 36 | 'new_chat_title', 37 | 'new_chat_photo', 38 | 'new_chat_members', 39 | 'migrate_to_chat_id', 40 | 'migrate_from_chat_id', 41 | 'location', 42 | 'left_chat_member', 43 | 'invoice', 44 | 'group_chat_created', 45 | 'game', 46 | 'dice', 47 | 'document', 48 | 'delete_chat_photo', 49 | 'contact', 50 | 'channel_chat_created', 51 | 'audio', 52 | 'connected_website', 53 | 'passport_data', 54 | 'poll', 55 | 'forward_date', 56 | ] as const 57 | 58 | const MessageSubTypesMapping = { 59 | forward_date: 'forward', 60 | } 61 | 62 | export class Context { 63 | public botInfo?: tt.UserFromGetMe 64 | readonly updateType: tt.UpdateType 65 | readonly updateSubTypes: ReadonlyArray 66 | readonly state: Record = {} 67 | 68 | constructor( 69 | readonly update: tt.Update, 70 | readonly tg: Telegram, 71 | private readonly options: { channelMode?: boolean; username?: string } = {} 72 | ) { 73 | this.updateType = UpdateTypes.find((key) => key in this.update)! 74 | // prettier-ignore 75 | if (this.updateType === 'message' || (this.options.channelMode && this.updateType === 'channel_post')) { 76 | this.updateSubTypes = MessageSubTypes 77 | .filter((key) => key in (this.update as any)[this.updateType]) 78 | .map((type) => (MessageSubTypesMapping as any)[type] || type) 79 | } else { 80 | this.updateSubTypes = [] 81 | } 82 | Object.getOwnPropertyNames(Context.prototype) 83 | .filter( 84 | (key) => 85 | key !== 'constructor' && typeof (this as any)[key] === 'function' 86 | ) 87 | .forEach((key) => ((this as any)[key] = (this as any)[key].bind(this))) 88 | } 89 | 90 | get me() { 91 | return this.options.username 92 | } 93 | 94 | get telegram() { 95 | return this.tg 96 | } 97 | 98 | get message() { 99 | if (!('message' in this.update)) return undefined 100 | return this.update.message 101 | } 102 | 103 | get editedMessage() { 104 | if (!('edited_message' in this.update)) return undefined 105 | return this.update.edited_message 106 | } 107 | 108 | get inlineQuery() { 109 | if (!('inline_query' in this.update)) return undefined 110 | return this.update.inline_query 111 | } 112 | 113 | get shippingQuery() { 114 | if (!('shipping_query' in this.update)) return undefined 115 | return this.update.shipping_query 116 | } 117 | 118 | get preCheckoutQuery() { 119 | if (!('pre_checkout_query' in this.update)) return undefined 120 | return this.update.pre_checkout_query 121 | } 122 | 123 | get chosenInlineResult() { 124 | if (!('chosen_inline_result' in this.update)) return undefined 125 | return this.update.chosen_inline_result 126 | } 127 | 128 | get channelPost() { 129 | if (!('channel_post' in this.update)) return undefined 130 | return this.update.channel_post 131 | } 132 | 133 | get editedChannelPost() { 134 | if (!('edited_channel_post' in this.update)) return undefined 135 | return this.update.edited_channel_post 136 | } 137 | 138 | get callbackQuery() { 139 | if (!('callback_query' in this.update)) return undefined 140 | return this.update.callback_query 141 | } 142 | 143 | get poll() { 144 | if (!('poll' in this.update)) return undefined 145 | return this.update.poll 146 | } 147 | 148 | get pollAnswer() { 149 | if (!('poll_answer' in this.update)) return undefined 150 | return this.update.poll_answer 151 | } 152 | 153 | get chat() { 154 | return ( 155 | this.message ?? 156 | this.editedMessage ?? 157 | this.callbackQuery?.message ?? 158 | this.channelPost ?? 159 | this.editedChannelPost 160 | )?.chat 161 | } 162 | 163 | get from() { 164 | return ( 165 | this.message ?? 166 | this.editedMessage ?? 167 | this.callbackQuery ?? 168 | this.inlineQuery ?? 169 | this.channelPost ?? 170 | this.editedChannelPost ?? 171 | this.shippingQuery ?? 172 | this.preCheckoutQuery ?? 173 | this.chosenInlineResult 174 | )?.from 175 | } 176 | 177 | get inlineMessageId() { 178 | return (this.callbackQuery ?? this.chosenInlineResult)?.inline_message_id 179 | } 180 | 181 | get passportData() { 182 | if (this.message == null) return undefined 183 | if (!('passport_data' in this.message)) return undefined 184 | return this.message?.passport_data 185 | } 186 | 187 | get webhookReply(): boolean { 188 | return this.tg.webhookReply 189 | } 190 | 191 | set webhookReply(enable: boolean) { 192 | this.tg.webhookReply = enable 193 | } 194 | 195 | private assert( 196 | value: T | undefined, 197 | method: string 198 | ): asserts value is T { 199 | if (value === undefined) { 200 | throw new Error( 201 | `Telegraf: "${method}" isn't available for "${ 202 | this.updateType 203 | }::${this.updateSubTypes.toString()}"` 204 | ) 205 | } 206 | } 207 | 208 | answerInlineQuery(...args: Shorthand<'answerInlineQuery'>) { 209 | this.assert(this.inlineQuery, 'answerInlineQuery') 210 | return this.telegram.answerInlineQuery(this.inlineQuery.id, ...args) 211 | } 212 | 213 | answerCbQuery(...args: Shorthand<'answerCbQuery'>) { 214 | this.assert(this.callbackQuery, 'answerCbQuery') 215 | return this.telegram.answerCbQuery(this.callbackQuery.id, ...args) 216 | } 217 | 218 | answerGameQuery(...args: Shorthand<'answerGameQuery'>) { 219 | this.assert(this.callbackQuery, 'answerGameQuery') 220 | return this.telegram.answerGameQuery(this.callbackQuery.id, ...args) 221 | } 222 | 223 | answerShippingQuery(...args: Shorthand<'answerShippingQuery'>) { 224 | this.assert(this.shippingQuery, 'answerShippingQuery') 225 | return this.telegram.answerShippingQuery(this.shippingQuery.id, ...args) 226 | } 227 | 228 | answerPreCheckoutQuery(...args: Shorthand<'answerPreCheckoutQuery'>) { 229 | this.assert(this.preCheckoutQuery, 'answerPreCheckoutQuery') 230 | return this.telegram.answerPreCheckoutQuery( 231 | this.preCheckoutQuery.id, 232 | ...args 233 | ) 234 | } 235 | 236 | editMessageText(text: string, extra?: tt.ExtraEditMessageText) { 237 | this.assert(this.callbackQuery ?? this.inlineMessageId, 'editMessageText') 238 | return this.telegram.editMessageText( 239 | this.chat?.id, 240 | this.callbackQuery?.message?.message_id, 241 | this.inlineMessageId, 242 | text, 243 | extra 244 | ) 245 | } 246 | 247 | editMessageCaption( 248 | caption: string | undefined, 249 | extra?: tt.InlineKeyboardMarkup 250 | ) { 251 | this.assert( 252 | this.callbackQuery ?? this.inlineMessageId, 253 | 'editMessageCaption' 254 | ) 255 | return this.telegram.editMessageCaption( 256 | this.chat?.id, 257 | this.callbackQuery?.message?.message_id, 258 | this.inlineMessageId, 259 | caption, 260 | extra 261 | ) 262 | } 263 | 264 | editMessageMedia(media: tt.MessageMedia, extra?: tt.ExtraEditMessage) { 265 | this.assert(this.callbackQuery ?? this.inlineMessageId, 'editMessageMedia') 266 | return this.telegram.editMessageMedia( 267 | this.chat?.id, 268 | this.callbackQuery?.message?.message_id, 269 | this.inlineMessageId, 270 | media, 271 | extra 272 | ) 273 | } 274 | 275 | editMessageReplyMarkup(markup: tt.InlineKeyboardMarkup | undefined) { 276 | this.assert( 277 | this.callbackQuery ?? this.inlineMessageId, 278 | 'editMessageReplyMarkup' 279 | ) 280 | return this.telegram.editMessageReplyMarkup( 281 | this.chat?.id, 282 | this.callbackQuery?.message?.message_id, 283 | this.inlineMessageId, 284 | markup 285 | ) 286 | } 287 | 288 | editMessageLiveLocation( 289 | latitude: number, 290 | longitude: number, 291 | markup?: tt.InlineKeyboardMarkup 292 | ) { 293 | this.assert( 294 | this.callbackQuery ?? this.inlineMessageId, 295 | 'editMessageLiveLocation' 296 | ) 297 | return this.telegram.editMessageLiveLocation( 298 | this.chat?.id, 299 | this.callbackQuery?.message?.message_id, 300 | this.inlineMessageId, 301 | latitude, 302 | longitude, 303 | markup 304 | ) 305 | } 306 | 307 | stopMessageLiveLocation(markup?: tt.InlineKeyboardMarkup) { 308 | this.assert( 309 | this.callbackQuery ?? this.inlineMessageId, 310 | 'stopMessageLiveLocation' 311 | ) 312 | return this.telegram.stopMessageLiveLocation( 313 | this.chat?.id, 314 | this.callbackQuery?.message?.message_id, 315 | this.inlineMessageId, 316 | markup 317 | ) 318 | } 319 | 320 | reply(...args: Shorthand<'sendMessage'>) { 321 | this.assert(this.chat, 'reply') 322 | return this.telegram.sendMessage(this.chat.id, ...args) 323 | } 324 | 325 | getChat(...args: Shorthand<'getChat'>) { 326 | this.assert(this.chat, 'getChat') 327 | return this.telegram.getChat(this.chat.id, ...args) 328 | } 329 | 330 | exportChatInviteLink(...args: Shorthand<'exportChatInviteLink'>) { 331 | this.assert(this.chat, 'exportChatInviteLink') 332 | return this.telegram.exportChatInviteLink(this.chat.id, ...args) 333 | } 334 | 335 | kickChatMember(...args: Shorthand<'kickChatMember'>) { 336 | this.assert(this.chat, 'kickChatMember') 337 | return this.telegram.kickChatMember(this.chat.id, ...args) 338 | } 339 | 340 | unbanChatMember(...args: Shorthand<'unbanChatMember'>) { 341 | this.assert(this.chat, 'unbanChatMember') 342 | return this.telegram.unbanChatMember(this.chat.id, ...args) 343 | } 344 | 345 | restrictChatMember(...args: Shorthand<'restrictChatMember'>) { 346 | this.assert(this.chat, 'restrictChatMember') 347 | return this.telegram.restrictChatMember(this.chat.id, ...args) 348 | } 349 | 350 | promoteChatMember(...args: Shorthand<'promoteChatMember'>) { 351 | this.assert(this.chat, 'promoteChatMember') 352 | return this.telegram.promoteChatMember(this.chat.id, ...args) 353 | } 354 | 355 | setChatAdministratorCustomTitle( 356 | ...args: Shorthand<'setChatAdministratorCustomTitle'> 357 | ) { 358 | this.assert(this.chat, 'setChatAdministratorCustomTitle') 359 | return this.telegram.setChatAdministratorCustomTitle(this.chat.id, ...args) 360 | } 361 | 362 | setChatPhoto(...args: Shorthand<'setChatPhoto'>) { 363 | this.assert(this.chat, 'setChatPhoto') 364 | return this.telegram.setChatPhoto(this.chat.id, ...args) 365 | } 366 | 367 | deleteChatPhoto(...args: Shorthand<'deleteChatPhoto'>) { 368 | this.assert(this.chat, 'deleteChatPhoto') 369 | return this.telegram.deleteChatPhoto(this.chat.id, ...args) 370 | } 371 | 372 | setChatTitle(...args: Shorthand<'setChatTitle'>) { 373 | this.assert(this.chat, 'setChatTitle') 374 | return this.telegram.setChatTitle(this.chat.id, ...args) 375 | } 376 | 377 | setChatDescription(...args: Shorthand<'setChatDescription'>) { 378 | this.assert(this.chat, 'setChatDescription') 379 | return this.telegram.setChatDescription(this.chat.id, ...args) 380 | } 381 | 382 | pinChatMessage(...args: Shorthand<'pinChatMessage'>) { 383 | this.assert(this.chat, 'pinChatMessage') 384 | return this.telegram.pinChatMessage(this.chat.id, ...args) 385 | } 386 | 387 | unpinChatMessage(...args: Shorthand<'unpinChatMessage'>) { 388 | this.assert(this.chat, 'unpinChatMessage') 389 | return this.telegram.unpinChatMessage(this.chat.id, ...args) 390 | } 391 | 392 | leaveChat(...args: Shorthand<'leaveChat'>) { 393 | this.assert(this.chat, 'leaveChat') 394 | return this.telegram.leaveChat(this.chat.id, ...args) 395 | } 396 | 397 | setChatPermissions(...args: Shorthand<'setChatPermissions'>) { 398 | this.assert(this.chat, 'setChatPermissions') 399 | return this.telegram.setChatPermissions(this.chat.id, ...args) 400 | } 401 | 402 | getChatAdministrators(...args: Shorthand<'getChatAdministrators'>) { 403 | this.assert(this.chat, 'getChatAdministrators') 404 | return this.telegram.getChatAdministrators(this.chat.id, ...args) 405 | } 406 | 407 | getChatMember(...args: Shorthand<'getChatMember'>) { 408 | this.assert(this.chat, 'getChatMember') 409 | return this.telegram.getChatMember(this.chat.id, ...args) 410 | } 411 | 412 | getChatMembersCount(...args: Shorthand<'getChatMembersCount'>) { 413 | this.assert(this.chat, 'getChatMembersCount') 414 | return this.telegram.getChatMembersCount(this.chat.id, ...args) 415 | } 416 | 417 | setPassportDataErrors(errors: readonly tt.PassportElementError[]) { 418 | this.assert(this.from, 'setPassportDataErrors') 419 | return this.telegram.setPassportDataErrors(this.from.id, errors) 420 | } 421 | 422 | replyWithPhoto(...args: Shorthand<'sendPhoto'>) { 423 | this.assert(this.chat, 'replyWithPhoto') 424 | return this.telegram.sendPhoto(this.chat.id, ...args) 425 | } 426 | 427 | replyWithMediaGroup(...args: Shorthand<'sendMediaGroup'>) { 428 | this.assert(this.chat, 'replyWithMediaGroup') 429 | return this.telegram.sendMediaGroup(this.chat.id, ...args) 430 | } 431 | 432 | replyWithAudio(...args: Shorthand<'sendAudio'>) { 433 | this.assert(this.chat, 'replyWithAudio') 434 | return this.telegram.sendAudio(this.chat.id, ...args) 435 | } 436 | 437 | replyWithDice(...args: Shorthand<'sendDice'>) { 438 | this.assert(this.chat, 'replyWithDice') 439 | return this.telegram.sendDice(this.chat.id, ...args) 440 | } 441 | 442 | replyWithDocument(...args: Shorthand<'sendDocument'>) { 443 | this.assert(this.chat, 'replyWithDocument') 444 | return this.telegram.sendDocument(this.chat.id, ...args) 445 | } 446 | 447 | replyWithSticker(...args: Shorthand<'sendSticker'>) { 448 | this.assert(this.chat, 'replyWithSticker') 449 | return this.telegram.sendSticker(this.chat.id, ...args) 450 | } 451 | 452 | replyWithVideo(...args: Shorthand<'sendVideo'>) { 453 | this.assert(this.chat, 'replyWithVideo') 454 | return this.telegram.sendVideo(this.chat.id, ...args) 455 | } 456 | 457 | replyWithAnimation(...args: Shorthand<'sendAnimation'>) { 458 | this.assert(this.chat, 'replyWithAnimation') 459 | return this.telegram.sendAnimation(this.chat.id, ...args) 460 | } 461 | 462 | replyWithVideoNote(...args: Shorthand<'sendVideoNote'>) { 463 | this.assert(this.chat, 'replyWithVideoNote') 464 | return this.telegram.sendVideoNote(this.chat.id, ...args) 465 | } 466 | 467 | replyWithInvoice(...args: Shorthand<'sendInvoice'>) { 468 | this.assert(this.chat, 'replyWithInvoice') 469 | return this.telegram.sendInvoice(this.chat.id, ...args) 470 | } 471 | 472 | replyWithGame(...args: Shorthand<'sendGame'>) { 473 | this.assert(this.chat, 'replyWithGame') 474 | return this.telegram.sendGame(this.chat.id, ...args) 475 | } 476 | 477 | replyWithVoice(...args: Shorthand<'sendVoice'>) { 478 | this.assert(this.chat, 'replyWithVoice') 479 | return this.telegram.sendVoice(this.chat.id, ...args) 480 | } 481 | 482 | replyWithPoll(...args: Shorthand<'sendPoll'>) { 483 | this.assert(this.chat, 'replyWithPoll') 484 | return this.telegram.sendPoll(this.chat.id, ...args) 485 | } 486 | 487 | replyWithQuiz(...args: Shorthand<'sendQuiz'>) { 488 | this.assert(this.chat, 'replyWithQuiz') 489 | return this.telegram.sendQuiz(this.chat.id, ...args) 490 | } 491 | 492 | stopPoll(...args: Shorthand<'stopPoll'>) { 493 | this.assert(this.chat, 'stopPoll') 494 | return this.telegram.stopPoll(this.chat.id, ...args) 495 | } 496 | 497 | replyWithChatAction(...args: Shorthand<'sendChatAction'>) { 498 | this.assert(this.chat, 'replyWithChatAction') 499 | return this.telegram.sendChatAction(this.chat.id, ...args) 500 | } 501 | 502 | replyWithLocation(...args: Shorthand<'sendLocation'>) { 503 | this.assert(this.chat, 'replyWithLocation') 504 | return this.telegram.sendLocation(this.chat.id, ...args) 505 | } 506 | 507 | replyWithVenue(...args: Shorthand<'sendVenue'>) { 508 | this.assert(this.chat, 'replyWithVenue') 509 | return this.telegram.sendVenue(this.chat.id, ...args) 510 | } 511 | 512 | replyWithContact(...args: Shorthand<'sendContact'>) { 513 | this.assert(this.chat, 'replyWithContact') 514 | return this.telegram.sendContact(this.chat.id, ...args) 515 | } 516 | 517 | getStickerSet(setName: string) { 518 | return this.telegram.getStickerSet(setName) 519 | } 520 | 521 | setChatStickerSet(setName: string) { 522 | this.assert(this.chat, 'setChatStickerSet') 523 | return this.telegram.setChatStickerSet(this.chat.id, setName) 524 | } 525 | 526 | deleteChatStickerSet() { 527 | this.assert(this.chat, 'deleteChatStickerSet') 528 | return this.telegram.deleteChatStickerSet(this.chat.id) 529 | } 530 | 531 | setStickerPositionInSet(sticker: string, position: number) { 532 | return this.telegram.setStickerPositionInSet(sticker, position) 533 | } 534 | 535 | setStickerSetThumb(...args: Parameters) { 536 | return this.telegram.setStickerSetThumb(...args) 537 | } 538 | 539 | deleteStickerFromSet(sticker: string) { 540 | return this.telegram.deleteStickerFromSet(sticker) 541 | } 542 | 543 | uploadStickerFile(...args: Shorthand<'uploadStickerFile'>) { 544 | this.assert(this.from, 'uploadStickerFile') 545 | return this.telegram.uploadStickerFile(this.from.id, ...args) 546 | } 547 | 548 | createNewStickerSet(...args: Shorthand<'createNewStickerSet'>) { 549 | this.assert(this.from, 'createNewStickerSet') 550 | return this.telegram.createNewStickerSet(this.from.id, ...args) 551 | } 552 | 553 | addStickerToSet(...args: Shorthand<'addStickerToSet'>) { 554 | this.assert(this.from, 'addStickerToSet') 555 | return this.telegram.addStickerToSet(this.from.id, ...args) 556 | } 557 | 558 | getMyCommands() { 559 | return this.telegram.getMyCommands() 560 | } 561 | 562 | setMyCommands(commands: readonly tt.BotCommand[]) { 563 | return this.telegram.setMyCommands(commands) 564 | } 565 | 566 | replyWithMarkdown(markdown: string, extra?: tt.ExtraEditMessage) { 567 | return this.reply(markdown, { parse_mode: 'Markdown', ...extra }) 568 | } 569 | 570 | replyWithMarkdownV2(markdown: string, extra?: tt.ExtraEditMessage) { 571 | return this.reply(markdown, { parse_mode: 'MarkdownV2', ...extra }) 572 | } 573 | 574 | replyWithHTML(html: string, extra?: tt.ExtraEditMessage) { 575 | return this.reply(html, { parse_mode: 'HTML', ...extra }) 576 | } 577 | 578 | deleteMessage(messageId?: number) { 579 | this.assert(this.chat, 'deleteMessage') 580 | if (typeof messageId !== 'undefined') { 581 | return this.telegram.deleteMessage(this.chat.id, messageId) 582 | } 583 | this.assert(this.message, 'deleteMessage') 584 | return this.telegram.deleteMessage(this.chat.id, this.message.message_id) 585 | } 586 | 587 | forwardMessage( 588 | chatId: string | number, 589 | extra?: { 590 | disable_notification?: boolean 591 | } 592 | ) { 593 | this.assert(this.message, 'forwardMessage') 594 | return this.telegram.forwardMessage( 595 | chatId, 596 | this.message.chat.id, 597 | this.message.message_id, 598 | extra 599 | ) 600 | } 601 | } 602 | 603 | export default Context 604 | -------------------------------------------------------------------------------- /test/composer.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const { Composer, Telegraf } = require('../') 3 | 4 | const baseMessage = { chat: { id: 1 }, from: { id: 42, username: 'telegraf' } } 5 | const baseGroupMessage = { chat: { id: 1, type: 'group' } } 6 | 7 | const topLevelUpdates = [ 8 | { type: 'message', update: { message: baseMessage } }, 9 | { type: 'edited_message', update: { edited_message: baseMessage } }, 10 | { type: 'callback_query', update: { callback_query: { message: baseMessage } } }, 11 | { type: 'inline_query', update: { inline_query: {} } }, 12 | { type: 'channel_post', update: { channel_post: {} } }, 13 | { type: 'edited_channel_post', update: { edited_channel_post: {} } }, 14 | { type: 'chosen_inline_result', update: { chosen_inline_result: {} } }, 15 | { type: 'poll', update: { poll: {} } } 16 | ] 17 | 18 | topLevelUpdates.forEach((update) => { 19 | test.cb('should route ' + update.type, (t) => { 20 | const bot = new Telegraf() 21 | bot.on(update.type, () => t.end()) 22 | bot.handleUpdate(update.update) 23 | }) 24 | }) 25 | 26 | test.cb('should route many types', (t) => { 27 | const bot = new Telegraf() 28 | bot.on(['chosen_inline_result', 'message'], () => t.end()) 29 | bot.handleUpdate({ inline_query: baseMessage }) 30 | bot.handleUpdate({ message: baseMessage }) 31 | }) 32 | 33 | test.cb('should route sub types', (t) => { 34 | const bot = new Telegraf() 35 | bot.on('text', () => t.end()) 36 | bot.handleUpdate({ message: { voice: {}, ...baseMessage } }) 37 | bot.handleUpdate({ message: { text: 'hello', ...baseMessage } }) 38 | }) 39 | 40 | const updateTypes = [ 41 | 'voice', 42 | 'video_note', 43 | 'video', 44 | 'animation', 45 | 'venue', 46 | 'text', 47 | 'supergroup_chat_created', 48 | 'successful_payment', 49 | 'sticker', 50 | 'pinned_message', 51 | 'photo', 52 | 'new_chat_title', 53 | 'new_chat_photo', 54 | 'new_chat_members', 55 | 'migrate_to_chat_id', 56 | 'migrate_from_chat_id', 57 | 'location', 58 | 'left_chat_member', 59 | 'invoice', 60 | 'group_chat_created', 61 | 'game', 62 | 'dice', 63 | 'document', 64 | 'delete_chat_photo', 65 | 'contact', 66 | 'channel_chat_created', 67 | 'audio', 68 | 'poll' 69 | ] 70 | 71 | updateTypes.forEach((update) => { 72 | test.cb('should route update type: ' + update, (t) => { 73 | const bot = new Telegraf() 74 | bot.on(update, (ctx) => { 75 | t.end() 76 | }) 77 | const message = { ...baseMessage } 78 | message[update] = {} 79 | bot.handleUpdate({ message: message }) 80 | }) 81 | }) 82 | 83 | test.cb('should route venue', (t) => { 84 | const bot = new Telegraf() 85 | bot.on('venue', () => t.end()) 86 | const message = { location: {}, venue: { title: 'location', address: 'n/a' }, ...baseMessage } 87 | bot.handleUpdate({ message: message }) 88 | }) 89 | 90 | test.cb('should route location', (t) => { 91 | const bot = new Telegraf() 92 | bot.on('venue', (ctx) => { 93 | t.true(ctx.updateSubTypes.includes('venue')) 94 | t.true(ctx.updateSubTypes.includes('location')) 95 | t.end() 96 | }) 97 | const message = { location: {}, venue: { title: 'location', address: 'n/a' }, ...baseMessage } 98 | bot.handleUpdate({ message: message }) 99 | }) 100 | 101 | test.cb('should route forward', (t) => { 102 | const bot = new Telegraf() 103 | bot.on('forward', (ctx) => { 104 | t.true(ctx.updateSubTypes.includes('forward')) 105 | t.end() 106 | }) 107 | const message = { 108 | forward_date: 1460829948, 109 | ...baseMessage 110 | } 111 | bot.handleUpdate({ message: message }) 112 | }) 113 | 114 | test('should throw error then called with undefined middleware', (t) => { 115 | const composer = new Composer() 116 | t.throws(() => { 117 | composer.compose(() => undefined) 118 | }) 119 | }) 120 | 121 | test('should throw error then called with invalid middleware', (t) => { 122 | const bot = new Telegraf() 123 | t.throws(() => { 124 | bot.on('text', 'foo') 125 | }) 126 | }) 127 | 128 | test.cb('should throw error then "next()" called twice', (t) => { 129 | const bot = new Telegraf() 130 | bot.catch((e) => t.end()) 131 | bot.use((ctx, next) => { 132 | next() 133 | return next() 134 | }) 135 | bot.handleUpdate({ message: { text: 'hello', ...baseMessage } }) 136 | }) 137 | 138 | test.cb('should throw error then "next()" called with wrong context', (t) => { 139 | const bot = new Telegraf() 140 | bot.catch((e) => t.end()) 141 | bot.use((ctx, next) => next('bad context')) 142 | bot.hears('hello', () => t.fail()) 143 | bot.handleUpdate({ message: { text: 'hello', ...baseMessage } }) 144 | }) 145 | 146 | test('should throw error then called with undefined trigger', (t) => { 147 | const bot = new Telegraf() 148 | t.throws(() => { 149 | bot.hears(['foo', null]) 150 | }) 151 | }) 152 | 153 | test.cb('should support Composer instance as middleware', (t) => { 154 | const bot = new Telegraf() 155 | const composer = new Composer() 156 | composer.on('text', (ctx) => { 157 | t.is('bar', ctx.state.foo) 158 | t.end() 159 | }) 160 | bot.use(({ state }, next) => { 161 | state.foo = 'bar' 162 | return next() 163 | }, composer) 164 | bot.handleUpdate({ message: { text: 'hello', ...baseMessage } }) 165 | }) 166 | 167 | test.cb('should support Composer instance as handler', (t) => { 168 | const bot = new Telegraf() 169 | const composer = new Composer() 170 | composer.on('text', () => t.end()) 171 | bot.on('text', composer) 172 | bot.handleUpdate({ message: { text: 'hello', ...baseMessage } }) 173 | }) 174 | 175 | test.cb('should handle text triggers', (t) => { 176 | const bot = new Telegraf() 177 | bot.hears('hello world', () => t.end()) 178 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 179 | }) 180 | 181 | test.cb('should handle fork', (t) => { 182 | const bot = new Telegraf() 183 | bot.use(Telegraf.fork(() => t.end())) 184 | bot.handleUpdate({ message: { voice: {}, ...baseMessage } }) 185 | }) 186 | 187 | test.cb('Composer.branch should work with value', (t) => { 188 | const bot = new Telegraf() 189 | bot.use(Composer.branch(true, () => t.end())) 190 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 191 | }) 192 | 193 | test.cb('Composer.branch should work with fn', (t) => { 194 | const bot = new Telegraf() 195 | bot.use(Composer.branch((ctx) => false, null, () => t.end())) 196 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 197 | }) 198 | 199 | test.cb('Composer.branch should work with async fn', (t) => { 200 | const bot = new Telegraf() 201 | bot.use(Composer.branch( 202 | (ctx) => { 203 | return new Promise((resolve) => setTimeout(resolve, 100, false)) 204 | }, 205 | () => { 206 | t.fail() 207 | t.end() 208 | }, 209 | () => t.end()) 210 | ) 211 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 212 | }) 213 | 214 | test.cb('Composer.acl should work with user id', (t) => { 215 | const bot = new Telegraf() 216 | bot.use(Composer.acl(42, () => t.end())) 217 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 218 | }) 219 | 220 | test.cb('Composer.acl should passthru', (t) => { 221 | const bot = new Telegraf() 222 | bot.use(Composer.acl(42, Composer.passThru())) 223 | bot.use(() => t.end()) 224 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 225 | }) 226 | 227 | test.cb('Composer.acl should not be false positive', (t) => { 228 | const bot = new Telegraf() 229 | bot.use(Composer.acl(999, () => t.fail())) 230 | bot.use(() => t.end()) 231 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 232 | }) 233 | 234 | test.cb('Composer.acl should work with user ids', (t) => { 235 | const bot = new Telegraf() 236 | bot.use(Composer.acl([42, 43], () => t.end())) 237 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 238 | }) 239 | 240 | test.cb('Composer.acl should work with fn', (t) => { 241 | const bot = new Telegraf() 242 | bot.use(Composer.acl((ctx) => ctx.from.username === 'telegraf', () => t.end())) 243 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 244 | }) 245 | 246 | test.cb('Composer.acl should work with async fn', (t) => { 247 | const bot = new Telegraf() 248 | bot.use(Composer.acl((ctx) => new Promise((resolve) => setTimeout(resolve, 100, true)), () => t.end())) 249 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 250 | }) 251 | 252 | test.cb('Composer.optional should work with truthy value', (t) => { 253 | const bot = new Telegraf() 254 | bot.use(Composer.optional(true, () => t.end())) 255 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 256 | }) 257 | 258 | test.cb('Composer.optional should work with false value', (t) => { 259 | const bot = new Telegraf() 260 | bot.use(Composer.optional(false, () => { 261 | t.fail() 262 | t.end() 263 | })) 264 | bot.use(() => t.end()) 265 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 266 | }) 267 | 268 | test.cb('Composer.optional should work with fn', (t) => { 269 | const bot = new Telegraf() 270 | bot.use(Composer.optional((ctx) => true, () => t.end())) 271 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 272 | }) 273 | 274 | test.cb('Composer.optional should work with async fn', (t) => { 275 | const bot = new Telegraf() 276 | bot.use(Composer.optional( 277 | (ctx) => { 278 | return new Promise((resolve) => { 279 | setTimeout(() => { 280 | resolve(false) 281 | }, 100) 282 | }) 283 | }, 284 | () => { 285 | t.fail() 286 | t.end() 287 | } 288 | )) 289 | bot.use(() => t.end()) 290 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 291 | }) 292 | 293 | test.cb('Composer.filter should work with fn', (t) => { 294 | const bot = new Telegraf() 295 | bot.filter(({ message }) => message.text.length < 2) 296 | bot.use(() => t.end()) 297 | bot.handleUpdate({ message: { text: '-', ...baseMessage } }) 298 | bot.handleUpdate({ message: { text: 'hello', ...baseMessage } }) 299 | bot.handleUpdate({ message: { text: 'hello world ', ...baseMessage } }) 300 | }) 301 | 302 | test.cb('Composer.filter should work with async fn', (t) => { 303 | const bot = new Telegraf() 304 | bot.filter(({ message }) => { 305 | return new Promise((resolve) => { 306 | setTimeout(() => { 307 | resolve(message.text.length < 2) 308 | }, 100) 309 | }) 310 | }) 311 | bot.use(() => t.end()) 312 | bot.handleUpdate({ message: { text: '-', ...baseMessage } }) 313 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 314 | }) 315 | 316 | test.cb('Composer.drop should work with fn', (t) => { 317 | const bot = new Telegraf() 318 | bot.drop(({ message }) => message.text.length > 2) 319 | bot.use(() => t.end()) 320 | bot.handleUpdate({ message: { text: '-', ...baseMessage } }) 321 | bot.handleUpdate({ message: { text: 'hello', ...baseMessage } }) 322 | bot.handleUpdate({ message: { text: 'hello world ', ...baseMessage } }) 323 | }) 324 | 325 | test.cb('Composer.drop should work with async fn', (t) => { 326 | const bot = new Telegraf() 327 | bot.drop(({ message }) => { 328 | return new Promise((resolve) => { 329 | setTimeout(() => { 330 | resolve(message.text.length > 2) 331 | }, 100) 332 | }) 333 | }) 334 | bot.use(() => t.end()) 335 | bot.handleUpdate({ message: { text: '-', ...baseMessage } }) 336 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 337 | }) 338 | 339 | test.cb('Composer.lazy should work with fn', (t) => { 340 | const bot = new Telegraf() 341 | bot.use(Composer.lazy((ctx) => () => t.end())) 342 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 343 | }) 344 | 345 | test.cb('Composer.lazy should support middlewares', (t) => { 346 | const bot = new Telegraf() 347 | bot.use(Composer.lazy((ctx) => (_, next) => next())) 348 | bot.use(() => t.end()) 349 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 350 | }) 351 | 352 | test.cb('Composer.dispatch should work with handlers array', (t) => { 353 | const bot = new Telegraf() 354 | bot.use(Composer.dispatch(1, [ 355 | () => { 356 | t.fail() 357 | t.end() 358 | }, 359 | () => t.end() 360 | ])) 361 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 362 | }) 363 | 364 | test.cb('Composer.dispatch should work', (t) => { 365 | const bot = new Telegraf() 366 | bot.use(Composer.dispatch('b', { 367 | b: () => t.end() 368 | })) 369 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 370 | }) 371 | 372 | test.cb('Composer.dispatch should work with async fn', (t) => { 373 | const bot = new Telegraf() 374 | bot.use(Composer.dispatch( 375 | (ctx) => { 376 | return new Promise((resolve) => { 377 | setTimeout(() => { 378 | resolve(1) 379 | }, 300) 380 | }) 381 | }, [ 382 | () => { 383 | t.fail() 384 | t.end() 385 | }, 386 | () => t.end() 387 | ])) 388 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 389 | }) 390 | 391 | test.cb('Composer.log should just work', (t) => { 392 | const bot = new Telegraf() 393 | bot.use(Composer.log(() => t.end())) 394 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 395 | }) 396 | 397 | test.cb('Composer.entity should work', (t) => { 398 | const bot = new Telegraf() 399 | bot.use(Composer.entity('hashtag', () => t.end())) 400 | bot.handleUpdate({ message: { text: '#foo', entities: [{ type: 'hashtag', offset: 0, length: 4 }] } }) 401 | }) 402 | 403 | test.cb('Composer.entity should not infer', (t) => { 404 | const bot = new Telegraf() 405 | bot.use(Composer.entity('command', () => t.end())) 406 | bot.use(() => t.end()) 407 | bot.handleUpdate({ message: { text: '#foo', entities: [{ type: 'hashtag', offset: 0, length: 4 }] } }) 408 | }) 409 | 410 | test.cb('Composer.entity should work with arrays', (t) => { 411 | const bot = new Telegraf() 412 | bot.use(Composer.entity(['command', 'hashtag'], () => t.end())) 413 | bot.handleUpdate({ message: { text: '#foo', entities: [{ type: 'hashtag', offset: 0, length: 4 }] } }) 414 | }) 415 | 416 | test.cb('Composer.entity should work with predicate', (t) => { 417 | const bot = new Telegraf() 418 | bot.use(Composer.entity((entity, value) => entity.type === 'hashtag' && value === '#foo', () => t.end())) 419 | bot.handleUpdate({ message: { text: '#foo', entities: [{ type: 'hashtag', offset: 0, length: 4 }] } }) 420 | }) 421 | 422 | test.cb('Composer.mention should work', (t) => { 423 | const bot = new Telegraf() 424 | bot.use(Composer.mention(() => t.end())) 425 | bot.handleUpdate({ message: { text: 'bar @foo', entities: [{ type: 'mention', offset: 4, length: 4 }] } }) 426 | }) 427 | 428 | test.cb('Composer.mention should work with pattern', (t) => { 429 | const bot = new Telegraf() 430 | bot.use(Composer.mention('foo', () => t.end())) 431 | bot.handleUpdate({ message: { text: 'bar @foo', entities: [{ type: 'mention', offset: 4, length: 4 }] } }) 432 | }) 433 | 434 | test.cb('Composer.hashtag should work', (t) => { 435 | const bot = new Telegraf() 436 | bot.use(Composer.hashtag(() => t.end())) 437 | bot.handleUpdate({ message: { text: '#foo', entities: [{ type: 'hashtag', offset: 0, length: 4 }] } }) 438 | }) 439 | 440 | test.cb('Composer.hashtag should work with pattern', (t) => { 441 | const bot = new Telegraf() 442 | bot.use(Composer.hashtag('foo', () => t.end())) 443 | bot.handleUpdate({ message: { text: 'bar #foo', entities: [{ type: 'hashtag', offset: 4, length: 4 }] } }) 444 | }) 445 | 446 | test.cb('Composer.hashtag should work with hash pattern', (t) => { 447 | const bot = new Telegraf() 448 | bot.use(Composer.hashtag('#foo', () => t.end())) 449 | bot.handleUpdate({ message: { text: 'bar #foo', entities: [{ type: 'hashtag', offset: 4, length: 4 }] } }) 450 | }) 451 | 452 | test.cb('Composer.hashtag should work with patterns array', (t) => { 453 | const bot = new Telegraf() 454 | bot.use(Composer.hashtag(['news', 'foo'], () => t.end())) 455 | bot.handleUpdate({ message: { text: 'bar #foo', entities: [{ type: 'hashtag', offset: 4, length: 4 }] } }) 456 | }) 457 | 458 | test.cb('should handle text triggers via functions', (t) => { 459 | const bot = new Telegraf() 460 | bot.hears((text) => text.startsWith('Hi'), () => t.end()) 461 | bot.handleUpdate({ message: { text: 'Hi there!', ...baseMessage } }) 462 | }) 463 | 464 | test.cb('should handle regex triggers', (t) => { 465 | const bot = new Telegraf() 466 | bot.hears(/hello (.+)/, (ctx) => { 467 | t.is('world', ctx.match[1]) 468 | t.end() 469 | }) 470 | bot.handleUpdate({ message: { text: 'Ola!', ...baseMessage } }) 471 | bot.handleUpdate({ message: { text: 'hello world', ...baseMessage } }) 472 | }) 473 | 474 | test.cb('should handle command', (t) => { 475 | const bot = new Telegraf() 476 | bot.command('foo', () => t.end()) 477 | bot.handleUpdate({ message: { text: '/foo', entities: [{ type: 'bot_command', offset: 0, length: 4 }], ...baseMessage } }) 478 | }) 479 | 480 | test.cb('should handle start command', (t) => { 481 | const bot = new Telegraf() 482 | bot.start(() => t.end()) 483 | bot.handleUpdate({ message: { text: '/start', entities: [{ type: 'bot_command', offset: 0, length: 6 }], ...baseMessage } }) 484 | }) 485 | 486 | test.cb('should handle help command', (t) => { 487 | const bot = new Telegraf() 488 | bot.help(() => t.end()) 489 | bot.handleUpdate({ message: { text: '/help', entities: [{ type: 'bot_command', offset: 0, length: 5 }], ...baseMessage } }) 490 | }) 491 | 492 | test.cb('should handle settings command', (t) => { 493 | const bot = new Telegraf() 494 | bot.settings(() => t.end()) 495 | bot.handleUpdate({ message: { text: '/settings', entities: [{ type: 'bot_command', offset: 0, length: 9 }], ...baseMessage } }) 496 | }) 497 | 498 | test.cb('should handle group command', (t) => { 499 | const bot = new Telegraf(null, { username: 'bot' }) 500 | bot.start(() => t.end()) 501 | bot.handleUpdate({ message: { text: '/start@bot', entities: [{ type: 'bot_command', offset: 0, length: 10 }], ...baseGroupMessage } }) 502 | }) 503 | 504 | test.cb('should handle game query', (t) => { 505 | const bot = new Telegraf() 506 | bot.gameQuery(() => t.end()) 507 | bot.handleUpdate({ callback_query: { game_short_name: 'foo' } }) 508 | }) 509 | 510 | test.cb('should handle action', (t) => { 511 | const bot = new Telegraf() 512 | bot.action('foo', () => t.end()) 513 | bot.handleUpdate({ callback_query: { data: 'foo' } }) 514 | }) 515 | 516 | test.cb('should handle regex action', (t) => { 517 | const bot = new Telegraf() 518 | bot.action(/foo (\d+)/, (ctx) => { 519 | t.true('match' in ctx) 520 | t.is('42', ctx.match[1]) 521 | t.end() 522 | }) 523 | bot.handleUpdate({ callback_query: { data: 'foo 42' } }) 524 | }) 525 | 526 | test.cb('should handle inline query', (t) => { 527 | const bot = new Telegraf() 528 | bot.inlineQuery('foo', () => t.end()) 529 | bot.handleUpdate({ inline_query: { query: 'foo' } }) 530 | }) 531 | 532 | test.cb('should handle regex inline query', (t) => { 533 | const bot = new Telegraf() 534 | bot.inlineQuery(/foo (\d+)/, (ctx) => { 535 | t.true('match' in ctx) 536 | t.is('42', ctx.match[1]) 537 | t.end() 538 | }) 539 | bot.handleUpdate({ inline_query: { query: 'foo 42' } }) 540 | }) 541 | 542 | test.cb('should support middlewares', (t) => { 543 | const bot = new Telegraf() 544 | bot.action('bar', (ctx) => { 545 | t.fail() 546 | }) 547 | bot.use(() => t.end()) 548 | bot.handleUpdate({ callback_query: { data: 'foo' } }) 549 | }) 550 | 551 | test.cb('should handle short command', (t) => { 552 | const bot = new Telegraf() 553 | bot.start(() => t.end()) 554 | bot.handleUpdate({ message: { text: '/start', entities: [{ type: 'bot_command', offset: 0, length: 6 }], ...baseMessage } }) 555 | }) 556 | 557 | test.cb('should handle command in group', (t) => { 558 | const bot = new Telegraf('---', { username: 'bot' }) 559 | bot.start(() => t.end()) 560 | bot.handleUpdate({ message: { text: '/start@bot', entities: [{ type: 'bot_command', offset: 0, length: 10 }], chat: { id: 2, type: 'group' } } }) 561 | }) 562 | 563 | test.cb('should handle command in supergroup', (t) => { 564 | const bot = new Telegraf() 565 | bot.options.username = 'bot' 566 | bot.start(() => t.end()) 567 | bot.handleUpdate({ message: { text: '/start@bot', entities: [{ type: 'bot_command', offset: 0, length: 10 }], chat: { id: 2, type: 'supergroup' } } }) 568 | }) 569 | -------------------------------------------------------------------------------- /src/composer.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import * as tt from './telegram-types' 4 | import { Middleware, NonemptyReadonlyArray } from './types' 5 | import Context from './context' 6 | 7 | type MaybeArray = T | T[] 8 | type MaybePromise = T | Promise 9 | type Triggers = MaybeArray< 10 | string | RegExp | ((value: string, ctx: TContext) => RegExpExecArray | null) 11 | > 12 | type Predicate = (t: T) => boolean 13 | type AsyncPredicate = (t: T) => Promise 14 | 15 | function always(x: T) { 16 | return () => x 17 | } 18 | const anoop = always(Promise.resolve()) 19 | 20 | function getEntities(msg: tt.Message | undefined): tt.MessageEntity[] { 21 | if (msg == null) return [] 22 | if ('caption_entities' in msg) return msg.caption_entities ?? [] 23 | if ('entities' in msg) return msg.entities ?? [] 24 | return [] 25 | } 26 | function getText( 27 | msg: tt.Message | tt.CallbackQuery | undefined 28 | ): string | undefined { 29 | if (msg == null) return undefined 30 | if ('caption' in msg) return msg.caption 31 | if ('text' in msg) return msg.text 32 | if ('data' in msg) return msg.data 33 | if ('game_short_name' in msg) return msg.game_short_name 34 | return undefined 35 | } 36 | 37 | export class Composer 38 | implements Middleware.Obj { 39 | private handler: Middleware.Fn 40 | 41 | constructor(...fns: ReadonlyArray>) { 42 | this.handler = Composer.compose(fns) 43 | } 44 | 45 | /** 46 | * Registers a middleware. 47 | */ 48 | use(...fns: ReadonlyArray>) { 49 | this.handler = Composer.compose([this.handler, ...fns]) 50 | return this 51 | } 52 | 53 | /** 54 | * Registers middleware for handling provided update types. 55 | */ 56 | on( 57 | updateTypes: MaybeArray, 58 | ...fns: NonemptyReadonlyArray> 59 | ) { 60 | return this.use(Composer.mount(updateTypes, ...fns)) 61 | } 62 | 63 | /** 64 | * Registers middleware for handling matching text messages. 65 | */ 66 | hears( 67 | triggers: Triggers, 68 | ...fns: ReadonlyArray> 69 | ) { 70 | return this.use(Composer.hears(triggers, ...fns)) 71 | } 72 | 73 | /** 74 | * Registers middleware for handling specified commands. 75 | */ 76 | command( 77 | commands: MaybeArray, 78 | ...fns: NonemptyReadonlyArray> 79 | ) { 80 | return this.use(Composer.command(commands, ...fns)) 81 | } 82 | 83 | /** 84 | * Registers middleware for handling matching callback queries. 85 | */ 86 | action( 87 | triggers: Triggers, 88 | ...fns: ReadonlyArray> 89 | ) { 90 | return this.use(Composer.action(triggers, ...fns)) 91 | } 92 | 93 | /** 94 | * Registers middleware for handling matching inline queries. 95 | */ 96 | inlineQuery( 97 | triggers: Triggers, 98 | ...fns: ReadonlyArray> 99 | ) { 100 | return this.use(Composer.inlineQuery(triggers, ...fns)) 101 | } 102 | 103 | gameQuery(...fns: NonemptyReadonlyArray>) { 104 | return this.use(Composer.gameQuery(...fns)) 105 | } 106 | 107 | /** 108 | * Registers middleware for dropping matching updates. 109 | */ 110 | drop(predicate: Predicate) { 111 | return this.use(Composer.drop(predicate)) 112 | } 113 | 114 | filter(predicate: Predicate) { 115 | return this.use(Composer.filter(predicate)) 116 | } 117 | 118 | private entity(...args: Parameters) { 119 | return this.use(Composer.entity(...args)) 120 | } 121 | 122 | email(...args: Parameters) { 123 | return this.use(Composer.email(...args)) 124 | } 125 | 126 | url(...args: Parameters) { 127 | return this.use(Composer.url(...args)) 128 | } 129 | 130 | textLink(...args: Parameters) { 131 | return this.use(Composer.textLink(...args)) 132 | } 133 | 134 | textMention(...args: Parameters) { 135 | return this.use(Composer.textMention(...args)) 136 | } 137 | 138 | mention(...args: Parameters) { 139 | return this.use(Composer.mention(...args)) 140 | } 141 | 142 | phone(...args: Parameters) { 143 | return this.use(Composer.phone(...args)) 144 | } 145 | 146 | hashtag(...args: Parameters) { 147 | return this.use(Composer.hashtag(...args)) 148 | } 149 | 150 | cashtag(...args: Parameters) { 151 | return this.use(Composer.cashtag(...args)) 152 | } 153 | 154 | /** 155 | * Registers a middleware for handling /start 156 | */ 157 | start( 158 | ...fns: ReadonlyArray> 159 | ) { 160 | const handler = Composer.compose(fns) 161 | return this.command('start', (ctx, next) => { 162 | // @ts-expect-error 163 | const startPayload = ctx.message.text.substring(7) 164 | return handler(Object.assign(ctx, { startPayload }), next) 165 | }) 166 | } 167 | 168 | /** 169 | * Registers a middleware for handling /help 170 | */ 171 | help(...fns: NonemptyReadonlyArray>) { 172 | return this.command('help', ...fns) 173 | } 174 | 175 | /** 176 | * Registers a middleware for handling /settings 177 | */ 178 | settings(...fns: NonemptyReadonlyArray>) { 179 | return this.command('settings', ...fns) 180 | } 181 | 182 | middleware() { 183 | return this.handler 184 | } 185 | 186 | static reply(...args: Parameters) { 187 | return (ctx: Context) => ctx.reply(...args) 188 | } 189 | 190 | private static catchAll( 191 | ...fns: ReadonlyArray> 192 | ) { 193 | return Composer.catch((err) => { 194 | console.error() 195 | console.error((err.stack || err.toString()).replace(/^/gm, ' ')) 196 | console.error() 197 | }, ...fns) 198 | } 199 | 200 | static catch( 201 | errorHandler: (err: any, ctx: TContext) => void, 202 | ...fns: ReadonlyArray> 203 | ): Middleware.Fn { 204 | const handler = Composer.compose(fns) 205 | // prettier-ignore 206 | return (ctx, next) => Promise.resolve(handler(ctx, next)) 207 | .catch((err) => errorHandler(err, ctx)) 208 | } 209 | 210 | /** 211 | * Generates middleware that runs in the background. 212 | * @deprecated 213 | */ 214 | static fork( 215 | middleware: Middleware 216 | ): Middleware.Fn { 217 | const handler = Composer.unwrap(middleware) 218 | return async (ctx, next) => { 219 | await Promise.all([handler(ctx, anoop), next()]) 220 | } 221 | } 222 | 223 | static tap( 224 | middleware: Middleware 225 | ): Middleware.Fn { 226 | const handler = Composer.unwrap(middleware) 227 | return (ctx, next) => 228 | Promise.resolve(handler(ctx, anoop)).then(() => next()) 229 | } 230 | 231 | static passThru(): Middleware.Fn { 232 | return (ctx, next) => next() 233 | } 234 | 235 | private static safePassThru() { 236 | // prettier-ignore 237 | // @ts-expect-error 238 | return (ctx, next) => typeof next === 'function' ? next(ctx) : Promise.resolve() 239 | } 240 | 241 | static lazy( 242 | factoryFn: (ctx: TContext) => MaybePromise> 243 | ): Middleware.Fn { 244 | if (typeof factoryFn !== 'function') { 245 | throw new Error('Argument must be a function') 246 | } 247 | return (ctx, next) => 248 | Promise.resolve(factoryFn(ctx)).then((middleware) => 249 | Composer.unwrap(middleware)(ctx, next) 250 | ) 251 | } 252 | 253 | static log(logFn: (s: string) => void = console.log): Middleware.Fn { 254 | return (ctx, next) => { 255 | logFn(JSON.stringify(ctx.update, null, 2)) 256 | return next() 257 | } 258 | } 259 | 260 | /** 261 | * @param trueMiddleware middleware to run if the predicate returns true 262 | * @param falseMiddleware middleware to run if the predicate returns false 263 | */ 264 | static branch( 265 | predicate: Predicate | AsyncPredicate, 266 | trueMiddleware: Middleware, 267 | falseMiddleware: Middleware 268 | ) { 269 | if (typeof predicate !== 'function') { 270 | return Composer.unwrap(predicate ? trueMiddleware : falseMiddleware) 271 | } 272 | return Composer.lazy((ctx) => 273 | Promise.resolve(predicate(ctx)).then((value) => 274 | value ? trueMiddleware : falseMiddleware 275 | ) 276 | ) 277 | } 278 | 279 | /** 280 | * Generates optional middleware. 281 | * @param middleware middleware to run if the predicate returns true 282 | */ 283 | static optional( 284 | predicate: Predicate | AsyncPredicate, 285 | ...fns: NonemptyReadonlyArray> 286 | ) { 287 | return Composer.branch( 288 | predicate, 289 | Composer.compose(fns), 290 | Composer.passThru() 291 | ) 292 | } 293 | 294 | static filter(predicate: Predicate) { 295 | return Composer.branch(predicate, Composer.passThru(), anoop) 296 | } 297 | 298 | static drop(predicate: Predicate) { 299 | return Composer.branch(predicate, anoop, Composer.passThru()) 300 | } 301 | 302 | static dispatch< 303 | TContext extends Context, 304 | Handlers extends Record> 305 | >(routeFn: (ctx: TContext) => keyof Handlers, handlers: Handlers) { 306 | return typeof routeFn === 'function' 307 | ? Composer.lazy((ctx) => 308 | Promise.resolve(routeFn(ctx)).then((value) => handlers[value]) 309 | ) 310 | : handlers[routeFn] 311 | } 312 | 313 | /** 314 | * Generates middleware for handling provided update types. 315 | */ 316 | static mount( 317 | updateType: MaybeArray, 318 | ...fns: NonemptyReadonlyArray> 319 | ) { 320 | const updateTypes = normalizeTextArguments(updateType) 321 | const predicate = (ctx: TContext) => 322 | updateTypes.includes(ctx.updateType) || 323 | // @ts-expect-error 324 | updateTypes.some((type) => ctx.updateSubTypes.includes(type)) 325 | return Composer.optional(predicate, ...fns) 326 | } 327 | 328 | private static entity( 329 | predicate: (entity: tt.MessageEntity, s: string, ctx: TContext) => boolean, 330 | ...fns: NonemptyReadonlyArray> 331 | ): Middleware { 332 | if (typeof predicate !== 'function') { 333 | const entityTypes = normalizeTextArguments(predicate) 334 | return Composer.entity(({ type }) => entityTypes.includes(type), ...fns) 335 | } 336 | return Composer.optional((ctx) => { 337 | const message = ctx.message ?? ctx.channelPost 338 | const entities = getEntities(message) 339 | const text = getText(message) 340 | if (text === undefined) return false 341 | return entities.some((entity) => 342 | predicate( 343 | entity, 344 | text.substring(entity.offset, entity.offset + entity.length), 345 | ctx 346 | ) 347 | ) 348 | }, ...fns) 349 | } 350 | 351 | static entityText( 352 | entityType: string, 353 | predicate: Triggers, 354 | ...fns: NonemptyReadonlyArray< 355 | Middleware 356 | > 357 | ): Middleware { 358 | if (fns.length === 0) { 359 | // prettier-ignore 360 | return Array.isArray(predicate) 361 | // @ts-expect-error 362 | ? Composer.entity(entityType, ...predicate) 363 | // @ts-expect-error 364 | : Composer.entity(entityType, predicate) 365 | } 366 | const triggers = normalizeTriggers(predicate) 367 | // @ts-expect-error 368 | return Composer.entity(({ type }, value, ctx) => { 369 | if (type !== entityType) { 370 | return false 371 | } 372 | for (const trigger of triggers) { 373 | // @ts-expect-error 374 | ctx.match = trigger(value, ctx) 375 | if (ctx.match) { 376 | return true 377 | } 378 | } 379 | return false 380 | }, ...fns) 381 | } 382 | 383 | static email( 384 | email: string, 385 | ...fns: NonemptyReadonlyArray> 386 | ) { 387 | return Composer.entityText('email', email, ...fns) 388 | } 389 | 390 | static phone( 391 | number: string, 392 | ...fns: NonemptyReadonlyArray> 393 | ) { 394 | return Composer.entityText('phone_number', number, ...fns) 395 | } 396 | 397 | static url( 398 | url: string, 399 | ...fns: NonemptyReadonlyArray> 400 | ) { 401 | return Composer.entityText('url', url, ...fns) 402 | } 403 | 404 | static textLink( 405 | link: string, 406 | ...fns: NonemptyReadonlyArray> 407 | ) { 408 | return Composer.entityText('text_link', link, ...fns) 409 | } 410 | 411 | static textMention( 412 | mention: string, 413 | ...fns: NonemptyReadonlyArray> 414 | ) { 415 | return Composer.entityText('text_mention', mention, ...fns) 416 | } 417 | 418 | static mention( 419 | mention: string, 420 | ...fns: NonemptyReadonlyArray> 421 | ) { 422 | return Composer.entityText( 423 | 'mention', 424 | normalizeTextArguments(mention, '@'), 425 | ...fns 426 | ) 427 | } 428 | 429 | static hashtag( 430 | hashtag: string, 431 | ...fns: NonemptyReadonlyArray> 432 | ) { 433 | return Composer.entityText( 434 | 'hashtag', 435 | normalizeTextArguments(hashtag, '#'), 436 | ...fns 437 | ) 438 | } 439 | 440 | static cashtag( 441 | cashtag: string, 442 | ...fns: NonemptyReadonlyArray> 443 | ) { 444 | return Composer.entityText( 445 | 'cashtag', 446 | normalizeTextArguments(cashtag, '$'), 447 | ...fns 448 | ) 449 | } 450 | 451 | private static match( 452 | triggers: ReadonlyArray< 453 | (text: string, ctx: TContext) => RegExpExecArray | null 454 | >, 455 | ...fns: ReadonlyArray> 456 | ): Middleware.Fn { 457 | const handler = Composer.compose(fns) 458 | return (ctx, next) => { 459 | const text = 460 | getText(ctx.message) ?? 461 | getText(ctx.channelPost) ?? 462 | getText(ctx.callbackQuery) ?? 463 | ctx.inlineQuery?.query 464 | if (text === undefined) return next() 465 | for (const trigger of triggers) { 466 | const match = trigger(text, ctx) 467 | if (match) { 468 | return handler(Object.assign(ctx, { match }), next) 469 | } 470 | } 471 | return next() 472 | } 473 | } 474 | 475 | /** 476 | * Generates middleware for handling matching text messages. 477 | */ 478 | static hears( 479 | triggers: Triggers, 480 | ...fns: ReadonlyArray> 481 | ) { 482 | return Composer.mount( 483 | 'text', 484 | Composer.match(normalizeTriggers(triggers), ...fns) 485 | ) 486 | } 487 | 488 | /** 489 | * Generates middleware for handling specified commands. 490 | */ 491 | static command( 492 | command: MaybeArray, 493 | ...fns: NonemptyReadonlyArray> 494 | ) { 495 | if (fns.length === 0) { 496 | // @ts-expect-error 497 | return Composer.entity(['bot_command'], command) 498 | } 499 | const commands = normalizeTextArguments(command, '/') 500 | return Composer.mount( 501 | 'text', 502 | Composer.lazy((ctx) => { 503 | const groupCommands = 504 | ctx.me && ctx.chat?.type.endsWith('group') 505 | ? // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 506 | commands.map((command) => `${command}@${ctx.me}`) 507 | : [] 508 | return Composer.entity( 509 | ({ offset, type }, value) => 510 | offset === 0 && 511 | type === 'bot_command' && 512 | (commands.includes(value) || groupCommands.includes(value)), 513 | ...fns 514 | ) 515 | }) 516 | ) 517 | } 518 | 519 | /** 520 | * Generates middleware for handling matching callback queries. 521 | */ 522 | static action( 523 | triggers: Triggers, 524 | ...fns: ReadonlyArray> 525 | ) { 526 | return Composer.mount( 527 | 'callback_query', 528 | Composer.match(normalizeTriggers(triggers), ...fns) 529 | ) 530 | } 531 | 532 | /** 533 | * Generates middleware for handling matching inline queries. 534 | */ 535 | static inlineQuery( 536 | triggers: Triggers, 537 | ...fns: ReadonlyArray> 538 | ) { 539 | return Composer.mount( 540 | 'inline_query', 541 | Composer.match(normalizeTriggers(triggers), ...fns) 542 | ) 543 | } 544 | 545 | static acl( 546 | userId: MaybeArray, 547 | ...fns: NonemptyReadonlyArray> 548 | ) { 549 | if (typeof userId === 'function') { 550 | return Composer.optional(userId, ...fns) 551 | } 552 | const allowed = Array.isArray(userId) ? userId : [userId] 553 | // prettier-ignore 554 | return Composer.optional((ctx) => !ctx.from || allowed.includes(ctx.from.id), ...fns) 555 | } 556 | 557 | private static memberStatus( 558 | status: MaybeArray, 559 | ...fns: NonemptyReadonlyArray> 560 | ) { 561 | const statuses = Array.isArray(status) ? status : [status] 562 | return Composer.optional(async (ctx) => { 563 | if (ctx.message === undefined) return false 564 | const member = await ctx.getChatMember(ctx.message.from.id) 565 | return statuses.includes(member.status) 566 | }, ...fns) 567 | } 568 | 569 | static admin( 570 | ...fns: NonemptyReadonlyArray> 571 | ) { 572 | return Composer.memberStatus(['administrator', 'creator'], ...fns) 573 | } 574 | 575 | static creator( 576 | ...fns: NonemptyReadonlyArray> 577 | ) { 578 | return Composer.memberStatus('creator', ...fns) 579 | } 580 | 581 | static chatType( 582 | type: MaybeArray, 583 | ...fns: NonemptyReadonlyArray> 584 | ) { 585 | const types = Array.isArray(type) ? type : [type] 586 | // @ts-expect-error 587 | // prettier-ignore 588 | return Composer.optional((ctx) => ctx.chat && types.includes(ctx.chat.type), ...fns) 589 | } 590 | 591 | static privateChat( 592 | ...fns: NonemptyReadonlyArray> 593 | ) { 594 | return Composer.chatType('private', ...fns) 595 | } 596 | 597 | static groupChat( 598 | ...fns: NonemptyReadonlyArray> 599 | ) { 600 | return Composer.chatType(['group', 'supergroup'], ...fns) 601 | } 602 | 603 | static gameQuery( 604 | ...fns: NonemptyReadonlyArray> 605 | ) { 606 | return Composer.optional( 607 | (ctx) => 608 | ctx.callbackQuery != null && 'game_short_name' in ctx.callbackQuery, 609 | ...fns 610 | ) 611 | } 612 | 613 | static unwrap(handler: Middleware) { 614 | if (!handler) { 615 | throw new Error('Handler is undefined') 616 | } 617 | return 'middleware' in handler ? handler.middleware() : handler 618 | } 619 | 620 | static compose( 621 | middlewares: readonly [ 622 | Middleware.Ext, 623 | ...ReadonlyArray> 624 | ] 625 | ): Middleware.Fn 626 | static compose( 627 | middlewares: ReadonlyArray> 628 | ): Middleware.Fn 629 | static compose( 630 | middlewares: ReadonlyArray> 631 | ): Middleware.Fn { 632 | if (!Array.isArray(middlewares)) { 633 | throw new Error('Middlewares must be an array') 634 | } 635 | if (middlewares.length === 0) { 636 | return Composer.passThru() 637 | } 638 | if (middlewares.length === 1) { 639 | return Composer.unwrap(middlewares[0]) 640 | } 641 | return (ctx, next) => { 642 | let index = -1 643 | return execute(0, ctx) 644 | function execute(i: number, context: TContext): Promise { 645 | if (!(context instanceof Context)) { 646 | // prettier-ignore 647 | return Promise.reject(new Error('next(ctx) called with invalid context')) 648 | } 649 | if (i <= index) { 650 | return Promise.reject(new Error('next() called multiple times')) 651 | } 652 | index = i 653 | const handler = middlewares[i] ? Composer.unwrap(middlewares[i]) : next 654 | if (!handler) { 655 | return Promise.resolve() 656 | } 657 | try { 658 | return Promise.resolve( 659 | handler(context, (ctx = context) => execute(i + 1, ctx)) 660 | ) 661 | } catch (err) { 662 | return Promise.reject(err) 663 | } 664 | } 665 | } 666 | } 667 | } 668 | 669 | function escapeRegExp(s: string) { 670 | // $& means the whole matched string 671 | return s.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&') 672 | } 673 | 674 | function normalizeTriggers( 675 | triggers: Triggers 676 | ) { 677 | if (!Array.isArray(triggers)) { 678 | triggers = [triggers] 679 | } 680 | return triggers.map((trigger) => { 681 | if (!trigger) { 682 | throw new Error('Invalid trigger') 683 | } 684 | if (typeof trigger === 'function') { 685 | return trigger 686 | } 687 | if (trigger instanceof RegExp) { 688 | return (value = '') => { 689 | trigger.lastIndex = 0 690 | return trigger.exec(value) 691 | } 692 | } 693 | const regex = new RegExp(`^${escapeRegExp(trigger)}$`) 694 | return (value: string) => regex.exec(value) 695 | }) 696 | } 697 | 698 | function normalizeTextArguments(argument: MaybeArray, prefix = '') { 699 | const args = Array.isArray(argument) ? argument : [argument] 700 | // prettier-ignore 701 | return args 702 | .filter(Boolean) 703 | .map((arg) => prefix && typeof arg === 'string' && !arg.startsWith(prefix) ? `${prefix}${arg}` : arg) 704 | } 705 | 706 | export default Composer 707 | --------------------------------------------------------------------------------