├── .mailmap ├── src ├── scenes.ts ├── utils.ts ├── types.ts ├── core │ ├── helpers │ │ ├── compact.ts │ │ ├── deunionize.ts │ │ ├── check.ts │ │ ├── args.ts │ │ ├── util.ts │ │ └── formatting.ts │ ├── network │ │ ├── error.ts │ │ ├── multipart-stream.ts │ │ ├── webhook.ts │ │ ├── polling.ts │ │ └── client.ts │ └── types │ │ └── typegram.ts ├── scenes │ ├── index.ts │ ├── base.ts │ ├── wizard │ │ ├── context.ts │ │ └── index.ts │ ├── stage.ts │ └── context.ts ├── index.ts ├── middleware.ts ├── router.ts ├── input.ts ├── format.ts ├── filters.ts ├── reactions.ts ├── markup.ts ├── cli.mts ├── button.ts ├── telegram-types.ts ├── future.ts ├── session.ts └── telegraf.ts ├── .prettierrc.json ├── .eslintignore ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── Feature_request.md │ └── Bug_report.md └── workflows │ ├── lock.yml │ ├── release.yml │ └── node.js.yml ├── release-notes ├── 4.9.1.md ├── 4.9.2.md ├── 4.10.1.md ├── 4.15.0.md ├── v5.0.0-alpha.1.md ├── 4.9.0.md ├── 4.10.0.md ├── 4.13.0.md ├── 4.12.0.md ├── 4.11.0.md └── 4.16.0.md ├── docs ├── examples │ └── README.md ├── assets │ └── logo.svg └── theme │ └── assets │ └── css │ └── custom.css ├── .editorconfig ├── test ├── compactOptions.js ├── format.js ├── scenes.js ├── _helpers.js ├── args.js ├── markup.js ├── session.js └── telegraf.js ├── tsconfig.json ├── .gitignore ├── LICENSE ├── .eslintrc ├── code_of_conduct.md ├── package.json └── README.md /.mailmap: -------------------------------------------------------------------------------- 1 | Wojciech Pawlik 2 | -------------------------------------------------------------------------------- /src/scenes.ts: -------------------------------------------------------------------------------- 1 | export * from './scenes/index.js' 2 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export { argsParser } from './core/helpers/args' 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type * from './core/types/typegram' 2 | export type * as Convenience from './telegram-types' 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /types.* 2 | /scenes.* 3 | /filters.* 4 | /format.* 5 | /future.* 6 | /utils.* 7 | /markup.* 8 | /session.* -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [dotcypress] 4 | open_collective: telegraf 5 | -------------------------------------------------------------------------------- /release-notes/4.9.1.md: -------------------------------------------------------------------------------- 1 | # v4.9.1 2 | 3 | * Updated typegram to v3.11.0. 4 | * (internal) Simplified cli, dropped two dependencies. 5 | -------------------------------------------------------------------------------- /release-notes/4.9.2.md: -------------------------------------------------------------------------------- 1 | # v4.9.2 2 | 3 | * Fixed bad shorthand for `ctx.replyWithVideo` [#1687](https://github.com/telegraf/telegraf/issues/1687) 4 | -------------------------------------------------------------------------------- /docs/examples/README.md: -------------------------------------------------------------------------------- 1 | # 301 Moved 2 | 3 | All examples have been modernised, rewritten in TS & ESM, and are now found in the [docs repo](https://github.com/feathers-studio/telegraf-docs). 4 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Question (via Telegram groups) 4 | url: https://github.com/telegraf/telegraf#resources 5 | about: Ask and answer questions about Telegraf 6 | - name: Question (via GitHub Discussions) 7 | url: https://github.com/telegraf/telegraf/discussions/categories/q-a 8 | about: Ask and answer questions about Telegraf 9 | -------------------------------------------------------------------------------- /src/core/helpers/compact.ts: -------------------------------------------------------------------------------- 1 | export function compactOptions( 2 | options?: T 3 | ): T | undefined { 4 | if (!options) { 5 | return options 6 | } 7 | 8 | const compacted: Partial = {} 9 | for (const key in options) 10 | if ( 11 | // todo(mkr): replace with Object.hasOwn in v5 (Node 16+) 12 | Object.prototype.hasOwnProperty.call(options, key) && 13 | options[key] !== undefined 14 | ) 15 | compacted[key] = options[key] 16 | 17 | return compacted as T | undefined 18 | } 19 | -------------------------------------------------------------------------------- /release-notes/4.10.1.md: -------------------------------------------------------------------------------- 1 | # 4.10.1 2 | 3 | * Fixed: `fmt` now allows any type instead of only `string | FmtString`. 4 | In the [last release](./4.10.0.md), we added `telegraf/fmt` - but it didn't work if you used numbers or other objects in your template literal. Since you'd usually expect this to "just work", we've made that possible. 5 | 6 | This now works as expected when `total` and `codesCount` are numbers (or other objects). 7 | 8 | ```TS 9 | fmt`${bold`Analytics`} 10 | Current pro users: ${total} 11 | Available redeem codes: ${codesCount}` 12 | ``` 13 | -------------------------------------------------------------------------------- /.github/workflows/lock.yml: -------------------------------------------------------------------------------- 1 | name: 'Lock old issues' 2 | 3 | on: 4 | schedule: 5 | - cron: '0 2 * * *' 6 | workflow_dispatch: 7 | 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | 12 | concurrency: 13 | group: lock 14 | 15 | jobs: 16 | action: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: dessant/lock-threads@v4 20 | with: 21 | github-token: ${{ github.token }} 22 | issue-inactive-days: '180' 23 | issue-lock-reason: 'resolved' 24 | process-only: 'issues' 25 | log-output: false 26 | -------------------------------------------------------------------------------- /src/scenes/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://github.com/telegraf/telegraf/issues/705#issuecomment-549056045 3 | * @see https://www.npmjs.com/package/telegraf-stateless-question 4 | * @packageDocumentation 5 | */ 6 | 7 | export { Stage } from './stage' 8 | export { 9 | SceneContext, 10 | SceneSession, 11 | default as SceneContextScene, 12 | SceneSessionData, 13 | } from './context' 14 | export { BaseScene } from './base' 15 | export { WizardScene } from './wizard' 16 | export { 17 | WizardContext, 18 | WizardSession, 19 | default as WizardContextWizard, 20 | WizardSessionData, 21 | } from './wizard/context' 22 | -------------------------------------------------------------------------------- /test/compactOptions.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const { compactOptions } = require('../lib/core/helpers/compact') 3 | 4 | test('compactOptions should remove undefined values from an object', (t) => { 5 | const input = { 6 | foo: 'bar', 7 | baz: undefined, 8 | qux: null, 9 | } 10 | 11 | const expected = { 12 | foo: 'bar', 13 | qux: null, 14 | } 15 | 16 | const result = compactOptions(input) 17 | 18 | t.deepEqual(result, expected) 19 | }) 20 | 21 | test('compactOptions should return undefined if input is undefined', (t) => { 22 | const input = undefined 23 | 24 | const result = compactOptions(input) 25 | 26 | t.is(result, undefined) 27 | }) 28 | -------------------------------------------------------------------------------- /src/core/network/error.ts: -------------------------------------------------------------------------------- 1 | import { ResponseParameters } from '../types/typegram' 2 | 3 | interface ErrorPayload { 4 | error_code: number 5 | description: string 6 | parameters?: ResponseParameters 7 | } 8 | export class TelegramError extends Error { 9 | constructor( 10 | readonly response: ErrorPayload, 11 | readonly on = {} 12 | ) { 13 | super(`${response.error_code}: ${response.description}`) 14 | } 15 | 16 | get code() { 17 | return this.response.error_code 18 | } 19 | 20 | get description() { 21 | return this.response.description 22 | } 23 | 24 | get parameters() { 25 | return this.response.parameters 26 | } 27 | } 28 | 29 | export default TelegramError 30 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Telegraf } from './telegraf' 2 | export { Context, NarrowedContext } from './context' 3 | export { Composer } from './composer' 4 | export { Middleware, MiddlewareFn, MiddlewareObj } from './middleware' 5 | export { Router } from './router' 6 | export { TelegramError } from './core/network/error' 7 | export { Telegram } from './telegram' 8 | 9 | export * as Types from './telegram-types' 10 | export * as Markup from './markup' 11 | export * as Input from './input' 12 | export * as Format from './format' 13 | 14 | export { deunionize } from './core/helpers/deunionize' 15 | export { session, MemorySessionStore, SessionStore } from './session' 16 | 17 | export * as Scenes from './scenes' 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 | 11 | 12 | **Describe the solution you'd like** 13 | 16 | 17 | **Describe alternatives you've considered** 18 | 21 | 22 | **Additional context** 23 | 26 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { Context } from './context' 2 | import { Update } from './core/types/typegram' 3 | 4 | /* 5 | next's parameter is in a contravariant position, and thus, trying to type it 6 | prevents assigning `MiddlewareFn` 7 | to `MiddlewareFn`. 8 | Middleware passing the parameter should be a separate type instead. 9 | */ 10 | export type MiddlewareFn, U extends Update = Update> = ( 11 | ctx: C, 12 | next: () => Promise 13 | ) => Promise | void 14 | 15 | export interface MiddlewareObj< 16 | C extends Context, 17 | U extends Update = Update, 18 | > { 19 | middleware: () => MiddlewareFn 20 | } 21 | 22 | export type Middleware, U extends Update = Update> = 23 | | MiddlewareFn 24 | | MiddlewareObj 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "Node16", 4 | "declaration": true, 5 | "declarationMap": true, 6 | "declarationDir": "typings/", 7 | "esModuleInterop": true, 8 | "outDir": "lib/", 9 | "rootDir": "src/", 10 | "target": "ES2019", 11 | "strict": true, 12 | "skipLibCheck": true, 13 | "noUncheckedIndexedAccess": true, 14 | "incremental": true, 15 | "tsBuildInfoFile": "typings/tsconfig.tsbuildinfo", 16 | "noErrorTruncation": true, 17 | "stripInternal": true, 18 | "forceConsistentCasingInFileNames": true 19 | }, 20 | "typedocOptions": { 21 | "customCss": "docs/theme/assets/css/custom.css", 22 | "excludeExternals": true, 23 | "includeVersion": true, 24 | "media": "docs/assets/", 25 | "name": "telegraf.js", 26 | "out": "docs/build/", 27 | "readme": "README.md" 28 | }, 29 | "include": ["src/"] 30 | } 31 | -------------------------------------------------------------------------------- /.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 | pnpm-lock.yaml 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 | /lib 41 | /docs/build 42 | /telegraf-docs/ 43 | /typings/ 44 | tsconfig.tsbuildinfo 45 | /bin/telegraf.mjs 46 | 47 | # Linting cache 48 | .eslintcache 49 | 50 | /*.js 51 | /*.d.ts 52 | -------------------------------------------------------------------------------- /src/core/helpers/deunionize.ts: -------------------------------------------------------------------------------- 1 | export type PropOr< 2 | T extends object | undefined, 3 | P extends string | symbol | number, 4 | D = undefined, 5 | > = T extends Partial> ? T[P] : D 6 | 7 | export type UnionKeys = T extends unknown ? keyof T : never 8 | 9 | type AddOptionalKeys = { readonly [P in K]?: never } 10 | 11 | /** 12 | * @see https://millsp.github.io/ts-toolbelt/modules/union_strict.html 13 | */ 14 | export type Deunionize = T extends object 15 | ? T & AddOptionalKeys, keyof T>> 16 | : T 17 | 18 | /** 19 | * Expose properties from all union variants. 20 | * @deprectated 21 | * @see https://github.com/telegraf/telegraf/issues/1388#issuecomment-791573609 22 | * @see https://millsp.github.io/ts-toolbelt/modules/union_strict.html 23 | */ 24 | export function deunionize(t: T) { 25 | return t as Deunionize 26 | } 27 | -------------------------------------------------------------------------------- /release-notes/4.15.0.md: -------------------------------------------------------------------------------- 1 | # 4.14.0 2 | 3 | This is a rather minor release. 4 | 5 | ## `anyOf` and `allOf` filter combinators 6 | 7 | [v4.11.0](https://github.com/telegraf/telegraf/releases/tag/v4.11.0) introduced support for filters in `Composer::on`, which allowed you to filter updates based on their content. 8 | 9 | This release adds two new combinators to the filter API: `anyOf` and `allOf`. This will play very nicely with custom filters. For example: 10 | 11 | ```TS 12 | import { anyOf, allOf } from "telegraf/filters"; 13 | 14 | // must match all filters 15 | bot.on(allOf(message(), isGroup), ctx => { 16 | // ... 17 | }); 18 | ``` 19 | 20 | ## Deprecating `hookPath` 21 | 22 | The confusingly named `hookPath` in `bot.launch` webhook options is now deprecated. It will be removed in the next major release. You can start using `path` instead, today. 23 | 24 | --- 25 | 26 | Meanwhile, we're working on new modules to add to the Telegraf ecosystem. Look forward to them, and join discussions in the official [Telegraf chat](https://t.me/TelegrafJSChat)! 27 | -------------------------------------------------------------------------------- /docs/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v4.* 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - run : npm ci --ignore-scripts 15 | - run : npm run build:docs 16 | - name: Make GitHub links absolute 17 | run : 18 | shopt -s globstar; 19 | sed 20 | --in-place 21 | --expression='s:/develop/docs/:/${{ github.sha }}/docs/:g' 22 | --expression='s: a[href$="telegraf.html"] { 4 | display: none; 5 | } 6 | 7 | a > h2:hover::after, 8 | a > h3:hover::after, 9 | a > h4:hover::after, 10 | a > h5:hover::after { 11 | content: '🔗'; 12 | opacity: 0.2; 13 | } 14 | 15 | a { 16 | color: #e74625; 17 | } 18 | 19 | [data-tsd-kind="Class"] { 20 | color: #0672DE; 21 | } 22 | 23 | [data-tsd-kind="Class"], 24 | nav .tsd-kind-class, 25 | .tsd-legend > .tsd-kind-constructor, 26 | .tsd-legend > .tsd-kind-method, 27 | .tsd-index-list > .tsd-kind-class, 28 | .tsd-index-list > .tsd-kind-constructor, 29 | .tsd-index-list > .tsd-kind-method, 30 | .tsd-kind-constructor > .tsd-signature::before, 31 | .tsd-kind-method > .tsd-signature::before { 32 | filter: hue-rotate(-199.8deg); 33 | } 34 | 35 | nav .tsd-kind-class .tsd-kind-get-signature, 36 | nav .tsd-kind-class .tsd-kind-accessor, 37 | nav .tsd-kind-class .tsd-kind-property { 38 | filter: hue-rotate(199.8deg); 39 | } 40 | 41 | .tsd-index-list .tsd-kind-type-alias > a, 42 | .tsd-index-list .tsd-kind-function > a { 43 | color: #b54dff; 44 | } 45 | -------------------------------------------------------------------------------- /release-notes/v5.0.0-alpha.1.md: -------------------------------------------------------------------------------- 1 | # v5.0.0-alpha.1 2 | 3 | ## Changes: 4 | 5 | * Dropped Node <18. 6 | * Removed `webhookReply`. 7 | * Subtly changed some signatures: 8 | - `Composer::use` now returns `void`. 9 | - `MiddlewareFn` can no longer return `void`. 10 | - `Composer.dispatch` requires `routeFn` to be synchronous. 11 | - `Telegram` constructor accepts a `Client` instead of `token`. 12 | - `Telegraf::handleUpdate` accepts a `Transformer` instead of `http.ServerResponse`. 13 | - `private Telegraf::handleError` changed to `(err: unknown, ctx?: C): Promise`. 14 | * Deprecated `deunionize` function. 15 | * Removed `Router` and most deprecated methods. 16 | * `reply*` methods now set `reply_to_message_id`. 17 | * Rewritten `core/network/client.ts`. As a result: 18 | - Files are uploaded differently than before v4.10. 19 | - Added `Telegram::use` for installing API transformers. 20 | - Replaced `Telegram::getFileLink` with `Telegram::download`. 21 | - Replaced `Telegraf.Options['telegram']` with `Telegraf.Options['client']`. 22 | 23 | ## Planned: 24 | 25 | * `composer.ts`, `scenes/`, `telegraf.ts` rework. 26 | * Fixing issues. 27 | * Deno support. 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2019 Vitaly Domnikov 4 | Copyright (c) 2020-2023 The Telegraf Contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 3 | "parserOptions": { 4 | "project": ["./tsconfig.json"] 5 | }, 6 | "overrides": [ 7 | { 8 | "files": ["*.ts", "*.mts"], 9 | "extends": ["plugin:prettier/recommended"], 10 | "rules": { 11 | "@typescript-eslint/ban-ts-comment": "warn", 12 | "@typescript-eslint/explicit-function-return-type": "off", 13 | "@typescript-eslint/no-explicit-any": "warn", 14 | "@typescript-eslint/no-non-null-assertion": "warn", 15 | "@typescript-eslint/promise-function-async": "off", 16 | "@typescript-eslint/no-namespace": "off", 17 | "no-undef": "off" 18 | } 19 | } 20 | ], 21 | "rules": { 22 | "prefer-promise-reject-errors": "off", 23 | "comma-dangle": "off", 24 | "space-before-function-paren": "off", 25 | "@typescript-eslint/no-empty-interface": "off", 26 | "@typescript-eslint/strict-boolean-expressions": "off" 27 | }, 28 | "ignorePatterns": [ 29 | "/bin/", 30 | "/lib/", 31 | "/typings/", 32 | "/docs/", 33 | "/test/", 34 | "/build/" 35 | ], 36 | "reportUnusedDisableDirectives": true, 37 | "plugins": ["ava"] 38 | } 39 | -------------------------------------------------------------------------------- /src/core/network/multipart-stream.ts: -------------------------------------------------------------------------------- 1 | import * as stream from 'stream' 2 | import { hasPropType } from '../helpers/check' 3 | import SandwichStream from 'sandwich-stream' 4 | const CRNL = '\r\n' 5 | 6 | interface Part { 7 | headers: { [key: string]: string } 8 | body: NodeJS.ReadStream | NodeJS.ReadableStream | Buffer | 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, header] of Object.entries(part.headers)) { 23 | partStream.write(`${key}:${header}${CRNL}`) 24 | } 25 | partStream.write(CRNL) 26 | if (MultipartStream.isStream(part.body)) { 27 | part.body.pipe(partStream) 28 | } else { 29 | partStream.end(part.body) 30 | } 31 | this.add(partStream) 32 | } 33 | 34 | static isStream( 35 | stream: unknown 36 | ): stream is { pipe: MultipartStream['pipe'] } { 37 | return ( 38 | typeof stream === 'object' && 39 | stream !== null && 40 | hasPropType(stream, 'pipe', 'function') 41 | ) 42 | } 43 | } 44 | 45 | export default MultipartStream 46 | -------------------------------------------------------------------------------- /.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: [ v4 ] 9 | pull_request: 10 | {} 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: 19 | - 16 20 | - 18 21 | - 20 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | persist-credentials: false 27 | - name: Reconfigure git to use HTTP authentication 28 | run: > 29 | git config --global url."https://github.com/".insteadOf ssh://git@github.com/ 30 | - name: Use Node.js ${{ matrix.node-version }} 31 | uses: actions/setup-node@v3 32 | with: 33 | node-version: ${{ matrix.node-version }} 34 | - uses: actions/cache@v3 35 | id: cache 36 | with: 37 | path: node_modules/ 38 | key: ${{ runner.os }}-node${{ matrix.node-version }}-${{ hashFiles('package*.json') }} 39 | - run: npm ci --ignore-scripts 40 | if: steps.cache.outputs.cache-hit != 'true' 41 | - run: npm test 42 | - run: npm run lint 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | 16 | 17 | ## Context 18 | 19 | 22 | 23 | * Telegraf.js Version: 24 | * Node.js Version: 25 | * Operating System: 26 | 27 | ## Minimal Example Code Reproducing the Issue 28 | 29 | ```ts 30 | // your code here. Please omit everything not related to the issue, 31 | // to make it easier to pinpoint the cause. 32 | // Please try running this code to make sure it actually reproduces the issue. 33 | ``` 34 | 35 | 39 | 40 | ## Expected Behavior 41 | 42 | 45 | 46 | ## Current Behavior 47 | 48 | 51 | 52 | ## Error Message and Logs (`export DEBUG='telegraf:*'`) 53 | 54 | ``` 55 | Please include any relevant log snippets or files here. 56 | ``` 57 | -------------------------------------------------------------------------------- /src/scenes/base.ts: -------------------------------------------------------------------------------- 1 | import { Middleware, MiddlewareFn } from '../middleware' 2 | import Composer from '../composer' 3 | import Context from '../context' 4 | 5 | const { compose } = Composer 6 | 7 | export interface SceneOptions { 8 | ttl?: number 9 | handlers: ReadonlyArray> 10 | enterHandlers: ReadonlyArray> 11 | leaveHandlers: ReadonlyArray> 12 | } 13 | 14 | export class BaseScene extends Composer { 15 | id: string 16 | ttl?: number 17 | enterHandler: MiddlewareFn 18 | leaveHandler: MiddlewareFn 19 | constructor(id: string, options?: SceneOptions) { 20 | const opts: SceneOptions = { 21 | handlers: [], 22 | enterHandlers: [], 23 | leaveHandlers: [], 24 | ...options, 25 | } 26 | super(...opts.handlers) 27 | this.id = id 28 | this.ttl = opts.ttl 29 | this.enterHandler = compose(opts.enterHandlers) 30 | this.leaveHandler = compose(opts.leaveHandlers) 31 | } 32 | 33 | enter(...fns: Array>) { 34 | this.enterHandler = compose([this.enterHandler, ...fns]) 35 | return this 36 | } 37 | 38 | leave(...fns: Array>) { 39 | this.leaveHandler = compose([this.leaveHandler, ...fns]) 40 | return this 41 | } 42 | 43 | enterMiddleware() { 44 | return this.enterHandler 45 | } 46 | 47 | leaveMiddleware() { 48 | return this.leaveHandler 49 | } 50 | } 51 | 52 | export default BaseScene 53 | -------------------------------------------------------------------------------- /release-notes/4.9.0.md: -------------------------------------------------------------------------------- 1 | # v4.9.0 2 | 3 | * Added support for Bot API 6.1. 4 | * Added support for Bot API 6.2. 5 | * Updated typegram. Uses a [fork](https://github.com/MKRhere/typegram) until upstream is updated. 6 | * Added method `Telegraf::createWebhook` which calls `setWebhook` first, and returns Promise of Express-style middleware. [[Example]](https://github.com/feathers-studio/telegraf-docs/blob/master/examples/webhook/express.ts) 7 | * Updated Telegraf binary; it is now written in TS, and supports ESM modules and new command-line options `--method` and `--data` to call API methods from the command-line. 8 | * Added experimental export of Telegraf's convenience types, such as the `Extra*` parameter types, now found as: `import type { Convenience } from "telegraf/types"` 9 | * Added `Context::sendMessage` and `Context:sendWith*` methods mirroring `Context::reply` and `Context::replyWith*` methods respectively. 10 | * Added new middleware: `import { useNewReplies } from telegraf/future` that changes the behaviour of `Context::reply*` methods to actually reply to the context message. 11 | * (docs) New documentation effort started at [feathers-studio/telegraf-docs](https://github.com/feathers-studio/telegraf-docs). 12 | * (docs) All doc examples were moved to new repo and updated to full TS and ESM. 13 | * (internal) Removed `Telegraf::handleUpdates`. 14 | * (internal) Accept `req.body` as Buffer or string instead of parsed object for compatibility with [serverless-http](https://github.com/dougmoscrop/serverless-http). 15 | -------------------------------------------------------------------------------- /src/scenes/wizard/context.ts: -------------------------------------------------------------------------------- 1 | import SceneContextScene, { SceneSession, SceneSessionData } from '../context' 2 | import Context from '../../context' 3 | import { Middleware } from '../../middleware' 4 | import { SessionContext } from '../../session' 5 | 6 | export interface WizardContext 7 | extends Context { 8 | session: WizardSession 9 | scene: SceneContextScene, D> 10 | wizard: WizardContextWizard> 11 | } 12 | 13 | export interface WizardSessionData extends SceneSessionData { 14 | cursor: number 15 | } 16 | 17 | export interface WizardSession 18 | extends SceneSession {} 19 | 20 | export default class WizardContextWizard< 21 | C extends SessionContext & { 22 | scene: SceneContextScene 23 | }, 24 | > { 25 | readonly state: object 26 | constructor( 27 | private readonly ctx: C, 28 | private readonly steps: ReadonlyArray> 29 | ) { 30 | this.state = ctx.scene.state 31 | this.cursor = ctx.scene.session.cursor ?? 0 32 | } 33 | 34 | get step() { 35 | return this.steps[this.cursor] 36 | } 37 | 38 | get cursor() { 39 | return this.ctx.scene.session.cursor 40 | } 41 | 42 | set cursor(cursor: number) { 43 | this.ctx.scene.session.cursor = cursor 44 | } 45 | 46 | selectStep(index: number) { 47 | this.cursor = index 48 | return this 49 | } 50 | 51 | next() { 52 | return this.selectStep(this.cursor + 1) 53 | } 54 | 55 | back() { 56 | return this.selectStep(this.cursor - 1) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /release-notes/4.10.0.md: -------------------------------------------------------------------------------- 1 | # v4.10.0 2 | 3 | * Deprecated `ctx.replyWithMarkdown`; prefer MarkdownV2 as Telegram recommends. 4 | * Deprecated `ctx.replyWithChatAction`; use identical method `ctx.sendChatAction` instead. 5 | * `bot.launch()` webhook options now accepts `certificate` for self-signed certs. 6 | * Added Input helpers to create the InputFile object. 7 | 8 | ```TS 9 | import { Telegraf, Input } from "telegraf"; 10 | const bot = new Telegraf(token); 11 | 12 | bot.telegram.sendVideo(chatId, Input.fromLocalFile("../assets/cats.mp4")); 13 | 14 | bot.telegram.sendDocument(chatId, Input.fromBuffer(buf)); 15 | 16 | bot.command("cat", ctx => { 17 | ctx.sendPhoto(Input.fromURL("https://funny-cats.example/cats.jpg")) 18 | }); 19 | ``` 20 | 21 | This helps clear the confusion many users have about InputFile, and provides a layer of abstraction that can be used in the future to declutter telegraf/client from having to handle all combinations of InputFile. 22 | * Brand new formatting helpers! No more awkward escaping. 23 | 24 | ```TS 25 | import { fmt, bold, italics, mention } from "telegraf/format"; 26 | 27 | ctx.reply(fmt` 28 | Ground control to ${mention("Major Tom", 10000000)} 29 | ${bold`Lock your Soyuz hatch`} and ${italic`put your helmet on`} 30 | — ${link("David Bowie", "https://en.wikipedia.org/wiki/David_Bowie")} 31 | `); 32 | ``` 33 | 34 | This also just works with captions! 35 | 36 | ```TS 37 | ctx.replyWithPhoto( 38 | file.id, 39 | { caption: fmt`${bold`File name:`} ${file.name}` }, 40 | ); 41 | ``` 42 | * Fix bot crashes if `updateHandler` throws ([#1709](https://github.com/telegraf/telegraf/issues/1709)) 43 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import { Middleware, MiddlewareObj } from './middleware' 4 | import Composer from './composer' 5 | import Context from './context' 6 | 7 | type NonemptyReadonlyArray = readonly [T, ...T[]] 8 | 9 | type RouteFn = (ctx: TContext) => { 10 | route: string 11 | context?: Partial 12 | state?: Partial 13 | } | null 14 | 15 | /** @deprecated in favor of {@link Composer.dispatch} */ 16 | export class Router implements MiddlewareObj { 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 | const result = this.routeFn(ctx) 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 | -------------------------------------------------------------------------------- /src/core/types/typegram.ts: -------------------------------------------------------------------------------- 1 | import * as Typegram from '@telegraf/types' 2 | 3 | // internal type provisions 4 | export * from '@telegraf/types/api' 5 | export * from '@telegraf/types/inline' 6 | export * from '@telegraf/types/manage' 7 | export * from '@telegraf/types/markup' 8 | export * from '@telegraf/types/message' 9 | export * from '@telegraf/types/methods' 10 | export * from '@telegraf/types/passport' 11 | export * from '@telegraf/types/payment' 12 | export * from '@telegraf/types/settings' 13 | export * from '@telegraf/types/update' 14 | 15 | // telegraf input file definition 16 | interface InputFileByPath { 17 | source: string 18 | filename?: string 19 | } 20 | interface InputFileByReadableStream { 21 | source: NodeJS.ReadableStream 22 | filename?: string 23 | } 24 | interface InputFileByBuffer { 25 | source: Buffer 26 | filename?: string 27 | } 28 | interface InputFileByURL { 29 | url: string 30 | filename?: string 31 | } 32 | export type InputFile = 33 | | InputFileByPath 34 | | InputFileByReadableStream 35 | | InputFileByBuffer 36 | | InputFileByURL 37 | 38 | export type Telegram = Typegram.ApiMethods 39 | 40 | export type Opts = Typegram.Opts[M] 41 | export type InputMedia = Typegram.InputMedia 42 | export type InputMediaPhoto = Typegram.InputMediaPhoto 43 | export type InputMediaVideo = Typegram.InputMediaVideo 44 | export type InputMediaAnimation = Typegram.InputMediaAnimation 45 | export type InputMediaAudio = Typegram.InputMediaAudio 46 | export type InputMediaDocument = Typegram.InputMediaDocument 47 | 48 | // tiny helper types 49 | export type ChatAction = Opts<'sendChatAction'>['action'] 50 | 51 | /** 52 | * Sending video notes by a URL is currently unsupported 53 | */ 54 | export type InputFileVideoNote = Exclude 55 | -------------------------------------------------------------------------------- /src/scenes/wizard/index.ts: -------------------------------------------------------------------------------- 1 | import BaseScene, { SceneOptions } from '../base' 2 | import { Middleware, MiddlewareObj } from '../../middleware' 3 | import WizardContextWizard, { WizardSessionData } from './context' 4 | import Composer from '../../composer' 5 | import Context from '../../context' 6 | import SceneContextScene from '../context' 7 | 8 | export class WizardScene< 9 | C extends Context & { 10 | scene: SceneContextScene 11 | wizard: WizardContextWizard 12 | }, 13 | > 14 | extends BaseScene 15 | implements MiddlewareObj 16 | { 17 | steps: Array> 18 | 19 | constructor(id: string, ...steps: Array>) 20 | constructor( 21 | id: string, 22 | options: SceneOptions, 23 | ...steps: Array> 24 | ) 25 | constructor( 26 | id: string, 27 | options: SceneOptions | Middleware, 28 | ...steps: Array> 29 | ) { 30 | let opts: SceneOptions | undefined 31 | let s: Array> 32 | if (typeof options === 'function' || 'middleware' in options) { 33 | opts = undefined 34 | s = [options, ...steps] 35 | } else { 36 | opts = options 37 | s = steps 38 | } 39 | super(id, opts) 40 | this.steps = s 41 | } 42 | 43 | middleware() { 44 | return Composer.compose([ 45 | (ctx, next) => { 46 | ctx.wizard = new WizardContextWizard(ctx, this.steps) 47 | return next() 48 | }, 49 | super.middleware(), 50 | (ctx, next) => { 51 | if (ctx.wizard.step === undefined) { 52 | ctx.wizard.selectStep(0) 53 | return ctx.scene.leave() 54 | } 55 | return Composer.unwrap(ctx.wizard.step)(ctx, next) 56 | }, 57 | ]) 58 | } 59 | 60 | enterMiddleware() { 61 | return Composer.compose([this.enterHandler, this.middleware()]) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /test/format.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const { FmtString, fmt, bold, italic, join } = require('../format') 3 | 4 | test('Idiomatic FmtString usage', (t) => 5 | t.notThrows(() => { 6 | const item = fmt`Hello, ${bold`World`}` 7 | 8 | t.deepEqual( 9 | item, 10 | Object.assign(new FmtString(), { 11 | text: 'Hello, World', 12 | parse_mode: undefined, 13 | entities: [{ type: 'bold', offset: 7, length: 5 }], 14 | }) 15 | ) 16 | })) 17 | 18 | test('FmtString from function', (t) => 19 | t.notThrows(() => { 20 | const item = bold('Hello') 21 | 22 | t.deepEqual( 23 | item, 24 | Object.assign(new FmtString(), { 25 | text: 'Hello', 26 | parse_mode: undefined, 27 | entities: [{ type: 'bold', offset: 0, length: 5 }], 28 | }) 29 | ) 30 | })) 31 | 32 | test('Nested FmtString', (t) => 33 | t.notThrows(() => { 34 | const item = bold(italic('Hello')) 35 | 36 | t.deepEqual( 37 | item, 38 | Object.assign(new FmtString(), { 39 | text: 'Hello', 40 | parse_mode: undefined, 41 | entities: [ 42 | { type: 'bold', offset: 0, length: 5 }, 43 | { type: 'italic', offset: 0, length: 5 }, 44 | ], 45 | }) 46 | ) 47 | })) 48 | 49 | test('Should join FmtStrings', (t) => 50 | t.notThrows(() => { 51 | const a = bold(italic('Hello')) 52 | const b = italic('World') 53 | 54 | const joined = join([a, b], ', ') 55 | 56 | t.deepEqual(joined, fmt`${a}, ${b}`) 57 | 58 | t.deepEqual( 59 | joined, 60 | Object.assign(new FmtString(), { 61 | text: 'Hello, World', 62 | parse_mode: undefined, 63 | entities: [ 64 | { type: 'bold', offset: 0, length: 5 }, 65 | { type: 'italic', offset: 0, length: 5 }, 66 | { type: 'italic', offset: 7, length: 5 }, 67 | ], 68 | }) 69 | ) 70 | })) 71 | -------------------------------------------------------------------------------- /src/core/network/webhook.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'http' 2 | import d from 'debug' 3 | import { type Update } from '../types/typegram' 4 | const debug = d('telegraf:webhook') 5 | 6 | export default function generateWebhook( 7 | filter: (req: http.IncomingMessage) => boolean, 8 | updateHandler: (update: Update, res: http.ServerResponse) => Promise 9 | ) { 10 | return async ( 11 | req: http.IncomingMessage & { body?: Update }, 12 | res: http.ServerResponse, 13 | next = (): void => { 14 | res.statusCode = 403 15 | debug('Replying with status code', res.statusCode) 16 | res.end() 17 | } 18 | ): Promise => { 19 | debug('Incoming request', req.method, req.url) 20 | 21 | if (!filter(req)) { 22 | debug('Webhook filter failed', req.method, req.url) 23 | return next() 24 | } 25 | 26 | let update: Update 27 | 28 | try { 29 | if (req.body != null) { 30 | /* If req.body is already set, we expect it to be the parsed 31 | request body (update object) received from Telegram 32 | However, some libraries such as `serverless-http` set req.body to the 33 | raw buffer, so we'll handle that additionally */ 34 | 35 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 36 | let body: any = req.body 37 | // if body is Buffer, parse it into string 38 | if (body instanceof Buffer) body = String(req.body) 39 | // if body is string, parse it into object 40 | if (typeof body === 'string') body = JSON.parse(body) 41 | update = body 42 | } else { 43 | let body = '' 44 | // parse each buffer to string and append to body 45 | for await (const chunk of req) body += String(chunk) 46 | // parse body to object 47 | update = JSON.parse(body) 48 | } 49 | } catch (error: unknown) { 50 | // if any of the parsing steps fails, give up and respond with error 51 | res.writeHead(415).end() 52 | debug('Failed to parse request body:', error) 53 | return 54 | } 55 | 56 | return await updateHandler(update, res) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/core/helpers/check.ts: -------------------------------------------------------------------------------- 1 | interface Mapping { 2 | string: string 3 | number: number 4 | bigint: bigint 5 | boolean: boolean 6 | symbol: symbol 7 | undefined: undefined 8 | object: Record 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | function: (...props: any[]) => any 11 | } 12 | 13 | /** 14 | * Checks if a given object has a property with a given name. 15 | * 16 | * Example invocation: 17 | * ```js 18 | * let obj = { 'foo': 'bar', 'baz': () => {} } 19 | * hasProp(obj, 'foo') // true 20 | * hasProp(obj, 'baz') // true 21 | * hasProp(obj, 'abc') // false 22 | * ``` 23 | * 24 | * @param obj An object to test 25 | * @param prop The name of the property 26 | */ 27 | export function hasProp( 28 | obj: O | undefined, 29 | prop: K 30 | ): obj is O & Record { 31 | return obj !== undefined && prop in obj 32 | } 33 | /** 34 | * Checks if a given object has a property with a given name. 35 | * Furthermore performs a `typeof` check on the property if it exists. 36 | * 37 | * Example invocation: 38 | * ```js 39 | * let obj = { 'foo': 'bar', 'baz': () => {} } 40 | * hasPropType(obj, 'foo', 'string') // true 41 | * hasPropType(obj, 'baz', 'function') // true 42 | * hasPropType(obj, 'abc', 'number') // false 43 | * ``` 44 | * 45 | * @param obj An object to test 46 | * @param prop The name of the property 47 | * @param type The type the property is expected to have 48 | */ 49 | export function hasPropType< 50 | O extends object, 51 | K extends PropertyKey, 52 | T extends keyof Mapping, 53 | V extends Mapping[T], 54 | >(obj: O | undefined, prop: K, type: T): obj is O & Record { 55 | return hasProp(obj, prop) && type === typeof obj[prop] 56 | } 57 | 58 | /** 59 | * Checks if the supplied array has two dimensions or not. 60 | * 61 | * Example invocations: 62 | * is2D([]) // false 63 | * is2D([[]]) // true 64 | * is2D([[], []]) // true 65 | * is2D([42]) // false 66 | * 67 | * @param arr an array with one or two dimensions 68 | */ 69 | export function is2D(arr: E[] | E[][]): arr is E[][] { 70 | return Array.isArray(arr[0]) 71 | } 72 | -------------------------------------------------------------------------------- /test/scenes.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('ava') 4 | const { Telegraf, session, Scenes } = require('../') 5 | 6 | function createBot (...args) { 7 | const bot = new Telegraf(...args) 8 | bot.botInfo = { id: 42, is_bot: true, username: 'bot', first_name: 'Bot' } 9 | return bot 10 | } 11 | 12 | const BaseTextMessage = { 13 | chat: { id: 1 }, 14 | from: { id: 1 }, 15 | text: 'foo' 16 | } 17 | 18 | test('should execute enter middleware in scene', (t) => { 19 | const bot = createBot() 20 | const scene = new Scenes.BaseScene('hello') 21 | scene.enter((ctx) => t.pass()) 22 | const stage = new Scenes.Stage([scene]) 23 | stage.use((ctx) => ctx.scene.enter('hello')) 24 | bot.use(session()) 25 | bot.use(stage) 26 | return bot.handleUpdate({ message: BaseTextMessage }) 27 | }) 28 | 29 | test('should execute enter middleware in wizard scene', (t) => { 30 | const bot = createBot() 31 | const scene = new Scenes.WizardScene('hello', []) 32 | scene.enter((ctx) => t.pass()) 33 | const stage = new Scenes.Stage([scene]) 34 | stage.use((ctx) => ctx.scene.enter('hello')) 35 | bot.use(session()) 36 | bot.use(stage) 37 | return bot.handleUpdate({ message: BaseTextMessage }) 38 | }) 39 | 40 | test('should execute first step in wizard scene on enter', (t) => { 41 | const bot = createBot() 42 | const scene = new Scenes.WizardScene( 43 | 'hello', 44 | (ctx) => { 45 | t.pass() 46 | } 47 | ) 48 | const stage = new Scenes.Stage([scene]) 49 | stage.use((ctx) => ctx.scene.enter('hello')) 50 | bot.use(session()) 51 | bot.use(stage) 52 | return bot.handleUpdate({ message: BaseTextMessage }) 53 | }) 54 | 55 | test('should execute both enter middleware and first step in wizard scene on enter', (t) => { 56 | t.plan(2) 57 | const bot = createBot() 58 | const scene = new Scenes.WizardScene( 59 | 'hello', 60 | (ctx) => { 61 | t.pass() 62 | } 63 | ) 64 | scene.enter((ctx, next) => { 65 | t.pass() 66 | return next() 67 | }) 68 | const stage = new Scenes.Stage([scene]) 69 | stage.use((ctx) => ctx.scene.enter('hello')) 70 | bot.use(session()) 71 | bot.use(stage) 72 | return bot.handleUpdate({ message: BaseTextMessage }) 73 | }) 74 | -------------------------------------------------------------------------------- /src/scenes/stage.ts: -------------------------------------------------------------------------------- 1 | import { isSessionContext, SessionContext } from '../session' 2 | import SceneContextScene, { 3 | SceneContextSceneOptions, 4 | SceneSession, 5 | SceneSessionData, 6 | } from './context' 7 | import { BaseScene } from './base' 8 | import { Composer } from '../composer' 9 | import { Context } from '../context' 10 | 11 | export class Stage< 12 | C extends SessionContext> & { 13 | scene: SceneContextScene 14 | }, 15 | D extends SceneSessionData = SceneSessionData, 16 | > extends Composer { 17 | options: Partial> 18 | scenes: Map> 19 | 20 | constructor( 21 | scenes: ReadonlyArray> = [], 22 | options?: Partial> 23 | ) { 24 | super() 25 | this.options = { ...options } 26 | this.scenes = new Map>() 27 | scenes.forEach((scene) => this.register(scene)) 28 | } 29 | 30 | register(...scenes: ReadonlyArray>) { 31 | scenes.forEach((scene) => { 32 | if (scene?.id == null || typeof scene.middleware !== 'function') { 33 | throw new Error('telegraf: Unsupported scene') 34 | } 35 | this.scenes.set(scene.id, scene) 36 | }) 37 | return this 38 | } 39 | 40 | middleware() { 41 | const handler = Composer.compose([ 42 | (ctx, next) => { 43 | const scenes: Map> = this.scenes 44 | const scene = new SceneContextScene(ctx, scenes, this.options) 45 | ctx.scene = scene 46 | return next() 47 | }, 48 | super.middleware(), 49 | Composer.lazy((ctx) => ctx.scene.current ?? Composer.passThru()), 50 | ]) 51 | return Composer.optional(isSessionContext, handler) 52 | } 53 | 54 | static enter }>( 55 | ...args: Parameters['enter']> 56 | ) { 57 | return (ctx: C) => ctx.scene.enter(...args) 58 | } 59 | 60 | static reenter }>( 61 | ...args: Parameters['reenter']> 62 | ) { 63 | return (ctx: C) => ctx.scene.reenter(...args) 64 | } 65 | 66 | static leave }>( 67 | ...args: Parameters['leave']> 68 | ) { 69 | return (ctx: C) => ctx.scene.leave(...args) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/input.ts: -------------------------------------------------------------------------------- 1 | import { InputFile } from './core/types/typegram' 2 | 3 | /** 4 | * The local file specified by path will be uploaded to Telegram using multipart/form-data. 5 | * 6 | * 10 MB max size for photos, 50 MB for other files. 7 | */ 8 | // prettier-ignore 9 | export const fromLocalFile = (path: string, filename?: string): InputFile => ({ source: path, filename }) 10 | 11 | /** 12 | * The buffer will be uploaded as file to Telegram using multipart/form-data. 13 | * 14 | * 10 MB max size for photos, 50 MB for other files. 15 | */ 16 | // prettier-ignore 17 | export const fromBuffer = (buffer: Buffer, filename?: string): InputFile => ({ source: buffer, filename }) 18 | 19 | /** 20 | * Contents of the stream will be uploaded as file to Telegram using multipart/form-data. 21 | * 22 | * 10 MB max size for photos, 50 MB for other files. 23 | */ 24 | // prettier-ignore 25 | export const fromReadableStream = (stream: NodeJS.ReadableStream, filename?: string): InputFile => ({ source: stream, filename }) 26 | 27 | /** 28 | * Contents of the URL will be streamed to Telegram. 29 | * 30 | * 10 MB max size for photos, 50 MB for other files. 31 | */ 32 | // prettier-ignore 33 | export const fromURLStream = (url: string | URL, filename?: string): InputFile => ({ url: url.toString(), filename }) 34 | 35 | /** 36 | * Provide Telegram with an HTTP URL for the file to be sent. 37 | * Telegram will download and send the file. 38 | * 39 | * * The target file must have the correct MIME type (e.g., audio/mpeg for `sendAudio`, etc.). 40 | * * `sendDocument` with URL will currently only work for GIF, PDF and ZIP files. 41 | * * To use `sendVoice`, the file must have the type audio/ogg and be no more than 1MB in size. 42 | * 1-20MB voice notes will be sent as files. 43 | * 44 | * 5 MB max size for photos and 20 MB max for other types of content. 45 | */ 46 | export const fromURL = (url: string | URL): string => url.toString() 47 | 48 | /** 49 | * If the file is already stored somewhere on the Telegram servers, you don't need to reupload it: 50 | * each file object has a file_id field, simply pass this file_id as a parameter instead of uploading. 51 | * 52 | * It is not possible to change the file type when resending by file_id. 53 | * 54 | * It is not possible to resend thumbnails using file_id. 55 | * They have to be uploaded using one of the other Input methods. 56 | * 57 | * There are no limits for files sent this way. 58 | */ 59 | export const fromFileId = (fileId: string): string => fileId 60 | -------------------------------------------------------------------------------- /src/core/helpers/args.ts: -------------------------------------------------------------------------------- 1 | interface Entity { 2 | /** Type of the entity. Currently, can be “mention” (@username), “hashtag” (#hashtag), “cashtag” ($USD), “bot_command” (/start@jobs_bot), “url” (https://telegram.org), “email” (do-not-reply@telegram.org), “phone_number” (+1-212-555-0123), “bold” (bold text), “italic” (italic text), “underline” (underlined text), “strikethrough” (strikethrough text), “spoiler” (spoiler message), “code” (monowidth string), “pre” (monowidth block), “text_link” (for clickable text URLs), “text_mention” (for users without usernames), “custom_emoji” (for inline custom emoji stickers) */ 3 | type: string 4 | /** Offset in UTF-16 code units to the start of the entity */ 5 | offset: number 6 | /** Length of the entity in UTF-16 code units */ 7 | length: number 8 | } 9 | 10 | const SINGLE_QUOTE = "'" 11 | const DOUBLE_QUOTE = '"' 12 | 13 | export function argsParser( 14 | str: string, 15 | entities: Entity[] = [], 16 | entityOffset = 0 17 | ) { 18 | const mentions: { [offset: string]: number } = {} 19 | for (const entity of entities) // extract all text_mentions into an { offset: length } map 20 | if (entity.type === 'text_mention' || entity.type === 'text_link') 21 | mentions[entity.offset - entityOffset] = entity.length 22 | 23 | const args: string[] = [] 24 | let done = 0 25 | let inside: `'` | `"` | undefined = undefined 26 | let buf = '' 27 | 28 | function flush(to: number) { 29 | if (done !== to) args.push(buf + str.slice(done, to)), (inside = undefined) 30 | buf = '' 31 | done = to + 1 32 | } 33 | 34 | for (let i = 0; i < str.length; i++) { 35 | const char = str[i] 36 | // quick lookup length of mention starting at i 37 | const mention = mentions[i] 38 | if (mention) { 39 | // if we're inside a quote, eagerly flush existing state 40 | flush(i) 41 | // this also consumes current index, so decrement 42 | done-- 43 | // fast forward to end of mention 44 | i += mention 45 | flush(i) 46 | } else if (char === SINGLE_QUOTE || char === DOUBLE_QUOTE) 47 | if (inside) 48 | if (inside === char) flush(i) 49 | else continue 50 | else flush(i), (inside = char) 51 | else if (char === ' ') 52 | if (inside) continue 53 | else flush(i) 54 | else if (char === '\n') flush(i) 55 | else if (char === '\\') 56 | (buf += str.slice(done, i)), (done = ++i) // skip parsing the next char 57 | else continue 58 | } 59 | 60 | if (done < str.length) flush(str.length) 61 | 62 | return args 63 | } 64 | -------------------------------------------------------------------------------- /test/_helpers.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const { Telegraf } = require('..') 4 | 5 | /** @type {import("../types").Message['from']} */ 6 | const from = { 7 | id: 1, 8 | is_bot: false, 9 | first_name: 'Enrico', 10 | last_name: 'Fermi', 11 | } 12 | 13 | /** @type {import("../types").Message['chat']} */ 14 | const chat = { 15 | id: 1, 16 | type: 'private', 17 | first_name: 'Enrico', 18 | last_name: 'Fermi', 19 | } 20 | 21 | let update_id = 1 22 | let message_id = 1 23 | 24 | /** @type { { message: { text:() => import("../types").Update.MessageUpdate } } } */ 25 | const Fixtures = { 26 | message: { 27 | text: () => ({ 28 | update_id: update_id++, 29 | message: { 30 | message_id: message_id++, 31 | date: Date.now(), 32 | from: { ...from }, 33 | chat: { ...chat }, 34 | text: 'foo', 35 | }, 36 | }), 37 | }, 38 | } 39 | 40 | const SyncStore = () => { 41 | const map = new Map() 42 | 43 | return { 44 | map, 45 | store: { 46 | get: (name) => map.get(name), 47 | set: (name, value) => void map.set(name, value), 48 | delete: (name) => void map.delete(name), 49 | }, 50 | } 51 | } 52 | 53 | const AsyncStore = () => { 54 | const map = new Map() 55 | 56 | return { 57 | map, 58 | store: { 59 | async get(name) { 60 | // API requests take time! 61 | await randSleep(50) 62 | return map.get(name) 63 | }, 64 | async set(name, value) { 65 | // API requests take time! 66 | await randSleep(50) 67 | map.set(name, value) 68 | }, 69 | async delete(name) { 70 | // API requests take time! 71 | await randSleep(50) 72 | map.delete(name) 73 | }, 74 | }, 75 | } 76 | } 77 | 78 | const genericAsyncMiddleware = async (ctx, next) => { 79 | await randSleep(200) 80 | return await next() 81 | } 82 | 83 | /** @returns {any} */ 84 | function createBot() { 85 | const bot = new Telegraf('') 86 | bot.botInfo = { 87 | id: 42, 88 | is_bot: true, 89 | username: 'bot', 90 | first_name: 'Bot', 91 | can_join_groups: true, 92 | can_read_all_group_messages: true, 93 | supports_inline_queries: true, 94 | } 95 | return bot 96 | } 97 | 98 | /** @type {(n: number) => Promise} */ 99 | const sleep = (t) => new Promise((r) => setTimeout(r, t)) 100 | 101 | /** @type {(n: number) => number} */ 102 | const rand = (n) => Math.ceil(Math.random() * n) 103 | 104 | /** @type {(n: number) => Promise} */ 105 | const randSleep = (n) => sleep(rand(n)) 106 | 107 | module.exports = { 108 | SyncStore, 109 | AsyncStore, 110 | Fixtures, 111 | rand, 112 | sleep, 113 | createBot, 114 | randSleep, 115 | genericAsyncMiddleware, 116 | } 117 | -------------------------------------------------------------------------------- /src/core/helpers/util.ts: -------------------------------------------------------------------------------- 1 | import { FmtString } from './formatting' 2 | import { Deunionize, UnionKeys } from './deunionize' 3 | 4 | export const env = process.env 5 | 6 | // eslint-disable-next-line @typescript-eslint/ban-types 7 | export type Any = {} | undefined | null 8 | 9 | export type Expand = T extends object 10 | ? T extends infer O 11 | ? { [K in keyof O]: O[K] } 12 | : never 13 | : T 14 | 15 | export type MaybeArray = T | T[] 16 | export type MaybePromise = T | Promise 17 | export type NonemptyReadonlyArray = readonly [T, ...T[]] 18 | 19 | // prettier-ignore 20 | export type ExclusiveKeys = keyof Omit 21 | 22 | export function fmtCaption< 23 | Extra extends { caption?: string | FmtString } | undefined, 24 | >( 25 | extra?: Extra 26 | ): Extra extends undefined 27 | ? undefined 28 | : Omit & { caption?: string } 29 | 30 | export function fmtCaption(extra?: { caption?: string | FmtString }) { 31 | if (!extra) return 32 | const caption = extra.caption 33 | if (!caption || typeof caption === 'string') return extra 34 | const { text, entities } = caption 35 | return { 36 | ...extra, 37 | caption: text, 38 | ...(entities && { 39 | caption_entities: entities, 40 | parse_mode: undefined, 41 | }), 42 | } 43 | } 44 | 45 | export type DistinctKeys = Exclude, keyof T> 46 | 47 | // prettier-ignore 48 | /* eslint-disable-next-line @typescript-eslint/ban-types */ 49 | export type KeyedDistinct> = Record & Deunionize, T> 50 | 51 | // prettier-ignore 52 | /* eslint-disable-next-line @typescript-eslint/ban-types */ 53 | export type Keyed> = Record & Deunionize, T> 54 | 55 | /** Construct a generic type guard */ 56 | export type Guard = (x: X) => x is Y 57 | 58 | /** Extract the guarded type from a type guard, defaults to never. */ 59 | export type Guarded = 60 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 61 | F extends (x: any) => x is infer T ? T : never 62 | 63 | export function* zip(xs: Iterable, ys: Iterable): Iterable { 64 | const x = xs[Symbol.iterator]() 65 | const y = ys[Symbol.iterator]() 66 | let x1 = x.next() 67 | let y1 = y.next() 68 | 69 | while (!x1.done) { 70 | yield x1.value 71 | if (!y1.done) yield y1.value 72 | x1 = x.next() 73 | y1 = y.next() 74 | } 75 | 76 | while (!y1.done) { 77 | yield y1.value 78 | y1 = y.next() 79 | } 80 | } 81 | 82 | export function indexed( 83 | target: T, 84 | indexer: (index: number) => U 85 | ) { 86 | return new Proxy(target, { 87 | get: function (target, prop, receiver) { 88 | if ( 89 | (typeof prop === 'string' || typeof prop === 'number') && 90 | !isNaN(+prop) 91 | ) 92 | return indexer.call(target, +prop) 93 | return Reflect.get(target, prop, receiver) 94 | }, 95 | }) 96 | } 97 | -------------------------------------------------------------------------------- /src/core/network/polling.ts: -------------------------------------------------------------------------------- 1 | import * as tg from '../types/typegram' 2 | import * as tt from '../../telegram-types' 3 | import AbortController from 'abort-controller' 4 | import ApiClient from './client' 5 | import d from 'debug' 6 | import { promisify } from 'util' 7 | import { TelegramError } from './error' 8 | const debug = d('telegraf:polling') 9 | const wait = promisify(setTimeout) 10 | function always(x: T) { 11 | return () => x 12 | } 13 | const noop = always(Promise.resolve()) 14 | 15 | export class Polling { 16 | private readonly abortController = new AbortController() 17 | private skipOffsetSync = false 18 | private offset = 0 19 | constructor( 20 | private readonly telegram: ApiClient, 21 | private readonly allowedUpdates: readonly tt.UpdateType[] 22 | ) {} 23 | 24 | private async *[Symbol.asyncIterator]() { 25 | debug('Starting long polling') 26 | do { 27 | try { 28 | const updates = await this.telegram.callApi( 29 | 'getUpdates', 30 | { 31 | timeout: 50, 32 | offset: this.offset, 33 | allowed_updates: this.allowedUpdates, 34 | }, 35 | this.abortController 36 | ) 37 | const last = updates[updates.length - 1] 38 | if (last !== undefined) { 39 | this.offset = last.update_id + 1 40 | } 41 | yield updates 42 | } catch (error) { 43 | const err = error as Error & { 44 | parameters?: { retry_after: number } 45 | } 46 | 47 | if (err.name === 'AbortError') return 48 | if ( 49 | err.name === 'FetchError' || 50 | (err instanceof TelegramError && err.code === 429) || 51 | (err instanceof TelegramError && err.code >= 500) 52 | ) { 53 | const retryAfter: number = err.parameters?.retry_after ?? 5 54 | debug('Failed to fetch updates, retrying after %ds.', retryAfter, err) 55 | await wait(retryAfter * 1000) 56 | continue 57 | } 58 | if ( 59 | err instanceof TelegramError && 60 | // Unauthorized Conflict 61 | (err.code === 401 || err.code === 409) 62 | ) { 63 | this.skipOffsetSync = true 64 | throw err 65 | } 66 | throw err 67 | } 68 | } while (!this.abortController.signal.aborted) 69 | } 70 | 71 | private async syncUpdateOffset() { 72 | if (this.skipOffsetSync) return 73 | debug('Syncing update offset...') 74 | await this.telegram.callApi('getUpdates', { offset: this.offset, limit: 1 }) 75 | } 76 | 77 | async loop(handleUpdate: (updates: tg.Update) => Promise) { 78 | if (this.abortController.signal.aborted) 79 | throw new Error('Polling instances must not be reused!') 80 | try { 81 | for await (const updates of this) 82 | await Promise.all(updates.map(handleUpdate)) 83 | } finally { 84 | debug('Long polling stopped') 85 | // prevent instance reuse 86 | this.stop() 87 | await this.syncUpdateOffset().catch(noop) 88 | } 89 | } 90 | 91 | stop() { 92 | this.abortController.abort() 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/format.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@telegraf/types' 2 | import { 3 | FmtString, 4 | createFmt, 5 | linkOrMention, 6 | join as _join, 7 | } from './core/helpers/formatting' 8 | 9 | export { FmtString } 10 | 11 | type Nestable = string | number | boolean | FmtString 12 | type Nesting = [ 13 | parts: Nestable | readonly Nestable[], 14 | ...items: Nestable[], 15 | ] 16 | type Nests = ( 17 | ...args: Nesting 18 | ) => FmtString 19 | 20 | // Nests means the function will return A, and it can nest B 21 | // Nests<'fmt', string> means it will nest anything 22 | // Nests<'code', never> means it will not nest anything 23 | 24 | // Allowing everything to nest 'fmt' is a necessary evil; it allows to indirectly nest illegal entities 25 | // Except for 'code' and 'pre', which don't nest anything anyway, so they only deal with strings 26 | 27 | export const join = _join as Nests<'fmt', string> 28 | 29 | export const fmt = createFmt() as Nests<'fmt', string> 30 | 31 | export const bold = createFmt('bold') as Nests< 32 | 'bold', 33 | 'fmt' | 'bold' | 'italic' | 'underline' | 'strikethrough' | 'spoiler' 34 | > 35 | 36 | export const italic = createFmt('italic') as Nests< 37 | 'italic', 38 | 'fmt' | 'bold' | 'italic' | 'underline' | 'strikethrough' | 'spoiler' 39 | > 40 | 41 | export const spoiler = createFmt('spoiler') as Nests< 42 | 'spoiler', 43 | 'fmt' | 'bold' | 'italic' | 'underline' | 'strikethrough' | 'spoiler' 44 | > 45 | 46 | export const strikethrough = 47 | // 48 | createFmt('strikethrough') as Nests< 49 | 'strikethrough', 50 | 'fmt' | 'bold' | 'italic' | 'underline' | 'strikethrough' | 'spoiler' 51 | > 52 | 53 | export const underline = 54 | // 55 | createFmt('underline') as Nests< 56 | 'underline', 57 | 'fmt' | 'bold' | 'italic' | 'underline' | 'strikethrough' | 'spoiler' 58 | > 59 | 60 | export const quote = 61 | // 62 | createFmt('blockquote') as Nests< 63 | 'blockquote', 64 | | 'fmt' 65 | | 'bold' 66 | | 'italic' 67 | | 'underline' 68 | | 'strikethrough' 69 | | 'spoiler' 70 | | 'code' 71 | > 72 | 73 | export const code = createFmt('code') as Nests<'code', never> 74 | 75 | export const pre = (language: string) => 76 | createFmt('pre', { language }) as Nests<'pre', never> 77 | 78 | export const link = ( 79 | content: Nestable< 80 | | 'fmt' 81 | | 'bold' 82 | | 'italic' 83 | | 'underline' 84 | | 'strikethrough' 85 | | 'spoiler' 86 | | 'code' 87 | >, 88 | url: string 89 | ) => 90 | // 91 | linkOrMention(content, { type: 'text_link', url }) as FmtString<'text_link'> 92 | 93 | export const mention = ( 94 | name: Nestable< 95 | | 'fmt' 96 | | 'bold' 97 | | 'italic' 98 | | 'underline' 99 | | 'strikethrough' 100 | | 'spoiler' 101 | | 'code' 102 | >, 103 | user: number | User 104 | ) => 105 | typeof user === 'number' 106 | ? link(name, 'tg://user?id=' + user) 107 | : (linkOrMention(name, { 108 | type: 'text_mention', 109 | user, 110 | }) as FmtString<'text_mention'>) 111 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/filters.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CallbackQuery, 3 | CommonMessageBundle, 4 | Message, 5 | Update, 6 | } from '@telegraf/types' 7 | import { DistinctKeys, KeyedDistinct, Guarded } from './core/helpers/util' 8 | 9 | export type Filter = (update: Update) => update is U 10 | 11 | export { Guarded } 12 | 13 | export type AllGuarded[]> = Fs extends [ 14 | infer A, 15 | ...infer B, 16 | ] 17 | ? B extends [] 18 | ? Guarded 19 | : // TS doesn't know otherwise that B is Filter[] 20 | B extends Filter[] 21 | ? Guarded & AllGuarded 22 | : never 23 | : never 24 | 25 | export const message = 26 | []>(...keys: Ks) => 27 | ( 28 | update: Update 29 | ): update is Update.MessageUpdate> => { 30 | if (!('message' in update)) return false 31 | for (const key of keys) { 32 | if (!(key in update.message)) return false 33 | } 34 | return true 35 | } 36 | 37 | export const editedMessage = 38 | []>(...keys: Ks) => 39 | ( 40 | update: Update 41 | ): update is Update.EditedMessageUpdate< 42 | KeyedDistinct 43 | > => { 44 | if (!('edited_message' in update)) return false 45 | for (const key of keys) { 46 | if (!(key in update.edited_message)) return false 47 | } 48 | return true 49 | } 50 | 51 | export const channelPost = 52 | []>(...keys: Ks) => 53 | ( 54 | update: Update 55 | ): update is Update.ChannelPostUpdate> => { 56 | if (!('channel_post' in update)) return false 57 | for (const key of keys) { 58 | if (!(key in update.channel_post)) return false 59 | } 60 | return true 61 | } 62 | 63 | export const editedChannelPost = 64 | []>(...keys: Ks) => 65 | ( 66 | update: Update 67 | ): update is Update.EditedChannelPostUpdate< 68 | KeyedDistinct 69 | > => { 70 | if (!('edited_channel_post' in update)) return false 71 | for (const key of keys) { 72 | if (!(key in update.edited_channel_post)) return false 73 | } 74 | return true 75 | } 76 | 77 | export const callbackQuery = 78 | []>(...keys: Ks) => 79 | ( 80 | update: Update 81 | ): update is Update.CallbackQueryUpdate< 82 | KeyedDistinct 83 | > => { 84 | if (!('callback_query' in update)) return false 85 | for (const key of keys) { 86 | if (!(key in update.callback_query)) return false 87 | } 88 | return true 89 | } 90 | 91 | /** Any of the provided filters must match */ 92 | export const anyOf = 93 | ( 94 | ...filters: { 95 | [UIdx in keyof Us]: Filter 96 | } 97 | ) => 98 | (update: Update): update is Us[number] => { 99 | for (const filter of filters) if (filter(update)) return true 100 | return false 101 | } 102 | 103 | /** All of the provided filters must match */ 104 | export const allOf = 105 | []>(...filters: Fs) => 106 | (update: Update): update is AllGuarded => { 107 | for (const filter of filters) if (!filter(update)) return false 108 | return true 109 | } 110 | -------------------------------------------------------------------------------- /test/args.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const test = require('ava').default 4 | const fc = require('fast-check') 5 | const { argsParser } = require('../lib/core/helpers/args') 6 | const { deepStrictEqual } = require('assert') 7 | 8 | test('argsParser should act predictably', (t) => { 9 | t.deepEqual( 10 | // 11 | argsParser(`A "quick fox" jumps`), 12 | ['A', 'quick fox', 'jumps'] 13 | ) 14 | t.deepEqual( 15 | // 16 | argsParser(`A "quick" fox" jumps`), 17 | ['A', 'quick', 'fox', ' jumps'] 18 | ) 19 | t.deepEqual( 20 | // 21 | argsParser(`A "quick"fox jumps`), 22 | ['A', 'quick', 'fox', 'jumps'] 23 | ) 24 | t.deepEqual( 25 | // 26 | argsParser(`A "quick\\" fox"" jumps`), 27 | ['A', 'quick" fox', ' jumps'] 28 | ) 29 | t.deepEqual( 30 | // 31 | argsParser(`A "quick\\\\" fox"" jumps`), 32 | ['A', 'quick\\', 'fox', ' jumps'] 33 | ) 34 | t.deepEqual( 35 | // 36 | argsParser(`\\\\ (`), 37 | ['\\', '('] 38 | ) 39 | t.deepEqual( 40 | // 41 | argsParser(`-a="b"`), 42 | ['-a=', 'b'] 43 | ) 44 | }) 45 | 46 | test('argsParser should break multiple lines', (t) => { 47 | t.deepEqual( 48 | // 49 | argsParser(`A "quick\n fox" jumps`), 50 | ['A', 'quick', 'fox', ' jumps'] 51 | ) 52 | }) 53 | 54 | test('argsParser should not break newline preceeded by \\', (t) => { 55 | t.deepEqual( 56 | // 57 | argsParser(`A "quick\\\n fox" jumps`), 58 | ['A', 'quick\n fox', 'jumps'] 59 | ) 60 | }) 61 | 62 | test('argsParser should respect text_mention and text_link', (t) => { 63 | t.deepEqual( 64 | // 65 | argsParser(`A "quick" Mr. Brown 'fox' "jumps`, [ 66 | { 67 | type: 'text_mention', 68 | offset: `A "quick" `.length, 69 | length: `Mr. Brown`.length, 70 | }, 71 | ]), 72 | ['A', 'quick', 'Mr. Brown', 'fox', 'jumps'] 73 | ) 74 | t.deepEqual( 75 | // 76 | argsParser(`A "quick Mr. Brown fox" jumps`, [ 77 | { 78 | type: 'text_link', 79 | offset: `A "quick `.length, 80 | length: `Mr. Brown`.length, 81 | }, 82 | ]), 83 | ['A', 'quick ', 'Mr. Brown', 'fox', ' jumps'] 84 | ) 85 | }) 86 | 87 | test('argsParser - simple property based tests', (t) => { 88 | fc.assert( 89 | // @ts-expect-error TS doesn't know what it's doing here, maybe a bug when running TS on JS 90 | // generate arbitrary strings containing no quotes or escapes 91 | fc.property(fc.stringMatching(/^[^'"\\]+$/), (str) => { 92 | const parsed = argsParser(str) 93 | // Property 1: none of the parsed strings must contain spaces 94 | deepStrictEqual(!parsed.some((x) => x.includes(' ')), true) 95 | 96 | const trimmed = str.trim() 97 | const spaces = [...trimmed.matchAll(/\s+/g)].length 98 | 99 | // Property 2: if the string only had spaces, number of parsed segments must equal 0 100 | if (!trimmed) deepStrictEqual(parsed.length, 0) 101 | else { 102 | // Property 3: number of source spaces must equal the number of parsed segments 103 | deepStrictEqual(parsed.length, spaces + 1) 104 | 105 | // Property 4: first non-space character must be the first non-space character in parsed 106 | deepStrictEqual(parsed[0]?.[0], trimmed.at(0)) 107 | 108 | // Property 5: last non-space character must be the first non-space character in parsed 109 | deepStrictEqual(parsed.at(-1)?.at(-1), trimmed.at(-1)) 110 | } 111 | 112 | t.pass() 113 | }) 114 | ) 115 | }) 116 | -------------------------------------------------------------------------------- /src/reactions.ts: -------------------------------------------------------------------------------- 1 | import { Deunionize } from './core/helpers/deunionize' 2 | import { indexed } from './core/helpers/util' 3 | import * as tg from './core/types/typegram' 4 | 5 | export type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' 6 | export const Digit = new Set(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']) 7 | export type Reaction = 8 | | tg.TelegramEmoji 9 | | `${Digit}${string}` 10 | | Deunionize 11 | 12 | type ReactionCtx = { update: Partial } 13 | 14 | const inspectReaction = (reaction: tg.ReactionType) => { 15 | if (reaction.type === 'custom_emoji') 16 | return `Custom(${reaction.custom_emoji_id})` 17 | else return reaction.emoji 18 | } 19 | 20 | export class ReactionList { 21 | // this is a lie, proxy will be used to access the properties 22 | [index: number]: Deunionize 23 | 24 | protected constructor(protected list: tg.ReactionType[]) {} 25 | 26 | static fromArray(list: tg.ReactionType[] = []): ReactionList { 27 | return indexed( 28 | new ReactionList(list), 29 | function (this: ReactionList, index) { 30 | return this.list[index] 31 | } 32 | ) 33 | } 34 | 35 | static has(reactions: tg.ReactionType[], reaction: Reaction): boolean { 36 | if (typeof reaction === 'string') 37 | if (Digit.has(reaction[0] as string)) 38 | return reactions.some( 39 | (r: Deunionize) => r.custom_emoji_id === reaction 40 | ) 41 | else 42 | return reactions.some( 43 | (r: Deunionize) => r.emoji === reaction 44 | ) 45 | 46 | return reactions.some((r: Deunionize) => { 47 | if (r.type === 'custom_emoji') 48 | return r.custom_emoji_id === reaction.custom_emoji_id 49 | else if (r.type === 'emoji') return r.emoji === reaction.emoji 50 | }) 51 | } 52 | 53 | toArray(): tg.ReactionType[] { 54 | return [...this.list] 55 | } 56 | 57 | filter( 58 | filterFn: (value: tg.ReactionType, index: number) => boolean 59 | ): ReactionList { 60 | return ReactionList.fromArray(this.list.filter(filterFn)) 61 | } 62 | 63 | has(reaction: Reaction): boolean { 64 | return ReactionList.has(this.list, reaction) 65 | } 66 | 67 | get count(): number { 68 | return this.list.length 69 | } 70 | 71 | [Symbol.iterator]() { 72 | return this.list[Symbol.iterator]() 73 | } 74 | 75 | [Symbol.for('nodejs.util.inspect.custom')]() { 76 | const flattened = this.list.map(inspectReaction).join(', ') 77 | return ['ReactionList {', flattened, '}'].join(' ') 78 | } 79 | } 80 | 81 | export class MessageReactions extends ReactionList { 82 | private constructor(public ctx: ReactionCtx) { 83 | super(ctx.update.message_reaction?.new_reaction ?? []) 84 | } 85 | 86 | static from(ctx: ReactionCtx) { 87 | return indexed( 88 | new MessageReactions(ctx), 89 | function (this: MessageReactions, index) { 90 | return this.list[index] 91 | } 92 | ) 93 | } 94 | 95 | get old() { 96 | return ReactionList.fromArray( 97 | this.ctx.update.message_reaction?.old_reaction 98 | ) 99 | } 100 | 101 | get new() { 102 | return ReactionList.fromArray( 103 | this.ctx.update.message_reaction?.new_reaction 104 | ) 105 | } 106 | 107 | get added(): ReactionList { 108 | return this.new.filter((reaction) => !this.old.has(reaction)) 109 | } 110 | 111 | get removed(): ReactionList { 112 | return this.old.filter((reaction) => !this.new.has(reaction)) 113 | } 114 | 115 | get kept(): ReactionList { 116 | return this.new.filter((reaction) => this.old.has(reaction)) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "telegraf", 3 | "version": "4.16.3", 4 | "description": "Modern Telegram Bot Framework", 5 | "license": "MIT", 6 | "author": "The Telegraf Contributors", 7 | "homepage": "https://telegraf.js.org", 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 | ".": { 18 | "types": "./typings/index.d.ts", 19 | "default": "./lib/index.js" 20 | }, 21 | "./filters": { 22 | "types": "./filters.d.ts", 23 | "default": "./filters.js" 24 | }, 25 | "./future": { 26 | "types": "./future.d.ts", 27 | "default": "./future.js" 28 | }, 29 | "./scenes": { 30 | "types": "./scenes.d.ts", 31 | "default": "./scenes.js" 32 | }, 33 | "./types": { 34 | "types": "./types.d.ts", 35 | "default": "./types.js" 36 | }, 37 | "./format": { 38 | "types": "./format.d.ts", 39 | "default": "./format.js" 40 | }, 41 | "./utils": { 42 | "types": "./utils.d.ts", 43 | "default": "./utils.js" 44 | }, 45 | "./markup": { 46 | "types": "./markup.d.ts", 47 | "default": "./markup.js" 48 | }, 49 | "./session": { 50 | "types": "./session.d.ts", 51 | "default": "./session.js" 52 | } 53 | }, 54 | "files": [ 55 | "bin/*", 56 | "src/**/*.ts", 57 | "lib/**/*.js", 58 | "typings/**/*.d.ts", 59 | "typings/**/*.d.ts.map", 60 | "types.*", 61 | "format.*", 62 | "filters.*", 63 | "future.*", 64 | "scenes.*", 65 | "utils.*", 66 | "markup.*", 67 | "session.*" 68 | ], 69 | "bin": { 70 | "telegraf": "lib/cli.mjs" 71 | }, 72 | "scripts": { 73 | "prepare": "npm run --silent build", 74 | "build": "tsc && npm run expose", 75 | "expose": "tsx build/expose.ts types scenes filters format future utils markup session", 76 | "build:docs": "typedoc src/index.ts", 77 | "pretest": "npm run build", 78 | "test": "ava", 79 | "lint": "eslint .", 80 | "checks": "npm test && npm run lint", 81 | "refresh": "npm run clean && npm ci", 82 | "clean": "git clean -fX .eslintcache docs/build/ lib/ typings/" 83 | }, 84 | "ava": { 85 | "files": [ 86 | "test/*", 87 | "!test/_*" 88 | ] 89 | }, 90 | "type": "commonjs", 91 | "engines": { 92 | "node": "^12.20.0 || >=14.13.1" 93 | }, 94 | "types": "./typings/index.d.ts", 95 | "dependencies": { 96 | "@telegraf/types": "^7.1.0", 97 | "abort-controller": "^3.0.0", 98 | "debug": "^4.3.4", 99 | "mri": "^1.2.0", 100 | "node-fetch": "^2.7.0", 101 | "p-timeout": "^4.1.0", 102 | "safe-compare": "^1.1.4", 103 | "sandwich-stream": "^2.0.2" 104 | }, 105 | "devDependencies": { 106 | "@types/debug": "^4.1.8", 107 | "@types/node": "^20.4.2", 108 | "@types/node-fetch": "^2.6.2", 109 | "@types/safe-compare": "^1.1.0", 110 | "@typescript-eslint/eslint-plugin": "^6.1.0", 111 | "@typescript-eslint/parser": "^6.1.0", 112 | "ava": "^5.3.1", 113 | "eslint": "^8.45.0", 114 | "eslint-config-prettier": "^9.0.0", 115 | "eslint-plugin-ava": "^14.0.0", 116 | "eslint-plugin-import": "^2.27.5", 117 | "eslint-plugin-node": "^11.1.0", 118 | "eslint-plugin-prettier": "^5.0.0", 119 | "eslint-plugin-promise": "^6.1.1", 120 | "fast-check": "^3.12.0", 121 | "prettier": "^3.0.3", 122 | "tsx": "^4.7.1", 123 | "typedoc": "^0.25.0", 124 | "typescript": "^5.2.2" 125 | }, 126 | "keywords": [ 127 | "telegraf", 128 | "telegram", 129 | "telegram bot api", 130 | "bot", 131 | "botapi", 132 | "bot framework" 133 | ] 134 | } 135 | -------------------------------------------------------------------------------- /release-notes/4.13.0.md: -------------------------------------------------------------------------------- 1 | # 4.13.0 2 | 3 | ### Multi-session and custom session property 4 | 5 | This update brings us the ability to have multiple session keys. This is achieved simply by passing `property` in session options: 6 | 7 | ```TS 8 | bot.use(session()); // creates ctx.session backed by an in-memory store 9 | 10 | bot.use(session({ 11 | property: "chatSession", 12 | getSessionKey: ctx => ctx.chat && String(ctx.chat.id), 13 | store: Redis({ url: "redis://127.0.0.1:6379" }); 14 | })); // creates ctx.chatSession backed by a Redis store 15 | ``` 16 | 17 | Thanks to @Evertt for making the case for this feature. 18 | 19 | --- 20 | 21 | ### Command parser 22 | 23 | It's an often requested feature to be able to parse command arguments. 24 | 25 | As of this release, `ctx.command`, `ctx.payload`, and `ctx.args` are available for this usecase. It's only available in `bot.command` handlers. 26 | 27 | `ctx.command` is the matched command (even if you used RegExp), and it does not include the botname if it was included in the user's command. `ctx.payload` is the unparsed text part excluding the command. `ctx.args` is a parsed list of arguments passed to it. Have a look at the example: 28 | 29 | ```TS 30 | // User sends /warn --delete "Offtopic chat" 31 | 32 | bot.command("warn", async ctx => { 33 | ctx.args; // [ "--delete", "Offtopic chat" ] 34 | 35 | ctx.command; // [ "warn" ] 36 | ctx.payload; // "--delete \"Offtopic chat\"" 37 | }); 38 | ``` 39 | 40 | ⚠️ `ctx.args` is still considered unstable, and the parser is subject to fine-tuning and improvements based on user feedback. 41 | 42 | The more generic `ctx.payload` for all commands causes `ctx.startPayload` in `bot.start` to be redundant, and hence the latter is now deprecated. 43 | 44 | ```diff 45 | bot.start(ctx => { 46 | - console.log(ctx.startPayload); 47 | + console.log(ctx.payload); 48 | }); 49 | ``` 50 | 51 | You can also play with this feature by importing the parser directly: 52 | 53 | ```TS 54 | import { argsParser } from "telegraf/utils"; 55 | 56 | // do not include the /command part! 57 | argsParser('--delete "Offtopic chat"'); // [ "--delete", "Offtopic chat" ] 58 | ``` 59 | 60 | --- 61 | 62 | ### New types package 63 | 64 | We have now forked Typegram to maintain types more in line with Telegraf. 65 | 66 | Most of you will be unaffected, because Telegraf just switched its internal import to `@telegraf/types`. If you have a direct dependency on `typegram` for any reason, you might want to consider switching that over. `typegram` will continue to be maintained as well. 67 | 68 | Remember that all of these types are available through Telegraf without installing any additional library: 69 | 70 | ```TS 71 | import type { Update } from "telegraf/types"; 72 | ``` 73 | 74 | This new package is [`@telegraf/types`](https://github.com/telegraf/types), available on [Deno/x](https://deno.land/x/telegraf_types) and [npm](https://www.npmjs.com/package/@telegraf/types) with our ongoing effort to make Telegraf more platform independent. 75 | 76 | --- 77 | 78 | ### Bot API 6.6, 6.7, and 6.8 support 79 | 80 | We're a little delayed this time, but we've got them all ready for you now: 81 | 82 | #### API 6.6 83 | 84 | - New methods `setMyDescription`, `getMyDescription`, `setMyShortDescription`, `getMyShortDescription`, ` setCustomEmojiStickerSetThumbnail`, `setStickerSetTitle`, `deleteStickerSet`, `setStickerEmojiList`, `setStickerKeywords`, `setStickerMaskPosition` 85 | - Renamed `setStickerSetThumb` -> `setStickerSetThumbnail` 86 | - Renamed thumb to thumbnail throughout the API 87 | - Various other minor changes, refer to [Bot API 6.6](https://core.telegram.org/bots/api-changelog#march-9-2023) 88 | 89 | #### API 6.7 90 | 91 | - New methods `setMyName`, `getMyName` 92 | - Various other minor changes, refer to [Bot API 6.7](https://core.telegram.org/bots/api-changelog#april-21-2023) 93 | 94 | #### API 6.8 95 | 96 | - New methods `unpinAllGeneralForumTopicMessages` 97 | - Various other minor changes, refer to [Bot API 6.8](https://core.telegram.org/bots/api-changelog#august-18-2023) 98 | -------------------------------------------------------------------------------- /src/core/helpers/formatting.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ 2 | import { MessageEntity, User } from '@telegraf/types' 3 | import { Any, zip } from './util' 4 | 5 | export type Nestable = 6 | | string 7 | | number 8 | | boolean 9 | | FmtString 10 | export type MaybeNestableList = 11 | | Nestable 12 | | readonly Nestable[] 13 | 14 | export interface FmtString { 15 | text: string 16 | entities?: MessageEntity[] 17 | parse_mode?: undefined 18 | __to_nest: Brand 19 | } 20 | 21 | export class FmtString 22 | implements FmtString 23 | { 24 | constructor( 25 | public text: string, 26 | entities?: MessageEntity[] 27 | ) { 28 | if (entities) { 29 | this.entities = entities 30 | // force parse_mode to undefined if entities are present 31 | this.parse_mode = undefined 32 | } 33 | } 34 | static normalise(content: Nestable) { 35 | if (content instanceof FmtString) return content 36 | return new FmtString(String(content)) 37 | } 38 | } 39 | 40 | const isArray: (xs: T | readonly T[]) => xs is readonly T[] = Array.isArray 41 | 42 | /** Given a base FmtString and something to append to it, mutates the base */ 43 | const _add = (base: FmtString, next: FmtString | Any) => { 44 | const len = base.text.length 45 | if (next instanceof FmtString) { 46 | base.text = `${base.text}${next.text}` 47 | // next.entities could be undefined and condition will fail 48 | for (let i = 0; i < (next.entities?.length || 0); i++) { 49 | // because of the above condition, next.entities[i] cannot be undefined 50 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 51 | const entity = next.entities![i]! 52 | // base.entities is ensured by caller 53 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 54 | base.entities!.push({ ...entity, offset: entity.offset + len }) 55 | } 56 | } else base.text = `${base.text}${next}` 57 | } 58 | 59 | /** 60 | * Given an `Iterable` and a separator, flattens the list into a single FmtString. 61 | * Analogous to Array#join -> string, but for FmtString 62 | */ 63 | export const join = ( 64 | fragments: Iterable, 65 | separator?: string | FmtString 66 | ) => { 67 | const result = new FmtString('') 68 | // ensure entities array so loop doesn't need to check 69 | result.entities = [] 70 | 71 | const iter = fragments[Symbol.iterator]() 72 | 73 | let curr = iter.next() 74 | while (!curr.done) { 75 | _add(result, curr.value) 76 | curr = iter.next() 77 | if (separator && !curr.done) _add(result, separator) 78 | } 79 | 80 | // set parse_mode: undefined if entities are present 81 | if (result.entities.length) result.parse_mode = undefined 82 | // remove entities array if not relevant 83 | else delete result.entities 84 | 85 | return result 86 | } 87 | 88 | /** Internal constructor for all fmt helpers */ 89 | export function createFmt(kind?: MessageEntity['type'], opts?: object) { 90 | return function fmt( 91 | parts: MaybeNestableList, 92 | ...items: Nestable[] 93 | ) { 94 | parts = isArray(parts) ? parts : [parts] 95 | const result = join(zip(parts, items)) 96 | if (kind) { 97 | result.entities ??= [] 98 | result.entities.unshift({ 99 | type: kind, 100 | offset: 0, 101 | length: result.text.length, 102 | ...opts, 103 | } as MessageEntity) 104 | result.parse_mode = undefined 105 | } 106 | return result 107 | } 108 | } 109 | 110 | export const linkOrMention = ( 111 | content: Nestable, 112 | data: 113 | | { type: 'text_link'; url: string } 114 | | { type: 'text_mention'; user: User } 115 | ) => { 116 | const { text, entities = [] } = FmtString.normalise(content) 117 | entities.unshift(Object.assign(data, { offset: 0, length: text.length })) 118 | return new FmtString(text, entities) 119 | } 120 | -------------------------------------------------------------------------------- /release-notes/4.12.0.md: -------------------------------------------------------------------------------- 1 | # 4.12.0 2 | 3 | Normally the most exciting features of a new release would be support for the latest Bot API. But in this update, it's session! This has been in the works for many months, and we're happy to bring it to you this release! 4 | 5 | ## Stable session 6 | 7 | Some of you may know that builtin session has been deprecated for quite a while. This was motivated by the fact that session is prone to race-conditions ([#1372](https://github.com/telegraf/telegraf/issues/1372)). This left the community in a grey area where they continued to use session despite the deprecation, since no clear alternative was provided. Added to this was the fact that there were no official database-backed sessions, and all unofficial async session middleware were affected by [#1372](https://github.com/telegraf/telegraf/issues/1372). 8 | 9 | This release finally addresses both of these long-running issues. 10 | 11 | ### No more race conditions 12 | 13 | [#1713](https://github.com/telegraf/telegraf/pull/1713) provides a reference-counted implementation resistant to race conditions. Session is now no longer deprecated, and can be used safely! 14 | 15 | > Note: You should read more about how to safely use session in the [docs repo](https://github.com/feathers-studio/telegraf-docs/blob/b694bcc36b4f71fb1cd650a345c2009ab4d2a2a5/guide/session.md). 16 | 17 | ### Official database adapters are here! 18 | 19 | We're also happy to announce a revamped [`@telegraf/session`](https://github.com/telegraf/session)—this provides official store implementations for database-backed sessions via Redis, MongoDB, MySQL, MariaDB, PostgreSQL, and SQLite. Just install the drivers necessary for your database, and off you go! Since this package now only provides a `store` implementation, it's usable with builtin session, and effectively makes all implementations have the same safety as the core package. [Check it out!](https://github.com/telegraf/session) 20 | 21 | ### Default session 22 | 23 | Additionally, session now accepts a `defaultSession` parameter. You no longer need a hacky middleware to do `ctx.session ??= { count }`. 24 | 25 | ```TS 26 | // 🤢 Old way 27 | bot.use(session()); 28 | bot.use((ctx, next) => { 29 | ctx.session ??= { count: 0 }; 30 | return next(); 31 | }); 32 | 33 | // 😁 New way ✅ 34 | bot.use(session({ defaultSession: () => ({ count: 0 }) })); 35 | ``` 36 | 37 | ## Bot API 6.5 support 38 | 39 | - Updated Typegram, added the following Markup.button helpers to request a user or chat: 40 | - `Markup.button.userRequest` 41 | - `Markup.button.botRequest` 42 | - `Markup.button.groupRequest` 43 | - `Markup.button.channelRequest` 44 | - `Telegram::setChatPermissions` and `Context::setChatPermissions` accept a new parameter for `{ use_independent_chat_permissions?: boolean }` as documented in the [API](https://core.telegram.org/bots/api#setchatpermissions). 45 | 46 | ## Bot API 6.4 support 47 | 48 | - Updated Typegram, added the following new methods to class `Telegram` and `Context`: 49 | - `editGeneralForumTopic` 50 | - `closeGeneralForumTopic` 51 | - `reopenGeneralForumTopic` 52 | - `hideGeneralForumTopic` 53 | - `unhideGeneralForumTopic` 54 | - `Context::sendChatAction` will automatically infer `message_thread_id` for topic messages. 55 | - Fix for `'this' Context of type 'NarrowedContext' is not assignable to method's 'this' of type 'Context'`. 56 | 57 | ## RegExp support for commands! 58 | 59 | Another long-standing problem was the lack of support for RegExp or case-insensitive command matching. This is here now: 60 | 61 | ```TS 62 | bot.command("hello", ctx => ctx.reply("You sent a case-sensitive /hello")); 63 | bot.command(/^hello$/i, ctx => ctx.reply("You sent a case-insensitive /hELLo")); 64 | ``` 65 | 66 | ## fmt helpers 67 | 68 | - New `join` fmt helper to combine dynamic arrays into a single FmtString. 69 | 70 | ```TS 71 | import { fmt, bold, join } from "telegraf/format"; 72 | 73 | // elsewhere 74 | bot.command("/fruits", async ctx => { 75 | const array = ["Oranges", "Apples", "Grapes"]; 76 | const fruitList = join(array.map(fruit => bold(fruit)), "\n"); 77 | const msg = fmt`Fruits to buy:\n${fruitList}`; 78 | await ctx.sendMessage(msg); 79 | }); 80 | ``` 81 | 82 | - Fixed various bugs in fmt helpers, so things like `bold(italic("telegraf"))` will now work as expected. 83 | -------------------------------------------------------------------------------- /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 | import { SessionContext } from '../session' 6 | const debug = d('telegraf:scenes:context') 7 | 8 | const noop = () => Promise.resolve() 9 | const now = () => Math.floor(Date.now() / 1000) 10 | 11 | export interface SceneContext 12 | extends Context { 13 | session: SceneSession 14 | scene: SceneContextScene, D> 15 | } 16 | 17 | export interface SceneSessionData { 18 | current?: string 19 | expires?: number 20 | state?: object 21 | } 22 | 23 | export interface SceneSession { 24 | __scenes?: S 25 | } 26 | 27 | export interface SceneContextSceneOptions { 28 | ttl?: number 29 | default?: string 30 | defaultSession: D 31 | } 32 | 33 | export default class SceneContextScene< 34 | C extends SessionContext>, 35 | D extends SceneSessionData = SceneSessionData, 36 | > { 37 | private readonly options: SceneContextSceneOptions 38 | 39 | constructor( 40 | private readonly ctx: C, 41 | private readonly scenes: Map>, 42 | options: Partial> 43 | ) { 44 | // @ts-expect-error {} might not be assignable to D 45 | const fallbackSessionDefault: D = {} 46 | 47 | this.options = { defaultSession: fallbackSessionDefault, ...options } 48 | } 49 | 50 | get session(): D { 51 | const defaultSession = Object.assign({}, this.options.defaultSession) 52 | 53 | let session = this.ctx.session?.__scenes ?? defaultSession 54 | if (session.expires !== undefined && session.expires < now()) { 55 | session = defaultSession 56 | } 57 | if (this.ctx.session === undefined) { 58 | this.ctx.session = { __scenes: session } 59 | } else { 60 | this.ctx.session.__scenes = session 61 | } 62 | return session 63 | } 64 | 65 | get state() { 66 | return (this.session.state ??= {}) 67 | } 68 | 69 | set state(value) { 70 | this.session.state = { ...value } 71 | } 72 | 73 | get current() { 74 | const sceneId = this.session.current ?? this.options.default 75 | return sceneId === undefined || !this.scenes.has(sceneId) 76 | ? undefined 77 | : this.scenes.get(sceneId) 78 | } 79 | 80 | reset() { 81 | if (this.ctx.session !== undefined) 82 | this.ctx.session.__scenes = Object.assign({}, this.options.defaultSession) 83 | } 84 | 85 | async enter(sceneId: string, initialState: object = {}, silent = false) { 86 | if (!this.scenes.has(sceneId)) { 87 | throw new Error(`Can't find scene: ${sceneId}`) 88 | } 89 | if (!silent) { 90 | await this.leave() 91 | } 92 | debug('Entering scene', sceneId, initialState, silent) 93 | this.session.current = sceneId 94 | this.state = initialState 95 | const ttl = this.current?.ttl ?? this.options.ttl 96 | if (ttl !== undefined) { 97 | this.session.expires = now() + ttl 98 | } 99 | if (this.current === undefined || silent) { 100 | return 101 | } 102 | const handler = 103 | 'enterMiddleware' in this.current && 104 | typeof this.current.enterMiddleware === 'function' 105 | ? this.current.enterMiddleware() 106 | : this.current.middleware() 107 | return await handler(this.ctx, noop) 108 | } 109 | 110 | reenter() { 111 | return this.session.current === undefined 112 | ? undefined 113 | : this.enter(this.session.current, this.state) 114 | } 115 | 116 | private leaving = false 117 | async leave() { 118 | if (this.leaving) return 119 | debug('Leaving scene') 120 | try { 121 | this.leaving = true 122 | if (this.current === undefined) { 123 | return 124 | } 125 | const handler = 126 | 'leaveMiddleware' in this.current && 127 | typeof this.current.leaveMiddleware === 'function' 128 | ? this.current.leaveMiddleware() 129 | : Composer.passThru() 130 | await handler(this.ctx, noop) 131 | return this.reset() 132 | } finally { 133 | this.leaving = false 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/markup.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ForceReply, 3 | InlineKeyboardButton, 4 | InlineKeyboardMarkup, 5 | KeyboardButton, 6 | ReplyKeyboardMarkup, 7 | ReplyKeyboardRemove, 8 | } from './core/types/typegram' 9 | import { is2D } from './core/helpers/check' 10 | 11 | type Hideable = B & { hide?: boolean } 12 | type HideableKBtn = Hideable 13 | type HideableIKBtn = Hideable 14 | 15 | export class Markup< 16 | T extends 17 | | InlineKeyboardMarkup 18 | | ReplyKeyboardMarkup 19 | | ReplyKeyboardRemove 20 | | ForceReply, 21 | > { 22 | constructor(readonly reply_markup: T) {} 23 | 24 | selective( 25 | this: Markup, 26 | value = true 27 | ) { 28 | return new Markup({ ...this.reply_markup, selective: value }) 29 | } 30 | 31 | placeholder( 32 | this: Markup, 33 | placeholder: string 34 | ) { 35 | return new Markup({ 36 | ...this.reply_markup, 37 | input_field_placeholder: placeholder, 38 | }) 39 | } 40 | 41 | resize(this: Markup, value = true) { 42 | return new Markup({ 43 | ...this.reply_markup, 44 | resize_keyboard: value, 45 | }) 46 | } 47 | 48 | oneTime(this: Markup, value = true) { 49 | return new Markup({ 50 | ...this.reply_markup, 51 | one_time_keyboard: value, 52 | }) 53 | } 54 | 55 | persistent(this: Markup, value = true) { 56 | return new Markup({ 57 | ...this.reply_markup, 58 | is_persistent: value, 59 | }) 60 | } 61 | } 62 | 63 | export * as button from './button' 64 | 65 | export function removeKeyboard(): Markup { 66 | return new Markup({ remove_keyboard: true }) 67 | } 68 | 69 | export function forceReply(): Markup { 70 | return new Markup({ force_reply: true }) 71 | } 72 | 73 | export function keyboard(buttons: HideableKBtn[][]): Markup 74 | export function keyboard( 75 | buttons: HideableKBtn[], 76 | options?: Partial> 77 | ): Markup 78 | export function keyboard( 79 | buttons: HideableKBtn[] | HideableKBtn[][], 80 | options?: Partial> 81 | ): Markup { 82 | const keyboard = buildKeyboard(buttons, { 83 | columns: 1, 84 | ...options, 85 | }) 86 | return new Markup({ keyboard }) 87 | } 88 | 89 | export function inlineKeyboard( 90 | buttons: HideableIKBtn[][] 91 | ): Markup 92 | export function inlineKeyboard( 93 | buttons: HideableIKBtn[], 94 | options?: Partial> 95 | ): Markup 96 | export function inlineKeyboard( 97 | buttons: HideableIKBtn[] | HideableIKBtn[][], 98 | options?: Partial> 99 | ): Markup { 100 | const inlineKeyboard = buildKeyboard(buttons, { 101 | columns: buttons.length, 102 | ...options, 103 | }) 104 | return new Markup({ inline_keyboard: inlineKeyboard }) 105 | } 106 | 107 | interface KeyboardBuildingOptions { 108 | wrap?: (btn: B, index: number, currentRow: B[]) => boolean 109 | columns: number 110 | } 111 | 112 | function buildKeyboard( 113 | buttons: B[] | B[][], 114 | options: KeyboardBuildingOptions 115 | ): B[][] { 116 | const result: B[][] = [] 117 | if (!Array.isArray(buttons)) { 118 | return result 119 | } 120 | if (is2D(buttons)) { 121 | return buttons.map((row) => row.filter((button) => !button.hide)) 122 | } 123 | const wrapFn = 124 | options.wrap !== undefined 125 | ? options.wrap 126 | : (_btn: B, _index: number, currentRow: B[]) => 127 | currentRow.length >= options.columns 128 | let currentRow: B[] = [] 129 | let index = 0 130 | for (const btn of buttons.filter((button) => !button.hide)) { 131 | if (wrapFn(btn, index, currentRow) && currentRow.length > 0) { 132 | result.push(currentRow) 133 | currentRow = [] 134 | } 135 | currentRow.push(btn) 136 | index++ 137 | } 138 | if (currentRow.length > 0) { 139 | result.push(currentRow) 140 | } 141 | return result 142 | } 143 | -------------------------------------------------------------------------------- /src/cli.mts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import d from 'debug' 4 | import parse from 'mri' 5 | import path from 'path' 6 | 7 | import type { RequestListener } from 'http' 8 | import type { TlsOptions } from 'tls' 9 | import { Telegraf, type Context, type Middleware } from './index.js' 10 | 11 | const debug = d('telegraf:cli') 12 | 13 | const helpMsg = `Usage: telegraf [opts] 14 | 15 | -t Bot token [$BOT_TOKEN] 16 | -d Webhook domain [$BOT_DOMAIN] 17 | -H Webhook host [0.0.0.0] 18 | -p Webhook port [$PORT or 3000] 19 | -l Enable logs 20 | -h Show this help message 21 | -m Bot API method to run directly 22 | -D Data to pass to the Bot API method` 23 | 24 | const help = () => console.log(helpMsg) 25 | 26 | export type Expand = T extends object 27 | ? T extends infer O 28 | ? { [K in keyof O]: O[K] } 29 | : never 30 | : T 31 | 32 | type Parsed = { 33 | // string params, all optional 34 | token?: string 35 | domain?: string 36 | method?: string 37 | data?: string 38 | 39 | // defaults exist 40 | host: string 41 | port: string 42 | 43 | // boolean params 44 | logs: boolean 45 | help: boolean 46 | 47 | // argv 48 | _: [program: string, entryfile: string, ...paths: string[]] 49 | } 50 | 51 | type Env = { 52 | BOT_TOKEN?: string 53 | BOT_DOMAIN?: string 54 | PORT?: string 55 | } 56 | 57 | /** 58 | * Runs the cli program and returns exit code 59 | */ 60 | export async function main(argv: string[], env: Env = {}) { 61 | const args = parse(argv, { 62 | alias: { 63 | // string params, all optional 64 | t: 'token', 65 | d: 'domain', 66 | m: 'method', 67 | D: 'data', 68 | 69 | // defaults exist 70 | H: 'host', 71 | p: 'port', 72 | 73 | // boolean params 74 | l: 'logs', 75 | h: 'help', 76 | }, 77 | boolean: ['h', 'l'], 78 | default: { 79 | H: '0.0.0.0', 80 | p: env.PORT || '3000', 81 | }, 82 | }) as Parsed 83 | 84 | if (args.help) { 85 | help() 86 | return 0 87 | } 88 | 89 | const token = args.token || env.BOT_TOKEN 90 | const domain = args.domain || env.BOT_DOMAIN 91 | 92 | if (!token) { 93 | console.error('Please supply Bot Token') 94 | help() 95 | return 1 96 | } 97 | 98 | const bot = new Telegraf(token) 99 | 100 | if (args.method) { 101 | const method = args.method as Parameters[0] 102 | console.log( 103 | await bot.telegram.callApi(method, JSON.parse(args.data || '{}')) 104 | ) 105 | return 0 106 | } 107 | 108 | let [, , file] = args._ 109 | 110 | if (!file) { 111 | try { 112 | const packageJson = (await import( 113 | path.resolve(process.cwd(), 'package.json') 114 | )) as { main?: string } 115 | file = packageJson.main || 'index.js' 116 | // eslint-disable-next-line no-empty 117 | } catch (err) {} 118 | } 119 | 120 | if (!file) { 121 | console.error('Please supply a bot handler file.\n') 122 | help() 123 | return 2 124 | } 125 | 126 | if (file[0] !== '/') file = path.resolve(process.cwd(), file) 127 | 128 | type Mod = 129 | | { 130 | default: Middleware 131 | botHandler: undefined 132 | httpHandler: undefined 133 | tlsOptions: undefined 134 | } 135 | | { 136 | default: undefined 137 | botHandler: Middleware 138 | httpHandler?: RequestListener 139 | tlsOptions?: TlsOptions 140 | } 141 | 142 | try { 143 | if (args.logs) d.enable('telegraf:*') 144 | 145 | const mod: Mod = await import(file) 146 | const botHandler = mod.botHandler || mod.default 147 | const httpHandler = mod.httpHandler 148 | const tlsOptions = mod.tlsOptions 149 | 150 | const config: Telegraf.LaunchOptions = {} 151 | if (domain) { 152 | config.webhook = { 153 | domain, 154 | host: args.host, 155 | port: Number(args.port), 156 | tlsOptions, 157 | cb: httpHandler, 158 | } 159 | } 160 | 161 | bot.use(botHandler) 162 | 163 | debug(`Starting module ${file}`) 164 | await bot.launch(config) 165 | } catch (err) { 166 | console.error(`Error launching bot from ${file}`, (err as Error)?.stack) 167 | return 3 168 | } 169 | 170 | // Enable graceful stop 171 | process.once('SIGINT', () => bot.stop('SIGINT')) 172 | process.once('SIGTERM', () => bot.stop('SIGTERM')) 173 | 174 | return 0 175 | } 176 | 177 | process.exitCode = await main(process.argv, process.env as Env) 178 | -------------------------------------------------------------------------------- /src/button.ts: -------------------------------------------------------------------------------- 1 | import { 2 | InlineKeyboardButton, 3 | KeyboardButton, 4 | KeyboardButtonRequestChat, 5 | KeyboardButtonRequestUsers, 6 | } from './core/types/typegram' 7 | 8 | type Hideable = B & { hide: boolean } 9 | 10 | export function text( 11 | text: string, 12 | hide = false 13 | ): Hideable { 14 | return { text, hide } 15 | } 16 | 17 | export function contactRequest( 18 | text: string, 19 | hide = false 20 | ): Hideable { 21 | return { text, request_contact: true, hide } 22 | } 23 | 24 | export function locationRequest( 25 | text: string, 26 | hide = false 27 | ): Hideable { 28 | return { text, request_location: true, hide } 29 | } 30 | 31 | export function pollRequest( 32 | text: string, 33 | type?: 'quiz' | 'regular', 34 | hide = false 35 | ): Hideable { 36 | return { text, request_poll: { type }, hide } 37 | } 38 | 39 | export function userRequest( 40 | text: string, 41 | /** Must fit in a signed 32 bit int */ 42 | request_id: number, 43 | extra?: Omit, 44 | hide = false 45 | ): Hideable { 46 | return { 47 | text, 48 | request_users: { request_id, ...extra }, 49 | hide, 50 | } 51 | } 52 | 53 | export function botRequest( 54 | text: string, 55 | /** Must fit in a signed 32 bit int */ 56 | request_id: number, 57 | extra?: Omit< 58 | KeyboardButtonRequestUsers, 59 | 'request_id' | 'user_is_bot' | 'text' 60 | >, 61 | hide = false 62 | ): Hideable { 63 | return { 64 | text, 65 | request_users: { request_id, user_is_bot: true, ...extra }, 66 | hide, 67 | } 68 | } 69 | 70 | type KeyboardButtonRequestGroup = Omit< 71 | KeyboardButtonRequestChat, 72 | 'request_id' | 'chat_is_channel' 73 | > 74 | 75 | export function groupRequest( 76 | text: string, 77 | /** Must fit in a signed 32 bit int */ 78 | request_id: number, 79 | extra?: KeyboardButtonRequestGroup, 80 | hide = false 81 | ): Hideable { 82 | return { 83 | text, 84 | request_chat: { request_id, chat_is_channel: false, ...extra }, 85 | hide, 86 | } 87 | } 88 | 89 | type KeyboardButtonRequestChannel = Omit< 90 | KeyboardButtonRequestChat, 91 | 'request_id' | 'chat_is_channel' | 'chat_is_forum' 92 | > 93 | 94 | export function channelRequest( 95 | text: string, 96 | /** Must fit in a signed 32 bit int */ 97 | request_id: number, 98 | extra?: KeyboardButtonRequestChannel, 99 | hide = false 100 | ): Hideable { 101 | return { 102 | text, 103 | request_chat: { request_id, chat_is_channel: true, ...extra }, 104 | hide, 105 | } 106 | } 107 | 108 | export function url( 109 | text: string, 110 | url: string, 111 | hide = false 112 | ): Hideable { 113 | return { text, url, hide } 114 | } 115 | 116 | export function callback( 117 | text: string, 118 | data: string, 119 | hide = false 120 | ): Hideable { 121 | return { text, callback_data: data, hide } 122 | } 123 | 124 | export function switchToChat( 125 | text: string, 126 | value: string, 127 | hide = false 128 | ): Hideable { 129 | return { text, switch_inline_query: value, hide } 130 | } 131 | 132 | export function switchToCurrentChat( 133 | text: string, 134 | value: string, 135 | hide = false 136 | ): Hideable { 137 | return { text, switch_inline_query_current_chat: value, hide } 138 | } 139 | 140 | export function game( 141 | text: string, 142 | hide = false 143 | ): Hideable { 144 | return { text, callback_game: {}, hide } 145 | } 146 | 147 | export function pay( 148 | text: string, 149 | hide = false 150 | ): Hideable { 151 | return { text, pay: true, hide } 152 | } 153 | 154 | export function login( 155 | text: string, 156 | url: string, 157 | opts: { 158 | forward_text?: string 159 | bot_username?: string 160 | request_write_access?: boolean 161 | } = {}, 162 | hide = false 163 | ): Hideable { 164 | return { 165 | text, 166 | login_url: { ...opts, url }, 167 | hide, 168 | } 169 | } 170 | 171 | export function webApp( 172 | text: string, 173 | url: string, 174 | hide = false 175 | // works as both InlineKeyboardButton and KeyboardButton 176 | ): Hideable { 177 | return { 178 | text, 179 | web_app: { url }, 180 | hide, 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /release-notes/4.11.0.md: -------------------------------------------------------------------------------- 1 | # 4.11.0 - API 6.3 & filters 2 | 3 | ## 🔺 Bot API 6.3 support 4 | 5 | * Updated to Typegram 4.1.0 and added the following new methods to `Telegram` class: 6 | * `createForumTopic` 7 | * `editForumTopic` 8 | * `closeForumTopic` 9 | * `reopenForumTopic` 10 | * `deleteForumTopic` 11 | * `unpinAllForumTopicMessages` 12 | * `getForumTopicIconStickers` 13 | * Added new method shorthands to `Context`; add `message_thread_id` implicitly to all context methods that take it. 14 | 15 | ## ✨ Filters! ✨ 16 | 17 | We've added a new powerful feature called filters! Here's how to use them. 18 | 19 | ```TS 20 | // import our filters 21 | import { message, editedMessage, channelPost, editedChannelPost, callbackQuery } from "telegraf/filters"; 22 | // you can also use require, like this: 23 | // const { message, editedMessage, channelPost, editedChannelPost, callbackQuery } = require("telegraf/filters"); 24 | 25 | const bot = new Telegraf(token); 26 | 27 | bot.on(message("text"), ctx => { 28 | // this is a text message update 29 | // ctx.message.text 30 | }); 31 | 32 | bot.on(channelPost("video"), ctx => { 33 | // this is a video channel post update 34 | // ctx.channelPost.video 35 | }); 36 | 37 | bot.on(callbackQuery("game_short_name"), ctx => { 38 | // this is a video channel post update 39 | // ctx.callbackQuery.game_short_name 40 | }); 41 | ``` 42 | 43 | This unlocks the ability to filter for very specific update types previously not possible! This is only an initial release, and filters will become even more powerful in future updates. 44 | 45 | All filters are also usable from a new method, `ctx.has`. This is very useful if you want to filter within a handler. For example: 46 | 47 | ```TS 48 | // handles all updates 49 | bot.use(ctx => { 50 | if (ctx.has(message("text"))) { 51 | // handles only text messages 52 | // ctx.message.text; 53 | } else { 54 | // handles all other messages 55 | } 56 | }); 57 | ``` 58 | 59 | Like `bot.on`, `ctx.has` also supports an array of update types and filters, even mixed: 60 | 61 | ```TS 62 | // match a message update or a callbackQuery with data present 63 | bot.on(["message", callbackQuery("data")], handler); 64 | 65 | if (ctx.has(["message", callbackQuery("data")])) { 66 | // ctx.update is a message update or a callbackQuery with data present 67 | }; 68 | ``` 69 | 70 | ## ⚠️ Deprecating `bot.on` with message types! 71 | 72 | As of this release, filtering by _**message type**_ using `bot.on()` (for example: "text", "photo", etc.) is deprecated. Don't panic, though! Your existing bots will continue to work, but whenever you can, you must update your message type filters to use the above filters before v5. This is fairly easy to do, like this: 73 | 74 | ```diff 75 | - bot.on("text", handler); 76 | + bot.on(message("text"), handler); 77 | ``` 78 | 79 | The deprecated message type behaviour will be removed in v5. 80 | 81 | You might be happy, or fairly upset about this development. But it was important we made this decision. For a long time, Telegraf has supported filtering by both update type and message type. 82 | 83 | This meant you could use `bot.on("message")`, or `bot.on("text")` (text here is a message type, and not an update type, so this was really making sure that `update.message.text` existed). However, when polls were introduced, this caused a conflict. `bot.on("poll")` would match both `update.poll` (update about stopped polls sent by the bot) and `update.message.poll` (a message that is a native poll). At type-level, both objects will show as available, which was wrong. 84 | 85 | Besides, this type of filters really limited how far we could go with Telegraf. That's why we introduced filters, which are way more powerful and flexible! 86 | 87 | ## `bot.launch` is now catchable ([#1657](https://github.com/telegraf/telegraf/issues/1657)) 88 | 89 | Polling errors were previously uncatchable in Telegraf. They are now. Simply attach a `catch` to `bot.launch`: 90 | 91 | ```TS 92 | bot.launch().catch(e => { 93 | // polling has errored 94 | }); 95 | 96 | // You an also use await and try/catch if you're using ESM 97 | ``` 98 | 99 | Two things to remember: 100 | 101 | * In case you're using `bot.launch` in webhook mode, it will immediately resolve after `setWebhook` completes. 102 | * The bot will not continue running after it errors, even if the error is caught. Before you create a new bot instance and launch it, consider that this error is fatal for a serious reason (for example: network is down, or bot token is incorrect). You may not want to attempt a restart when this happens. 103 | 104 | We previously did not want fatal errors to be caught, since it gives the impression that it's a handleable error. However, being able to catch this is useful when you launch multiple bots in the same process, and one of them failing doesn't need to bring down the process. 105 | 106 | Use this feature with care. :) 107 | 108 | ## Minor changes 109 | 110 | * Format helpers (`"telegraf/format"`) now use template string substitution instead of naively using `+=`. ([Discussion](https://t.me/TelegrafJSChat/87251)) 111 | -------------------------------------------------------------------------------- /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().reply_markup } 6 | t.deepEqual(markup, { remove_keyboard: true }) 7 | }) 8 | 9 | test('should generate forceReply markup', (t) => { 10 | const markup = { ...Markup.forceReply().reply_markup } 11 | t.deepEqual(markup, { force_reply: true }) 12 | }) 13 | 14 | test('should generate resizeKeyboard markup', (t) => { 15 | const markup = { ...Markup.keyboard([]).resize().reply_markup } 16 | t.deepEqual(markup, { keyboard: [], resize_keyboard: true }) 17 | }) 18 | 19 | test('should generate oneTimeKeyboard markup', (t) => { 20 | const markup = { ...Markup.keyboard([]).oneTime().reply_markup } 21 | t.deepEqual(markup, { keyboard: [], one_time_keyboard: true }) 22 | }) 23 | 24 | test('should generate selective hide markup', (t) => { 25 | const markup = { ...Markup.removeKeyboard().selective().reply_markup } 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().reply_markup } 31 | t.deepEqual(markup, { keyboard: [], selective: true, one_time_keyboard: true }) 32 | }) 33 | 34 | test('should generate keyboard markup', (t) => { 35 | const markup = { ...Markup.keyboard([['one'], ['two', 'three']]).reply_markup } 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']).reply_markup } 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 }).reply_markup } 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 }).reply_markup } 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 | }).reply_markup 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']).reply_markup } 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']) } 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 standard button markup', (t) => { 116 | const markup = { ...Markup.button.text('foo') } 117 | t.deepEqual(markup, { text: 'foo', hide: false }) 118 | }) 119 | 120 | test('should generate cb button markup', (t) => { 121 | const markup = { ...Markup.button.callback('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.button.url('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.button.locationRequest('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.button.contactRequest('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.button.switchToChat('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.button.switchToCurrentChat('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.button.game('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.button.game('play again', true) } 157 | t.deepEqual(markup, { text: 'play again', callback_game: {}, hide: true }) 158 | }) 159 | -------------------------------------------------------------------------------- /src/telegram-types.ts: -------------------------------------------------------------------------------- 1 | /** @format */ 2 | 3 | import { Expand } from './core/helpers/util' 4 | import { 5 | Message, 6 | Opts, 7 | Telegram, 8 | Update, 9 | InputMediaAudio, 10 | InputMediaDocument, 11 | InputMediaPhoto, 12 | InputMediaVideo, 13 | } from './core/types/typegram' 14 | 15 | import { UnionKeys } from './core/helpers/deunionize' 16 | import { FmtString } from './format' 17 | 18 | export { Markup } from './markup' 19 | 20 | // tiny helper types 21 | export type ChatAction = Opts<'sendChatAction'>['action'] 22 | 23 | // Modify type so caption, if exists, can be FmtString 24 | export type WrapCaption = T extends { caption?: string } 25 | ? Expand & { caption?: string | FmtString }> 26 | : T 27 | 28 | // extra types 29 | /** 30 | * Create an `Extra*` type from the arguments of a given method `M extends keyof Telegram` but `Omit`ting fields with key `K` from it. 31 | * 32 | * Note that `chat_id` may not be specified in `K` because it is `Omit`ted by default. 33 | */ 34 | type MakeExtra< 35 | M extends keyof Telegram, 36 | K extends keyof Omit, 'chat_id'> = never, 37 | > = WrapCaption, 'chat_id' | K>> 38 | 39 | export type ExtraAddStickerToSet = MakeExtra< 40 | 'addStickerToSet', 41 | 'name' | 'user_id' 42 | > 43 | export type ExtraAnimation = MakeExtra<'sendAnimation', 'animation'> 44 | export type ExtraAnswerCbQuery = MakeExtra< 45 | 'answerCallbackQuery', 46 | 'text' | 'callback_query_id' 47 | > 48 | export type ExtraAnswerInlineQuery = MakeExtra< 49 | 'answerInlineQuery', 50 | 'inline_query_id' | 'results' 51 | > 52 | export type ExtraSetChatPermissions = MakeExtra< 53 | 'setChatPermissions', 54 | 'permissions' 55 | > 56 | export type ExtraAudio = MakeExtra<'sendAudio', 'audio'> 57 | export type ExtraContact = MakeExtra< 58 | 'sendContact', 59 | 'phone_number' | 'first_name' 60 | > 61 | export type ExtraCopyMessage = MakeExtra< 62 | 'copyMessage', 63 | 'from_chat_id' | 'message_id' 64 | > 65 | export type ExtraCopyMessages = MakeExtra< 66 | 'copyMessages', 67 | 'from_chat_id' | 'message_ids' 68 | > 69 | export type ExtraCreateChatInviteLink = MakeExtra<'createChatInviteLink'> 70 | export type NewInvoiceLinkParameters = MakeExtra<'createInvoiceLink'> 71 | export type ExtraCreateNewStickerSet = MakeExtra< 72 | 'createNewStickerSet', 73 | 'name' | 'title' | 'user_id' 74 | > 75 | export type ExtraDice = MakeExtra<'sendDice'> 76 | export type ExtraDocument = MakeExtra<'sendDocument', 'document'> 77 | export type ExtraEditChatInviteLink = MakeExtra< 78 | 'editChatInviteLink', 79 | 'invite_link' 80 | > 81 | export type ExtraEditMessageCaption = MakeExtra< 82 | 'editMessageCaption', 83 | 'message_id' | 'inline_message_id' | 'caption' 84 | > 85 | export type ExtraEditMessageLiveLocation = MakeExtra< 86 | 'editMessageLiveLocation', 87 | 'message_id' | 'inline_message_id' | 'latitude' | 'longitude' 88 | > 89 | export type ExtraEditMessageMedia = MakeExtra< 90 | 'editMessageMedia', 91 | 'message_id' | 'inline_message_id' | 'media' 92 | > 93 | export type ExtraEditMessageText = MakeExtra< 94 | 'editMessageText', 95 | 'message_id' | 'inline_message_id' | 'text' 96 | > 97 | export type ExtraGame = MakeExtra<'sendGame', 'game_short_name'> 98 | export type NewInvoiceParameters = MakeExtra< 99 | 'sendInvoice', 100 | | 'disable_notification' 101 | | 'reply_parameters' 102 | | 'reply_markup' 103 | | 'message_thread_id' 104 | > 105 | export type ExtraInvoice = MakeExtra<'sendInvoice', keyof NewInvoiceParameters> 106 | export type ExtraBanChatMember = MakeExtra< 107 | 'banChatMember', 108 | 'user_id' | 'until_date' 109 | > 110 | export type ExtraKickChatMember = ExtraBanChatMember 111 | export type ExtraLocation = MakeExtra<'sendLocation', 'latitude' | 'longitude'> 112 | export type ExtraMediaGroup = MakeExtra<'sendMediaGroup', 'media'> 113 | export type ExtraPhoto = MakeExtra<'sendPhoto', 'photo'> 114 | export type ExtraPoll = MakeExtra<'sendPoll', 'question' | 'options' | 'type'> 115 | export type ExtraPromoteChatMember = MakeExtra<'promoteChatMember', 'user_id'> 116 | export type ExtraReplyMessage = MakeExtra<'sendMessage', 'text'> 117 | export type ExtraForwardMessage = MakeExtra< 118 | 'forwardMessage', 119 | 'from_chat_id' | 'message_id' 120 | > 121 | export type ExtraForwardMessages = MakeExtra< 122 | 'forwardMessages', 123 | 'from_chat_id' | 'message_ids' 124 | > 125 | export type ExtraSendChatAction = MakeExtra<'sendChatAction', 'action'> 126 | export type ExtraRestrictChatMember = MakeExtra<'restrictChatMember', 'user_id'> 127 | export type ExtraSetMyCommands = MakeExtra<'setMyCommands', 'commands'> 128 | export type ExtraSetWebhook = MakeExtra<'setWebhook', 'url'> 129 | export type ExtraSticker = MakeExtra<'sendSticker', 'sticker'> 130 | export type ExtraStopPoll = MakeExtra<'stopPoll', 'message_id'> 131 | export type ExtraVenue = MakeExtra< 132 | 'sendVenue', 133 | 'latitude' | 'longitude' | 'title' | 'address' 134 | > 135 | export type ExtraVideo = MakeExtra<'sendVideo', 'video'> 136 | export type ExtraVideoNote = MakeExtra<'sendVideoNote', 'video_note'> 137 | export type ExtraVoice = MakeExtra<'sendVoice', 'voice'> 138 | export type ExtraBanChatSenderChat = MakeExtra< 139 | 'banChatSenderChat', 140 | 'sender_chat_id' 141 | > 142 | export type ExtraCreateForumTopic = MakeExtra<'createForumTopic', 'name'> 143 | export type ExtraEditForumTopic = MakeExtra< 144 | 'editForumTopic', 145 | 'message_thread_id' 146 | > 147 | 148 | export type MediaGroup = 149 | | readonly (InputMediaPhoto | InputMediaVideo)[] 150 | | readonly InputMediaAudio[] 151 | | readonly InputMediaDocument[] 152 | 153 | // types used for inference of ctx object 154 | 155 | /** Possible update types */ 156 | export type UpdateType = Exclude, keyof Update> 157 | 158 | /** Possible message subtypes. Same as the properties on a message object */ 159 | export type MessageSubType = 160 | | 'forward_date' 161 | | Exclude< 162 | UnionKeys, 163 | keyof Message.CaptionableMessage | 'entities' | 'media_group_id' 164 | > 165 | 166 | type ExtractPartial = T extends unknown 167 | ? Required extends U 168 | ? T 169 | : never 170 | : never 171 | 172 | /** 173 | * Maps [[`Composer.on`]]'s `updateType` or `messageSubType` to a `tt.Update` subtype. 174 | * @deprecated 175 | */ 176 | export type MountMap = { 177 | [T in UpdateType]: Extract> 178 | } & { 179 | [T in MessageSubType]: { 180 | message: ExtractPartial> 181 | update_id: number 182 | } 183 | } 184 | 185 | export interface CommandContextExtn { 186 | match: RegExpExecArray 187 | /** 188 | * Matched command. This will always be the actual command, excluding preceeding slash and `@botname` 189 | * 190 | * Examples: 191 | * ``` 192 | * /command abc -> command 193 | * /command@xyzbot abc -> command 194 | * ``` 195 | */ 196 | command: string 197 | /** 198 | * The unparsed payload part of the command 199 | * 200 | * Examples: 201 | * ``` 202 | * /command abc def -> "abc def" 203 | * /command "token1 token2" -> "\"token1 token2\"" 204 | * ``` 205 | */ 206 | payload: string 207 | /** 208 | * Command args parsed into an array. 209 | * 210 | * Examples: 211 | * ``` 212 | * /command token1 token2 -> [ "token1", "token2" ] 213 | * /command "token1 token2" -> [ "token1 token2" ] 214 | * /command token1 "token2 token3" -> [ "token1" "token2 token3" ] 215 | * ``` 216 | * @unstable Parser implementation might vary until considered stable 217 | * */ 218 | args: string[] 219 | } 220 | -------------------------------------------------------------------------------- /test/session.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const test = require('ava').default 4 | const { session } = require('..') 5 | 6 | const { 7 | SyncStore, 8 | AsyncStore, 9 | Fixtures, 10 | createBot, 11 | randSleep, 12 | genericAsyncMiddleware, 13 | } = require('./_helpers') 14 | 15 | /** @typedef {import('..').Context & { session: { count: number } }} MyCtx */ 16 | 17 | /* 18 | 19 | This is testing essentially the following logic: 20 | 21 | Setup: 22 | * store is sync, but middlewares aren't 23 | * fire all updates simultaneously 24 | 25 | If racing were to occur, ctx.session would repeatedly get overwritten 26 | 27 | We test that it did not in fact get overwritten, and many updates were 28 | able to concurrently read and write to session asynchronously without racing 29 | 30 | */ 31 | test('must resist session racing (with sync store)', (t) => 32 | t.notThrowsAsync( 33 | new Promise((resolve) => { 34 | /** @type {import('..').Telegraf} */ 35 | const bot = createBot() 36 | 37 | const { store, map } = SyncStore() 38 | bot.use(session({ store })) 39 | 40 | // pretend there are other middlewares that slow down by a random amount 41 | bot.use(genericAsyncMiddleware) 42 | 43 | bot.on('message', async (ctx) => { 44 | if (ctx.session === undefined) { 45 | ctx.session = { count: 1 } 46 | } else { 47 | ctx.session.count++ 48 | } 49 | 50 | // pretend we make an API call, etc 51 | await randSleep(200) 52 | }) 53 | 54 | // pretend there are other middlewares that slow down by a random amount 55 | bot.use(genericAsyncMiddleware) 56 | 57 | return Promise.all( 58 | Array 59 | // create 100 text updates and fire them all at once 60 | .from({ length: 100 }, Fixtures.message.text) 61 | .map((fixture) => bot.handleUpdate(fixture)) 62 | ) 63 | .then(() => { 64 | t.deepEqual(map.size, 1) 65 | t.deepEqual(map.get('1:1'), { count: 100 }) 66 | }) 67 | .then(() => resolve(true)) 68 | }) 69 | )) 70 | 71 | /* 72 | 73 | This is testing essentially the following logic: 74 | 75 | Setup: 76 | * everything is async - store, and middlewares 77 | * fire all updates simultaneously 78 | 79 | If racing were to occur, ctx.session would repeatedly get overwritten 80 | 81 | We test that it did not in fact get overwritten, and many updates were 82 | able to concurrently read and write to session asynchronously without racing 83 | 84 | */ 85 | test('must resist session racing (with async store)', (t) => 86 | t.notThrowsAsync( 87 | new Promise((resolve) => { 88 | /** @type {import('..').Telegraf} */ 89 | const bot = createBot() 90 | 91 | const { store, map } = AsyncStore() 92 | bot.use(session({ store })) 93 | 94 | bot.on('message', async (ctx) => { 95 | if (ctx.session === undefined) { 96 | ctx.session = { count: 1 } 97 | } else { 98 | ctx.session.count++ 99 | } 100 | 101 | // pretend we make an API call, etc 102 | await randSleep(200) 103 | }) 104 | 105 | // pretend there are other middlewares that slow down by a random amount 106 | bot.use(genericAsyncMiddleware) 107 | 108 | return Promise.all( 109 | Array 110 | // create 100 text updates and fire them all at once 111 | .from({ length: 100 }, Fixtures.message.text) 112 | .map((fixture) => bot.handleUpdate(fixture)) 113 | ) 114 | .then(() => { 115 | t.deepEqual(map.size, 1) 116 | t.deepEqual(map.get('1:1'), { count: 100 }) 117 | }) 118 | .then(() => resolve(true)) 119 | }) 120 | )) 121 | 122 | test('must not write session back if session not touched after defaultSession was passed', (t) => 123 | t.notThrowsAsync( 124 | new Promise((resolve) => { 125 | /** @type {import('..').Telegraf} */ 126 | const bot = createBot() 127 | 128 | const { store, map } = AsyncStore() 129 | bot.use(session({ store, defaultSession: () => ({ count: 0 }) })) 130 | 131 | bot.on('message', async (ctx) => { 132 | // pretend we make an API call, etc 133 | await randSleep(200) 134 | 135 | // ctx.session is not touched 136 | }) 137 | 138 | return Promise.all( 139 | Array 140 | // create 100 text message updates and fire them all at once 141 | .from({ length: 100 }, Fixtures.message.text) 142 | .map((fixture) => bot.handleUpdate(fixture)) 143 | ) 144 | .then(() => t.deepEqual(map.size, 0)) 145 | .then(() => resolve(true)) 146 | }) 147 | )) 148 | 149 | test('must write session back if session was touched after defaultSession is passed', (t) => 150 | t.notThrowsAsync( 151 | new Promise((resolve) => { 152 | /** @type {import('..').Telegraf} */ 153 | const bot = createBot() 154 | 155 | const { store, map } = AsyncStore() 156 | bot.use(session({ store, defaultSession: () => ({ count: 0 }) })) 157 | 158 | bot.on('message', async (ctx) => { 159 | ctx.session.count++ 160 | // pretend we make an API call, etc 161 | await randSleep(200) 162 | }) 163 | 164 | return Promise.all( 165 | Array 166 | // create 100 text message updates and fire them all at once 167 | .from({ length: 100 }, Fixtures.message.text) 168 | .map((fixture) => bot.handleUpdate(fixture)) 169 | ).then(() => { 170 | const entries = [...map.entries()] 171 | t.deepEqual(entries.length, 1) 172 | const [key, value] = entries[0] 173 | t.deepEqual(key, '1:1') 174 | t.deepEqual(value, { count: 100 }) 175 | resolve(true) 176 | }) 177 | }) 178 | )) 179 | 180 | test('multiple sessions can be used independently without conflict', (t) => 181 | t.notThrowsAsync( 182 | new Promise((resolve) => { 183 | /** @type {import('..').Telegraf} */ 184 | const bot = createBot() 185 | 186 | const { store, map } = SyncStore() 187 | 188 | // first session, ctx.session 189 | bot.use( 190 | session({ 191 | store, 192 | defaultSession: () => ({ count: 0 }), 193 | }) 194 | ) 195 | 196 | const { store: chatStore, map: chatStoreMap } = SyncStore() 197 | 198 | // second session, ctx.chatSession 199 | bot.use( 200 | session({ 201 | property: 'chatSession', 202 | getSessionKey: (ctx) => ctx.chat && String(ctx.chat.id), 203 | store: chatStore, 204 | defaultSession: () => ({ chatCount: 0 }), 205 | }) 206 | ) 207 | 208 | bot.on('message', async (ctx) => { 209 | ctx.session.count++ 210 | ctx.chatSession.chatCount++ 211 | // pretend we make an API call, etc 212 | await randSleep(200) 213 | }) 214 | 215 | return Promise.all( 216 | Array 217 | // create 100 text message updates and fire them all at once 218 | .from({ length: 100 }, Fixtures.message.text) 219 | // get different chatIds 220 | .map((fixture, id) => ((fixture.message.chat.id = id), fixture)) 221 | .map((fixture) => bot.handleUpdate(fixture)) 222 | ) 223 | .then(() => { 224 | t.deepEqual(map.size, 100) 225 | for (let chatId = 0; chatId < 100; chatId++) 226 | t.deepEqual(map.get(`1:${chatId}`), { count: 1 }) 227 | }) 228 | .then(() => { 229 | t.deepEqual(chatStoreMap.size, 100) 230 | for (let chatId = 0; chatId < 100; chatId++) 231 | t.deepEqual(chatStoreMap.get(String(chatId)), { chatCount: 1 }) 232 | }) 233 | .then(() => resolve(true)) 234 | }) 235 | )) 236 | -------------------------------------------------------------------------------- /src/future.ts: -------------------------------------------------------------------------------- 1 | import { ReplyParameters } from '@telegraf/types' 2 | import Context from './context' 3 | import { Middleware } from './middleware' 4 | 5 | type ReplyContext = { [key in keyof Context & `reply${string}`]: Context[key] } 6 | 7 | function makeReply< 8 | C extends Context, 9 | E extends { reply_parameters?: ReplyParameters }, 10 | >(ctx: C, extra?: E) { 11 | if (ctx.msgId) 12 | return { 13 | // overrides in this order so user can override all properties 14 | reply_parameters: { 15 | message_id: ctx.msgId, 16 | ...extra?.reply_parameters, 17 | }, 18 | ...extra, 19 | } 20 | else return extra 21 | } 22 | 23 | const replyContext: ReplyContext = { 24 | replyWithChatAction: function () { 25 | throw new TypeError( 26 | 'ctx.replyWithChatAction has been removed, use ctx.sendChatAction instead' 27 | ) 28 | }, 29 | reply(this: Context, text, extra) { 30 | this.assert(this.chat, 'reply') 31 | return this.telegram.sendMessage(this.chat.id, text, makeReply(this, extra)) 32 | }, 33 | replyWithAnimation(this: Context, animation, extra) { 34 | this.assert(this.chat, 'replyWithAnimation') 35 | return this.telegram.sendAnimation( 36 | this.chat.id, 37 | animation, 38 | makeReply(this, extra) 39 | ) 40 | }, 41 | replyWithAudio(this: Context, audio, extra) { 42 | this.assert(this.chat, 'replyWithAudio') 43 | return this.telegram.sendAudio(this.chat.id, audio, makeReply(this, extra)) 44 | }, 45 | replyWithContact(this: Context, phoneNumber, firstName, extra) { 46 | this.assert(this.chat, 'replyWithContact') 47 | return this.telegram.sendContact( 48 | this.chat.id, 49 | phoneNumber, 50 | firstName, 51 | makeReply(this, extra) 52 | ) 53 | }, 54 | replyWithDice(this: Context, extra) { 55 | this.assert(this.chat, 'replyWithDice') 56 | return this.telegram.sendDice(this.chat.id, makeReply(this, extra)) 57 | }, 58 | replyWithDocument(this: Context, document, extra) { 59 | this.assert(this.chat, 'replyWithDocument') 60 | return this.telegram.sendDocument( 61 | this.chat.id, 62 | document, 63 | makeReply(this, extra) 64 | ) 65 | }, 66 | replyWithGame(this: Context, gameName, extra) { 67 | this.assert(this.chat, 'replyWithGame') 68 | return this.telegram.sendGame( 69 | this.chat.id, 70 | gameName, 71 | makeReply(this, extra) 72 | ) 73 | }, 74 | replyWithHTML(this: Context, html, extra) { 75 | this.assert(this.chat, 'replyWithHTML') 76 | return this.telegram.sendMessage(this.chat.id, html, { 77 | parse_mode: 'HTML', 78 | ...makeReply(this, extra), 79 | }) 80 | }, 81 | replyWithInvoice(this: Context, invoice, extra) { 82 | this.assert(this.chat, 'replyWithInvoice') 83 | return this.telegram.sendInvoice( 84 | this.chat.id, 85 | invoice, 86 | makeReply(this, extra) 87 | ) 88 | }, 89 | replyWithLocation(this: Context, latitude, longitude, extra) { 90 | this.assert(this.chat, 'replyWithLocation') 91 | return this.telegram.sendLocation( 92 | this.chat.id, 93 | latitude, 94 | longitude, 95 | makeReply(this, extra) 96 | ) 97 | }, 98 | replyWithMarkdown(this: Context, markdown, extra) { 99 | this.assert(this.chat, 'replyWithMarkdown') 100 | return this.telegram.sendMessage(this.chat.id, markdown, { 101 | parse_mode: 'Markdown', 102 | ...makeReply(this, extra), 103 | }) 104 | }, 105 | replyWithMarkdownV2(this: Context, markdown, extra) { 106 | this.assert(this.chat, 'replyWithMarkdownV2') 107 | return this.telegram.sendMessage(this.chat.id, markdown, { 108 | parse_mode: 'MarkdownV2', 109 | ...makeReply(this, extra), 110 | }) 111 | }, 112 | replyWithMediaGroup(this: Context, media, extra) { 113 | this.assert(this.chat, 'replyWithMediaGroup') 114 | return this.telegram.sendMediaGroup( 115 | this.chat.id, 116 | media, 117 | makeReply(this, extra) 118 | ) 119 | }, 120 | replyWithPhoto(this: Context, photo, extra) { 121 | this.assert(this.chat, 'replyWithPhoto') 122 | return this.telegram.sendPhoto(this.chat.id, photo, makeReply(this, extra)) 123 | }, 124 | replyWithPoll(this: Context, question, options, extra) { 125 | this.assert(this.chat, 'replyWithPoll') 126 | return this.telegram.sendPoll( 127 | this.chat.id, 128 | question, 129 | options, 130 | makeReply(this, extra) 131 | ) 132 | }, 133 | replyWithQuiz(this: Context, question, options, extra) { 134 | this.assert(this.chat, 'replyWithQuiz') 135 | return this.telegram.sendQuiz( 136 | this.chat.id, 137 | question, 138 | options, 139 | makeReply(this, extra) 140 | ) 141 | }, 142 | replyWithSticker(this: Context, sticker, extra) { 143 | this.assert(this.chat, 'replyWithSticker') 144 | return this.telegram.sendSticker( 145 | this.chat.id, 146 | sticker, 147 | makeReply(this, extra) 148 | ) 149 | }, 150 | replyWithVenue(this: Context, latitude, longitude, title, address, extra) { 151 | this.assert(this.chat, 'replyWithVenue') 152 | return this.telegram.sendVenue( 153 | this.chat.id, 154 | latitude, 155 | longitude, 156 | title, 157 | address, 158 | makeReply(this, extra) 159 | ) 160 | }, 161 | replyWithVideo(this: Context, video, extra) { 162 | this.assert(this.chat, 'replyWithVideo') 163 | return this.telegram.sendVideo(this.chat.id, video, makeReply(this, extra)) 164 | }, 165 | replyWithVideoNote(this: Context, videoNote, extra) { 166 | this.assert(this.chat, 'replyWithVideoNote') 167 | return this.telegram.sendVideoNote( 168 | this.chat.id, 169 | videoNote, 170 | makeReply(this, extra) 171 | ) 172 | }, 173 | replyWithVoice(this: Context, voice, extra) { 174 | this.assert(this.chat, 'replyWithVoice') 175 | return this.telegram.sendVoice(this.chat.id, voice, makeReply(this, extra)) 176 | }, 177 | } 178 | 179 | /** 180 | * Sets up Context to use the new reply methods. 181 | * This middleware makes `ctx.reply()` and `ctx.replyWith*()` methods will actually reply to the message they are replying to. 182 | * Use `ctx.sendMessage()` to send a message in chat without replying to it. 183 | * 184 | * If the message to reply is deleted, `reply()` will send a normal message. 185 | * If the update is not a message and we are unable to reply, `reply()` will send a normal message. 186 | */ 187 | export function useNewReplies(): Middleware { 188 | return (ctx, next) => { 189 | ctx.reply = replyContext.reply 190 | ctx.replyWithPhoto = replyContext.replyWithPhoto 191 | ctx.replyWithMediaGroup = replyContext.replyWithMediaGroup 192 | ctx.replyWithAudio = replyContext.replyWithAudio 193 | ctx.replyWithDice = replyContext.replyWithDice 194 | ctx.replyWithDocument = replyContext.replyWithDocument 195 | ctx.replyWithSticker = replyContext.replyWithSticker 196 | ctx.replyWithVideo = replyContext.replyWithVideo 197 | ctx.replyWithAnimation = replyContext.replyWithAnimation 198 | ctx.replyWithVideoNote = replyContext.replyWithVideoNote 199 | ctx.replyWithInvoice = replyContext.replyWithInvoice 200 | ctx.replyWithGame = replyContext.replyWithGame 201 | ctx.replyWithVoice = replyContext.replyWithVoice 202 | ctx.replyWithPoll = replyContext.replyWithPoll 203 | ctx.replyWithQuiz = replyContext.replyWithQuiz 204 | ctx.replyWithChatAction = replyContext.replyWithChatAction 205 | ctx.replyWithLocation = replyContext.replyWithLocation 206 | ctx.replyWithVenue = replyContext.replyWithVenue 207 | ctx.replyWithContact = replyContext.replyWithContact 208 | ctx.replyWithMarkdown = replyContext.replyWithMarkdown 209 | ctx.replyWithMarkdownV2 = replyContext.replyWithMarkdownV2 210 | ctx.replyWithHTML = replyContext.replyWithHTML 211 | return next() 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/session.ts: -------------------------------------------------------------------------------- 1 | import { Context } from './context' 2 | import { ExclusiveKeys, MaybePromise } from './core/helpers/util' 3 | import { MiddlewareFn } from './middleware' 4 | import d from 'debug' 5 | const debug = d('telegraf:session') 6 | 7 | export interface SyncSessionStore { 8 | get: (name: string) => T | undefined 9 | set: (name: string, value: T) => void 10 | delete: (name: string) => void 11 | } 12 | 13 | export interface AsyncSessionStore { 14 | get: (name: string) => Promise 15 | set: (name: string, value: T) => Promise 16 | delete: (name: string) => Promise 17 | } 18 | 19 | export type SessionStore = SyncSessionStore | AsyncSessionStore 20 | 21 | interface SessionOptions { 22 | /** Customise the session prop. Defaults to "session" and is available as ctx.session. */ 23 | property?: P 24 | getSessionKey?: (ctx: C) => MaybePromise 25 | store?: SessionStore 26 | defaultSession?: (ctx: C) => S 27 | } 28 | 29 | /** @deprecated session can use custom properties now. Construct this type directly. */ 30 | export interface SessionContext extends Context { 31 | session?: S 32 | } 33 | 34 | /** 35 | * Returns middleware that adds `ctx.session` for storing arbitrary state per session key. 36 | * 37 | * The default `getSessionKey` is `${ctx.from.id}:${ctx.chat.id}`. 38 | * If either `ctx.from` or `ctx.chat` is `undefined`, default session key and thus `ctx.session` are also `undefined`. 39 | * 40 | * > ⚠️ Session data is kept only in memory by default, which means that all data will be lost when the process is terminated. 41 | * > 42 | * > If you want to persist data across process restarts, or share it among multiple instances, you should use 43 | * [@telegraf/session](https://www.npmjs.com/package/@telegraf/session), or pass custom `storage`. 44 | * 45 | * @see {@link https://github.com/feathers-studio/telegraf-docs/blob/b694bcc36b4f71fb1cd650a345c2009ab4d2a2a5/guide/session.md Telegraf Docs | Session} 46 | * @see {@link https://github.com/feathers-studio/telegraf-docs/blob/master/examples/session-bot.ts Example} 47 | */ 48 | export function session< 49 | S extends NonNullable, 50 | C extends Context & { [key in P]?: C[P] }, 51 | P extends (ExclusiveKeys & string) | 'session' = 'session', 52 | // ^ Only allow prop names that aren't keys in base Context. 53 | // At type level, this is cosmetic. To not get cluttered with all Context keys. 54 | >(options?: SessionOptions): MiddlewareFn { 55 | const prop = options?.property ?? ('session' as P) 56 | const getSessionKey = options?.getSessionKey ?? defaultGetSessionKey 57 | const store = options?.store ?? new MemorySessionStore() 58 | // caches value from store in-memory while simultaneous updates share it 59 | // when counter reaches 0, the cached ref will be freed from memory 60 | const cache = new Map() 61 | // temporarily stores concurrent requests 62 | const concurrents = new Map>() 63 | 64 | // this function must be handled with care 65 | // read full description on the original PR: https://github.com/telegraf/telegraf/pull/1713 66 | // make sure to update the tests in test/session.js if you make any changes or fix bugs here 67 | return async (ctx, next) => { 68 | const updId = ctx.update.update_id 69 | 70 | let released = false 71 | 72 | function releaseChecks() { 73 | if (released && process.env.EXPERIMENTAL_SESSION_CHECKS) 74 | throw new Error( 75 | "Session was accessed or assigned to after the middleware chain exhausted. This is a bug in your code. You're probably accessing session asynchronously and missing awaits." 76 | ) 77 | } 78 | 79 | // because this is async, requests may still race here, but it will get autocorrected at (1) 80 | // v5 getSessionKey should probably be synchronous to avoid that 81 | const key = await getSessionKey(ctx) 82 | if (!key) { 83 | // Leaving this here could be useful to check for `prop in ctx` in future middleware 84 | ctx[prop] = undefined as unknown as S 85 | return await next() 86 | } 87 | 88 | let cached = cache.get(key) 89 | if (cached) { 90 | debug(`(${updId}) found cached session, reusing from cache`) 91 | ++cached.counter 92 | } else { 93 | debug(`(${updId}) did not find cached session`) 94 | // if another concurrent request has already sent a store request, fetch that instead 95 | let promise = concurrents.get(key) 96 | if (promise) 97 | debug(`(${updId}) found a concurrent request, reusing promise`) 98 | else { 99 | debug(`(${updId}) fetching from upstream store`) 100 | promise = store.get(key) 101 | } 102 | // synchronously store promise so concurrent requests can share response 103 | concurrents.set(key, promise) 104 | const upstream = await promise 105 | // all concurrent awaits will have promise in their closure, safe to remove now 106 | concurrents.delete(key) 107 | debug(`(${updId}) updating cache`) 108 | // another request may have beaten us to the punch 109 | const c = cache.get(key) 110 | if (c) { 111 | // another request did beat us to the punch 112 | c.counter++ 113 | // (1) preserve cached reference; in-memory reference is always newer than from store 114 | cached = c 115 | } else { 116 | // we're the first, so we must cache the reference 117 | cached = { ref: upstream ?? options?.defaultSession?.(ctx), counter: 1 } 118 | cache.set(key, cached) 119 | } 120 | } 121 | 122 | // TS already knows cached is always defined by this point, but does not guard cached. 123 | // It will, however, guard `c` here. 124 | const c = cached 125 | 126 | let touched = false 127 | 128 | Object.defineProperty(ctx, prop, { 129 | get() { 130 | releaseChecks() 131 | touched = true 132 | return c.ref 133 | }, 134 | set(value: S) { 135 | releaseChecks() 136 | touched = true 137 | c.ref = value 138 | }, 139 | }) 140 | 141 | try { 142 | await next() 143 | released = true 144 | } finally { 145 | if (--c.counter === 0) { 146 | // decrement to avoid memory leak 147 | debug(`(${updId}) refcounter reached 0, removing cached`) 148 | cache.delete(key) 149 | } 150 | debug(`(${updId}) middlewares completed, checking session`) 151 | 152 | // only update store if ctx.session was touched 153 | if (touched) 154 | if (c.ref == null) { 155 | debug(`(${updId}) ctx.${prop} missing, removing from store`) 156 | await store.delete(key) 157 | } else { 158 | debug(`(${updId}) ctx.${prop} found, updating store`) 159 | await store.set(key, c.ref) 160 | } 161 | } 162 | } 163 | } 164 | 165 | function defaultGetSessionKey(ctx: Context): string | undefined { 166 | const fromId = ctx.from?.id 167 | const chatId = ctx.chat?.id 168 | if (fromId == null || chatId == null) return undefined 169 | return `${fromId}:${chatId}` 170 | } 171 | 172 | /** @deprecated Use `Map` */ 173 | export class MemorySessionStore implements SyncSessionStore { 174 | private readonly store = new Map() 175 | 176 | constructor(private readonly ttl = Infinity) {} 177 | 178 | get(name: string): T | undefined { 179 | const entry = this.store.get(name) 180 | if (entry == null) { 181 | return undefined 182 | } else if (entry.expires < Date.now()) { 183 | this.delete(name) 184 | return undefined 185 | } 186 | return entry.session 187 | } 188 | 189 | set(name: string, value: T): void { 190 | const now = Date.now() 191 | this.store.set(name, { session: value, expires: now + this.ttl }) 192 | } 193 | 194 | delete(name: string): void { 195 | this.store.delete(name) 196 | } 197 | } 198 | 199 | /** @deprecated session can use custom properties now. Directly use `'session' in ctx` instead */ 200 | export function isSessionContext( 201 | ctx: Context 202 | ): ctx is SessionContext { 203 | return 'session' in ctx 204 | } 205 | -------------------------------------------------------------------------------- /test/telegraf.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const { Telegraf, session } = require('../') 3 | 4 | function createBot(...args) { 5 | const bot = new Telegraf(...args) 6 | bot.botInfo = { id: 42, is_bot: true, username: 'bot', first_name: 'Bot' } 7 | return bot 8 | } 9 | 10 | const BaseTextMessage = { 11 | chat: { id: 1 }, 12 | text: 'foo', 13 | } 14 | 15 | test('should provide chat and sender info', (t) => 16 | t.notThrowsAsync( 17 | new Promise((resolve) => { 18 | const bot = createBot() 19 | bot.on(['text', 'message'], (ctx) => { 20 | t.is(ctx.from.id, 42) 21 | t.is(ctx.chat.id, 1) 22 | resolve() 23 | }) 24 | bot.handleUpdate({ message: { ...BaseTextMessage, from: { id: 42 } } }) 25 | }) 26 | )) 27 | 28 | test('should share state', (t) => 29 | t.notThrowsAsync( 30 | new Promise((resolve) => { 31 | const bot = createBot() 32 | bot.on( 33 | 'message', 34 | (ctx, next) => { 35 | ctx.state.answer = 41 36 | return next() 37 | }, 38 | (ctx, next) => { 39 | ctx.state.answer++ 40 | return next() 41 | }, 42 | (ctx) => { 43 | t.is(ctx.state.answer, 42) 44 | resolve() 45 | } 46 | ) 47 | bot.handleUpdate({ message: BaseTextMessage }) 48 | }) 49 | )) 50 | 51 | test('should store session state', (t) => { 52 | const bot = createBot() 53 | bot.use(session()) 54 | bot.hears('calc', (ctx) => { 55 | t.true('session' in ctx) 56 | t.true('counter' in ctx.session) 57 | t.is(ctx.session.counter, 2) 58 | }) 59 | bot.on('message', (ctx) => { 60 | t.true('session' in ctx) 61 | if (ctx.session == null) ctx.session = { counter: 0 } 62 | ctx.session.counter++ 63 | }) 64 | return bot 65 | .handleUpdate({ 66 | message: { ...BaseTextMessage, from: { id: 42 }, chat: { id: 42 } }, 67 | }) 68 | .then(() => 69 | bot.handleUpdate({ 70 | message: { ...BaseTextMessage, from: { id: 42 }, chat: { id: 42 } }, 71 | }) 72 | ) 73 | .then(() => 74 | bot.handleUpdate({ 75 | message: { ...BaseTextMessage, from: { id: 100500 }, chat: { id: 42 } }, 76 | }) 77 | ) 78 | .then(() => 79 | bot.handleUpdate({ 80 | message: { 81 | ...BaseTextMessage, 82 | from: { id: 42 }, 83 | chat: { id: 42 }, 84 | text: 'calc', 85 | }, 86 | }) 87 | ) 88 | }) 89 | 90 | test('should store session state with custom store', (t) => { 91 | const bot = createBot() 92 | const dummyStore = {} 93 | bot.use( 94 | session({ 95 | store: { 96 | get: (key) => 97 | new Promise((resolve) => setTimeout(resolve, 25, dummyStore[key])), 98 | set: (key, value) => 99 | new Promise((resolve) => setTimeout(resolve, 25)).then( 100 | () => (dummyStore[key] = value) 101 | ), 102 | }, 103 | }) 104 | ) 105 | bot.hears('calc', (ctx) => { 106 | t.true('session' in ctx) 107 | t.true('counter' in ctx.session) 108 | t.is(dummyStore['42:42'].counter, 2) 109 | }) 110 | bot.on('message', (ctx) => { 111 | t.true('session' in ctx) 112 | if (ctx.session == null) { 113 | ctx.session = { counter: 0 } 114 | } 115 | ctx.session.counter++ 116 | }) 117 | return bot 118 | .handleUpdate({ 119 | message: { ...BaseTextMessage, from: { id: 42 }, chat: { id: 42 } }, 120 | }) 121 | .then(() => 122 | bot.handleUpdate({ 123 | message: { ...BaseTextMessage, from: { id: 42 }, chat: { id: 42 } }, 124 | }) 125 | ) 126 | .then(() => 127 | bot.handleUpdate({ 128 | message: { ...BaseTextMessage, from: { id: 100500 }, chat: { id: 42 } }, 129 | }) 130 | ) 131 | .then(() => 132 | bot.handleUpdate({ 133 | message: { 134 | ...BaseTextMessage, 135 | from: { id: 42 }, 136 | chat: { id: 42 }, 137 | text: 'calc', 138 | }, 139 | }) 140 | ) 141 | }) 142 | 143 | test('should work with context extensions', (t) => 144 | t.notThrowsAsync( 145 | new Promise((resolve) => { 146 | const bot = createBot() 147 | bot.context.db = { 148 | getUser: () => undefined, 149 | } 150 | bot.on('message', (ctx) => { 151 | t.true('db' in ctx) 152 | t.true('getUser' in ctx.db) 153 | resolve() 154 | }) 155 | bot.handleUpdate({ message: BaseTextMessage }) 156 | }) 157 | )) 158 | 159 | class MockResponse { 160 | constructor() { 161 | this.writableEnded = false 162 | } 163 | 164 | setHeader() {} 165 | end(body) { 166 | this.writableEnded = true 167 | this.body = body 168 | } 169 | } 170 | 171 | test('should handle webhook response', async (t) => { 172 | const bot = createBot() 173 | bot.on('message', async (ctx) => { 174 | ctx.telegram.webhookReply = true 175 | const result = await ctx.replyWithChatAction('typing') 176 | t.true(result) 177 | }) 178 | const res = new MockResponse() 179 | await bot.handleUpdate({ message: BaseTextMessage }, res) 180 | t.true(res.writableEnded) 181 | t.deepEqual(JSON.parse(res.body), { 182 | method: 'sendChatAction', 183 | chat_id: 1, 184 | action: 'typing', 185 | }) 186 | }) 187 | 188 | test('should respect webhookReply option', async (t) => { 189 | const bot = createBot(null, { telegram: { webhookReply: false } }) 190 | bot.catch((err) => { 191 | throw err 192 | }) // Disable log 193 | bot.on('message', async (ctx) => ctx.replyWithChatAction('typing')) 194 | const res = new MockResponse() 195 | await t.throwsAsync(bot.handleUpdate({ message: BaseTextMessage }, res)) 196 | t.true(res.writableEnded) 197 | t.is(res.body, undefined) 198 | }) 199 | 200 | test('should respect webhookReply runtime change', async (t) => { 201 | const bot = createBot() 202 | bot.webhookReply = false 203 | bot.catch((err) => { 204 | throw err 205 | }) // Disable log 206 | bot.on('message', async (ctx) => ctx.replyWithChatAction('typing')) 207 | 208 | const res = new MockResponse() 209 | // Throws cause Bot Token is required for http call' 210 | await t.throwsAsync(bot.handleUpdate({ message: BaseTextMessage }, res)) 211 | t.true(res.writableEnded) 212 | t.is(res.body, undefined) 213 | }) 214 | 215 | test('should respect webhookReply runtime change (per request)', async (t) => { 216 | const bot = createBot() 217 | bot.catch((err) => { 218 | throw err 219 | }) // Disable log 220 | bot.on('message', async (ctx) => { 221 | ctx.webhookReply = false 222 | return ctx.replyWithChatAction('typing') 223 | }) 224 | const res = new MockResponse() 225 | await t.throwsAsync(bot.handleUpdate({ message: BaseTextMessage }, res)) 226 | t.true(res.writableEnded) 227 | t.is(res.body, undefined) 228 | }) 229 | 230 | test('should deterministically generate `secretPathComponent`', (t) => { 231 | const foo = createBot('foo') 232 | const bar = createBot('bar') 233 | t.deepEqual(foo.secretPathComponent(), foo.secretPathComponent()) 234 | t.deepEqual(bar.secretPathComponent(), bar.secretPathComponent()) 235 | t.notDeepEqual(foo.secretPathComponent(), bar.secretPathComponent()) 236 | }) 237 | 238 | test('ctx.entities() should return entities from message', (t) => { 239 | const bot = createBot() 240 | bot.on('message', (ctx) => { 241 | t.deepEqual(ctx.entities(), [ 242 | { type: 'bot_command', offset: 0, length: 6, fragment: '/start' }, 243 | { type: 'code', offset: 7, length: 4, fragment: 'test' }, 244 | ]) 245 | }) 246 | return bot.handleUpdate({ 247 | message: { 248 | chat: { id: 1 }, 249 | text: '/start test', 250 | entities: [ 251 | { type: 'bot_command', offset: 0, length: 6 }, 252 | { type: 'code', offset: 7, length: 4 }, 253 | ], 254 | }, 255 | }) 256 | }) 257 | 258 | test('ctx.entities() should return only requested entities', (t) => { 259 | const bot = createBot() 260 | bot.on('message', (ctx) => { 261 | t.deepEqual(ctx.entities('bold', 'code'), [ 262 | { type: 'bold', offset: 7, length: 4, fragment: 'bold' }, 263 | { type: 'code', offset: 12, length: 4, fragment: 'code' }, 264 | ]) 265 | }) 266 | return bot.handleUpdate({ 267 | message: { 268 | chat: { id: 1 }, 269 | text: '/start bold code', 270 | entities: [ 271 | { type: 'bot_command', offset: 0, length: 6 }, 272 | { type: 'bold', offset: 7, length: 4 }, 273 | { type: 'code', offset: 12, length: 4 }, 274 | ], 275 | }, 276 | }) 277 | }) 278 | -------------------------------------------------------------------------------- /release-notes/4.16.0.md: -------------------------------------------------------------------------------- 1 | # 4.16.0 2 | 3 | > _\*tsk tsk\*_ There's a big announcement at the end of this release! 4 | 5 | ## API Updates 6 | 7 | - Support for [API 7.0](https://core.telegram.org/bots/api-changelog#december-29-2023). Highlights are Reactions, Replies 2.0, Link Previews, Blockquotes, and Chat Boosts. 8 | - Support for [API 7.1](https://core.telegram.org/bots/api-changelog#february-16-2024). 9 | - All methods and update types from these API versions are now fully supported. 10 | 11 | ## Format helpers 12 | 13 | - Added `blockquote` format helper. 14 | - Stricter types for format helpers. For example, it will now be a type-error to try to nest `pre` within another entity. 15 | 16 | ## Working with Reactions 17 | 18 | - To listen on reaction addition and removal, use `Composer.reaction`: 19 | 20 | ```TS 21 | bot.reaction("👍", (ctx) => { 22 | // user added a 👍 reaction 23 | }); 24 | 25 | // prefix with - to listen to reaction removal 26 | bot.reaction("-👍", (ctx) => { 27 | // user removed a 👍 reaction 28 | }); 29 | ``` 30 | 31 | This also just works with custom emoji IDs. 32 | 33 | ```TS 34 | bot.reaction("5368742036629364794", (ctx) => { 35 | // user added a reaction with the given custom emoji ID 36 | }); 37 | 38 | bot.reaction("-5368742036629364794", (ctx) => { 39 | // user removed a reaction with the given custom emoji ID 40 | }); 41 | ``` 42 | 43 | - You can probe and inspect the reaction list with the `ctx.reactions` smart object: 44 | 45 | ```TS 46 | bot.on("message_reaction", async (ctx) => { 47 | // remember that ctx.reactions is a smart object, but not an array 48 | 49 | // message has a 👍 reaction 50 | ctx.reactions.has("👍"); 51 | 52 | // message has a reaction with the given custom emoji ID 53 | ctx.reactions.has("5368742036629364794"); 54 | 55 | // number of reactions from this user on the message 56 | ctx.reaction.count; 57 | 58 | // indexed access is allowed: 59 | const first = ctx.reactions[0]; 60 | 61 | // the 👍 emoji was added in this update 62 | if (ctx.reactions.added.has("👍")) { 63 | // user added a 👍 reaction 64 | await User.updateOne({ id: ctx.from.id }, { $inc: { likes: 1 } }); 65 | } 66 | 67 | // the 👍 emoji was removed in this update 68 | if (ctx.reactions.removed.has("👍")) { 69 | // user removed a 👍 reaction 70 | await User.updateOne({ id: ctx.from.id }, { $inc: { likes: -1 } }); 71 | }; 72 | 73 | // to copy any of these smart objects into an array, call the `toArray` method 74 | const reactions = ctx.reactions.toArray(); 75 | const added = ctx.reactions.added.toArray(); 76 | const removed = ctx.reactions.removed.toArray(); 77 | }); 78 | ``` 79 | 80 | - To react to a message, use `ctx.react`: 81 | 82 | ```TS 83 | bot.on("text", async (ctx) => { 84 | await ctx.react("👍"); 85 | }); 86 | ``` 87 | 88 | You can also react to a message with a custom emoji ID: 89 | 90 | ```TS 91 | bot.on("text", async (ctx) => { 92 | await ctx.react("5368742036629364794"); 93 | }); 94 | ``` 95 | 96 | The `bot.telegram.setMessageReaction` method is also available. 97 | 98 | ## `Context::text` and `Context::entities` helpers 99 | 100 | - Added the `ctx.entities` method to fetch entities in any message. 101 | 102 | > There's a bonus if you keep reading! 103 | 104 | ```TS 105 | bot.on("text", (ctx) => { 106 | // fetch all entities 107 | const entities = ctx.entities(); 108 | // fetch all command entities 109 | const commandEntities = ctx.entities("bot_command"); 110 | // fetch all mentions and text mentions 111 | const mentions = ctx.entities("mention", "text_mention"); 112 | }); 113 | ``` 114 | 115 | Not only does this method fetch entities from any message, but also works with captions, game text, and poll explanations. In short, if an update has any text and entities, this method will find them. 116 | 117 | You might now wonder how to get the text from the update. The `ctx.text` property is here to help: 118 | 119 | ```TS 120 | bot.on(message("text"), (ctx) => { 121 | // fetch the text from the update 122 | const text = ctx.text; 123 | }); 124 | 125 | bot.on(message("photo"), (ctx) => { 126 | // fetch the caption from the photo 127 | const caption = ctx.text; 128 | }); 129 | ``` 130 | 131 | - 🎁 Bonus! Every entity in the `ctx.entities()` array will have a fragment of the text they represent! 132 | 133 | ```TS 134 | bot.on("text", (ctx) => { 135 | const entities = ctx.entities("bold"); 136 | for (const entity of entities) { 137 | // the text that is bold 138 | const boldText = entity.fragment; 139 | } 140 | }); 141 | ``` 142 | 143 | ## Message and message ID shorthands 144 | 145 | - `Context::msg` shorthand to get any message in the update. 146 | 147 | ```TS 148 | bot.use((ctx) => { 149 | // finds one of: 150 | // ctx.message ?? ctx.editedMessage ?? ctx.callbackQuery?.message ?? ctx.channelPost ?? ctx.editedChannelPost 151 | const msg = ctx.msg; 152 | }); 153 | ``` 154 | 155 | `ctx.msg` is decorated with the `isAccessible` and `has` methods. The `has` method works similarly to the `message()` filter in `bot.on`. It checks if the message has a certain property. 156 | 157 | ```TS 158 | if (ctx.msg.isAccessible()) { 159 | // msg is accessible, not deleted or otherwise unavailable 160 | // this is a type-guard based on the runtime check for msg.date === 0 161 | } 162 | 163 | if (ctx.msg.has("text")) { 164 | // ctx.msg.text exists 165 | } 166 | ``` 167 | 168 | - `Context::msgId` shorthand to get any available message ID in the update. This also includes `message_id` present in updates that do not contain a message, such as `message_reaction`, and `message_reaction_count`. 169 | 170 | ```TS 171 | bot.use((ctx) => { 172 | // finds one of: 173 | // ctx.msg.message_id ?? ctx.messageReaction.message_id ?? ctx.messageReactionCount.message_id 174 | const msgId = ctx.msgId; 175 | }); 176 | ``` 177 | 178 | ## bot.launch takes an onLaunch callback 179 | 180 | - `bot.launch` now takes an optional callback that is called when the bot is launched. 181 | 182 | ```TS 183 | bot.launch(() => console.log("Bot is starting!")); 184 | ``` 185 | 186 | If you pass LaunchOptions, the callback goes after the options. 187 | 188 | ```TS 189 | bot.launch({ dropPendingUpdates: true }, () => console.log("Bot is starting!")); 190 | ``` 191 | 192 | This is useful for running some code when the bot is launched, such as logging to the console, or sending a message to a channel. Remember that errors thrown in this callback will not be caught by the bot's error handler. You must handle them yourself. 193 | 194 | > It's worth noting that the callback is called once the first `getMe` call is successful. This means network is working, and bot token is valid. Due to how networks work, there isn't a way to define when the bot is "fully" launched. The bot may still crash after the callback is called, for example if another instance of the bot is running elsewhere and polling `getUpdates` fails. For these reasons, onLaunch callback exists under `@experimental`, and may receive improvements based on feedback. 195 | 196 | ## Other fixes and improvements 197 | 198 | - Added `ctx.match` for `Composer.command` (#1938). 199 | - Fixed thumbnail uploads in media groups (#1947). 200 | - The shorthand `ctx.chat` now includes `chat` from `this.messageReaction ?? this.messageReactionCount ?? this.removedChatBoost`. 201 | - The shorthand `ctx.from` now includes the field `user` from `ctx.messageReaction ?? ctx.pollAnswer ?? ctx.chatBoost?.boost.source`, in addition to fetching `from` in other updates. 202 | - `useNewReplies` uses `ctx.msgId` instead of `ctx.message.message_id` to reply to messages, which works for more update types than before. 203 | - The following modules are now directly importable: `types`, `scenes`, `filters`, `format`, `future`, `utils`, `markup`, `session`. For example, via `import { WizardScene } from "telegraf/scenes"`. This was previously available in v3, and was removed in v4. 204 | 205 | ## Minor breaking changes 206 | 207 | - (Breaking) `Markup.button.userRequest` will take `extra` instead of `user_is_premium` as the third parameter. 208 | - (Breaking) `Markup.button.botRequest` will take `extra` before `hide` as the third parameter. 209 | - (Types breaking) `reply_to_message_id` and `allow_sending_without_reply` replaced by `reply_parameters`. 210 | - (Types breaking) `disable_web_page_preview` and `link_preview_options` replaced by `link_preview_options`. 211 | 212 | # 🎉 Big announcement 213 | 214 | ## Telegraf v4 - Last Major Update 215 | 216 | This will be the last major update for Telegraf v4. 217 | 218 | ### What to Expect 219 | 220 | If you are currently using Telegraf v4, you can continue using it as you have been. Telegraf v4 will be supported until February 2025, with the following commitments: 221 | 222 | - Bug fixes and security updates will still be released for Telegraf v4. 223 | - The existing documentation for Telegraf v4 will remain available. 224 | - New API updates will only focus on ensuring compatibility with the latest Telegram Bot API. No new convenience features will be added to Telegraf v4. 225 | 226 | ### Introducing Telegraf v5 227 | 228 | In the coming weeks, we plan to release Telegraf v5. Telegraf v5 will bring a revamped API, new functionalities, numerous convenient helpers, an improved approach to handling updates, and enhanced documentation. 229 | 230 | One of the key highlights of Telegraf v5 is its platform-agnostic nature, allowing you to run it on any JavaScript runtime environment, including Deno, Bun, edge runtimes such as Cloudflare Workers and Vercel, browsers, and more. 231 | 232 | ### Smooth Transition to v5 233 | 234 | If you have closely followed the development of v4 in the past year and stayed updated with deprecation notices, the transition to v5 will be seamless for you. For those still using v4, we will provide a migration guide to assist you in upgrading to v5. Stay tuned for more information on the release of v5! 235 | 236 | #### Thanks for all the love ❤️! Go follow the releases channel on Telegram: [t.me/Telegraf_JS](https://t.me/Telegraf_JS). 237 | -------------------------------------------------------------------------------- /src/telegraf.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto' 2 | import * as http from 'http' 3 | import * as https from 'https' 4 | import * as tg from './core/types/typegram' 5 | import * as tt from './telegram-types' 6 | import { Composer } from './composer' 7 | import { MaybePromise } from './core/helpers/util' 8 | import ApiClient from './core/network/client' 9 | import { compactOptions } from './core/helpers/compact' 10 | import Context from './context' 11 | import d from 'debug' 12 | import generateCallback from './core/network/webhook' 13 | import { Polling } from './core/network/polling' 14 | import pTimeout from 'p-timeout' 15 | import Telegram from './telegram' 16 | import { TlsOptions } from 'tls' 17 | import { URL } from 'url' 18 | import safeCompare = require('safe-compare') 19 | const debug = d('telegraf:main') 20 | 21 | const DEFAULT_OPTIONS: Telegraf.Options = { 22 | telegram: {}, 23 | handlerTimeout: 90_000, // 90s in ms 24 | contextType: Context, 25 | } 26 | 27 | function always(x: T) { 28 | return () => x 29 | } 30 | 31 | const anoop = always(Promise.resolve()) 32 | 33 | export namespace Telegraf { 34 | export interface Options { 35 | contextType: new ( 36 | ...args: ConstructorParameters 37 | ) => TContext 38 | handlerTimeout: number 39 | telegram?: Partial 40 | } 41 | 42 | export interface LaunchOptions { 43 | dropPendingUpdates?: boolean 44 | /** List the types of updates you want your bot to receive */ 45 | allowedUpdates?: tt.UpdateType[] 46 | /** Configuration options for when the bot is run via webhooks */ 47 | webhook?: { 48 | /** Public domain for webhook. */ 49 | domain: string 50 | 51 | /** 52 | * Webhook url path; will be automatically generated if not specified 53 | * @deprecated Pass `path` instead 54 | * */ 55 | hookPath?: string 56 | 57 | /** Webhook url path; will be automatically generated if not specified */ 58 | path?: string 59 | 60 | host?: string 61 | port?: number 62 | 63 | /** The fixed IP address which will be used to send webhook requests instead of the IP address resolved through DNS */ 64 | ipAddress?: string 65 | 66 | /** 67 | * Maximum allowed number of simultaneous HTTPS connections to the webhook for update delivery, 1-100. Defaults to 40. 68 | * Use lower values to limit the load on your bot's server, and higher values to increase your bot's throughput. 69 | */ 70 | maxConnections?: number 71 | 72 | /** TLS server options. Omit to use http. */ 73 | tlsOptions?: TlsOptions 74 | 75 | /** 76 | * A secret token to be sent in a header `“X-Telegram-Bot-Api-Secret-Token”` in every webhook request. 77 | * 1-256 characters. Only characters `A-Z`, `a-z`, `0-9`, `_` and `-` are allowed. 78 | * The header is useful to ensure that the request comes from a webhook set by you. 79 | */ 80 | secretToken?: string 81 | 82 | /** 83 | * Upload your public key certificate so that the root certificate in use can be checked. 84 | * See [self-signed guide](https://core.telegram.org/bots/self-signed) for details. 85 | */ 86 | certificate?: tg.InputFile 87 | 88 | cb?: http.RequestListener 89 | } 90 | } 91 | } 92 | 93 | const TOKEN_HEADER = 'x-telegram-bot-api-secret-token' 94 | 95 | export class Telegraf extends Composer { 96 | private readonly options: Telegraf.Options 97 | private webhookServer?: http.Server | https.Server 98 | private polling?: Polling 99 | /** Set manually to avoid implicit `getMe` call in `launch` or `webhookCallback` */ 100 | public botInfo?: tg.UserFromGetMe 101 | public telegram: Telegram 102 | readonly context: Partial = {} 103 | 104 | /** Assign to this to customise the webhook filter middleware. 105 | * `{ path, secretToken }` will be bound to this rather than the Telegraf instance. 106 | * Remember to assign a regular function and not an arrow function so it's bindable. 107 | */ 108 | public webhookFilter = function ( 109 | // NOTE: this function is assigned to a variable instead of being a method to signify that it's assignable 110 | // NOTE: the `this` binding is so custom impls don't need to double wrap 111 | this: { 112 | /** @deprecated Use path instead */ 113 | hookPath: string 114 | path: string 115 | secretToken?: string 116 | }, 117 | req: http.IncomingMessage 118 | ) { 119 | const debug = d('telegraf:webhook') 120 | 121 | if (req.method === 'POST') { 122 | if (safeCompare(this.path, req.url as string)) { 123 | // no need to check if secret_token was not set 124 | if (!this.secretToken) return true 125 | else { 126 | const token = req.headers[TOKEN_HEADER] as string 127 | if (safeCompare(this.secretToken, token)) return true 128 | else debug('Secret token does not match:', token, this.secretToken) 129 | } 130 | } else debug('Path does not match:', req.url, this.path) 131 | } else debug('Unexpected request method, not POST. Received:', req.method) 132 | 133 | return false 134 | } 135 | 136 | private handleError = (err: unknown, ctx: C): MaybePromise => { 137 | // set exit code to emulate `warn-with-error-code` behavior of 138 | // https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode 139 | // to prevent a clean exit despite an error being thrown 140 | process.exitCode = 1 141 | console.error('Unhandled error while processing', ctx.update) 142 | throw err 143 | } 144 | 145 | constructor(token: string, options?: Partial>) { 146 | super() 147 | // @ts-expect-error Trust me, TS 148 | this.options = { 149 | ...DEFAULT_OPTIONS, 150 | ...compactOptions(options), 151 | } 152 | this.telegram = new Telegram(token, this.options.telegram) 153 | debug('Created a `Telegraf` instance') 154 | } 155 | 156 | private get token() { 157 | return this.telegram.token 158 | } 159 | 160 | /** @deprecated use `ctx.telegram.webhookReply` */ 161 | set webhookReply(webhookReply: boolean) { 162 | this.telegram.webhookReply = webhookReply 163 | } 164 | 165 | /** @deprecated use `ctx.telegram.webhookReply` */ 166 | get webhookReply() { 167 | return this.telegram.webhookReply 168 | } 169 | 170 | /** 171 | * _Override_ error handling 172 | */ 173 | catch(handler: (err: unknown, ctx: C) => MaybePromise) { 174 | this.handleError = handler 175 | return this 176 | } 177 | 178 | /** 179 | * You must call `bot.telegram.setWebhook` for this to work. 180 | * You should probably use {@link Telegraf.createWebhook} instead. 181 | */ 182 | webhookCallback(path = '/', opts: { secretToken?: string } = {}) { 183 | const { secretToken } = opts 184 | return generateCallback( 185 | this.webhookFilter.bind({ hookPath: path, path, secretToken }), 186 | (update: tg.Update, res: http.ServerResponse) => 187 | this.handleUpdate(update, res) 188 | ) 189 | } 190 | 191 | private getDomainOpts(opts: { domain: string; path?: string }) { 192 | const protocol = 193 | opts.domain.startsWith('https://') || opts.domain.startsWith('http://') 194 | 195 | if (protocol) 196 | debug( 197 | 'Unexpected protocol in domain, telegraf will use https:', 198 | opts.domain 199 | ) 200 | 201 | const domain = protocol ? new URL(opts.domain).host : opts.domain 202 | const path = opts.path ?? `/telegraf/${this.secretPathComponent()}` 203 | const url = `https://${domain}${path}` 204 | 205 | return { domain, path, url } 206 | } 207 | 208 | /** 209 | * Specify a url to receive incoming updates via webhook. 210 | * Returns an Express-style middleware you can pass to app.use() 211 | */ 212 | async createWebhook( 213 | opts: { domain: string; path?: string } & tt.ExtraSetWebhook 214 | ) { 215 | const { domain, path, ...extra } = opts 216 | 217 | const domainOpts = this.getDomainOpts({ domain, path }) 218 | 219 | await this.telegram.setWebhook(domainOpts.url, extra) 220 | debug(`Webhook set to ${domainOpts.url}`) 221 | 222 | return this.webhookCallback(domainOpts.path, { 223 | secretToken: extra.secret_token, 224 | }) 225 | } 226 | 227 | private startPolling(allowedUpdates: tt.UpdateType[] = []) { 228 | this.polling = new Polling(this.telegram, allowedUpdates) 229 | return this.polling.loop(async (update) => { 230 | await this.handleUpdate(update) 231 | }) 232 | } 233 | 234 | private startWebhook( 235 | path: string, 236 | tlsOptions?: TlsOptions, 237 | port?: number, 238 | host?: string, 239 | cb?: http.RequestListener, 240 | secretToken?: string 241 | ) { 242 | const webhookCb = this.webhookCallback(path, { secretToken }) 243 | const callback: http.RequestListener = 244 | typeof cb === 'function' 245 | ? (req, res) => webhookCb(req, res, () => cb(req, res)) 246 | : webhookCb 247 | this.webhookServer = 248 | tlsOptions != null 249 | ? https.createServer(tlsOptions, callback) 250 | : http.createServer(callback) 251 | this.webhookServer.listen(port, host, () => { 252 | debug('Webhook listening on port: %s', port) 253 | }) 254 | return this 255 | } 256 | 257 | secretPathComponent() { 258 | return crypto 259 | .createHash('sha3-256') 260 | .update(this.token) 261 | .update(process.version) // salt 262 | .digest('hex') 263 | } 264 | 265 | async launch(onLaunch?: () => void): Promise 266 | async launch( 267 | config: Telegraf.LaunchOptions, 268 | onLaunch?: () => void 269 | ): Promise 270 | /** 271 | * @see https://github.com/telegraf/telegraf/discussions/1344#discussioncomment-335700 272 | */ 273 | async launch( 274 | config: Telegraf.LaunchOptions | (() => void) = {}, 275 | /** @experimental */ 276 | onLaunch?: () => void 277 | ) { 278 | const [cfg, onMe] = 279 | typeof config === 'function' ? [{}, config] : [config, onLaunch] 280 | const drop_pending_updates = cfg.dropPendingUpdates 281 | const allowed_updates = cfg.allowedUpdates 282 | const webhook = cfg.webhook 283 | 284 | debug('Connecting to Telegram') 285 | this.botInfo ??= await this.telegram.getMe() 286 | onMe?.() 287 | debug(`Launching @${this.botInfo.username}`) 288 | 289 | if (webhook === undefined) { 290 | await this.telegram.deleteWebhook({ drop_pending_updates }) 291 | debug('Bot started with long polling') 292 | await this.startPolling(allowed_updates) 293 | return 294 | } 295 | 296 | const domainOpts = this.getDomainOpts({ 297 | domain: webhook.domain, 298 | path: webhook.path ?? webhook.hookPath, 299 | }) 300 | 301 | const { tlsOptions, port, host, cb, secretToken } = webhook 302 | 303 | this.startWebhook(domainOpts.path, tlsOptions, port, host, cb, secretToken) 304 | 305 | await this.telegram.setWebhook(domainOpts.url, { 306 | drop_pending_updates: drop_pending_updates, 307 | allowed_updates: allowed_updates, 308 | ip_address: webhook.ipAddress, 309 | max_connections: webhook.maxConnections, 310 | secret_token: webhook.secretToken, 311 | certificate: webhook.certificate, 312 | }) 313 | 314 | debug(`Bot started with webhook @ ${domainOpts.url}`) 315 | } 316 | 317 | stop(reason = 'unspecified') { 318 | debug('Stopping bot... Reason:', reason) 319 | // https://github.com/telegraf/telegraf/pull/1224#issuecomment-742693770 320 | if (this.polling === undefined && this.webhookServer === undefined) { 321 | throw new Error('Bot is not running!') 322 | } 323 | this.webhookServer?.close() 324 | this.polling?.stop() 325 | } 326 | 327 | private botInfoCall?: Promise 328 | async handleUpdate(update: tg.Update, webhookResponse?: http.ServerResponse) { 329 | this.botInfo ??= 330 | (debug( 331 | 'Update %d is waiting for `botInfo` to be initialized', 332 | update.update_id 333 | ), 334 | await (this.botInfoCall ??= this.telegram.getMe())) 335 | debug('Processing update', update.update_id) 336 | const tg = new Telegram(this.token, this.telegram.options, webhookResponse) 337 | const TelegrafContext = this.options.contextType 338 | const ctx = new TelegrafContext(update, tg, this.botInfo) 339 | Object.assign(ctx, this.context) 340 | try { 341 | await pTimeout( 342 | Promise.resolve(this.middleware()(ctx, anoop)), 343 | this.options.handlerTimeout 344 | ) 345 | } catch (err) { 346 | return await this.handleError(err, ctx) 347 | } finally { 348 | if (webhookResponse?.writableEnded === false) { 349 | webhookResponse.end() 350 | } 351 | debug('Finished processing update', update.update_id) 352 | } 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /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 { stat, realpath } from 'fs/promises' 5 | import * as http from 'http' 6 | import * as https from 'https' 7 | import * as path from 'path' 8 | import fetch, { RequestInit } from 'node-fetch' 9 | import { hasProp, hasPropType } from '../helpers/check' 10 | import { InputFile, Opts, Telegram } from '../types/typegram' 11 | import { AbortSignal } from 'abort-controller' 12 | import { compactOptions } from '../helpers/compact' 13 | import MultipartStream from './multipart-stream' 14 | import TelegramError from './error' 15 | import { URL } from 'url' 16 | // eslint-disable-next-line @typescript-eslint/no-var-requires 17 | const debug = require('debug')('telegraf:client') 18 | const { isStream } = MultipartStream 19 | 20 | const WEBHOOK_REPLY_METHOD_ALLOWLIST = new Set([ 21 | 'answerCallbackQuery', 22 | 'answerInlineQuery', 23 | 'deleteMessage', 24 | 'leaveChat', 25 | 'sendChatAction', 26 | ]) 27 | 28 | namespace ApiClient { 29 | export type Agent = http.Agent | ((parsedUrl: URL) => http.Agent) | undefined 30 | export interface Options { 31 | /** 32 | * Agent for communicating with the bot API. 33 | */ 34 | agent?: http.Agent 35 | /** 36 | * Agent for attaching files via URL. 37 | * 1. Not all agents support both `http:` and `https:`. 38 | * 2. When passing a function, create the agents once, outside of the function. 39 | * Creating new agent every request probably breaks `keepAlive`. 40 | */ 41 | attachmentAgent?: Agent 42 | apiRoot: string 43 | /** 44 | * @default 'bot' 45 | * @see https://github.com/tdlight-team/tdlight-telegram-bot-api#user-mode 46 | */ 47 | apiMode: 'bot' | 'user' 48 | webhookReply: boolean 49 | testEnv: boolean 50 | } 51 | 52 | export interface CallApiOptions { 53 | signal?: AbortSignal 54 | } 55 | } 56 | 57 | const DEFAULT_EXTENSIONS: Record = { 58 | audio: 'mp3', 59 | photo: 'jpg', 60 | sticker: 'webp', 61 | video: 'mp4', 62 | animation: 'mp4', 63 | video_note: 'mp4', 64 | voice: 'ogg', 65 | } 66 | 67 | const DEFAULT_OPTIONS: ApiClient.Options = { 68 | apiRoot: 'https://api.telegram.org', 69 | apiMode: 'bot', 70 | webhookReply: true, 71 | agent: new https.Agent({ 72 | keepAlive: true, 73 | keepAliveMsecs: 10000, 74 | }), 75 | attachmentAgent: undefined, 76 | testEnv: false, 77 | } 78 | 79 | function includesMedia(payload: Record) { 80 | return Object.entries(payload).some(([key, value]) => { 81 | if (key === 'link_preview_options') return false 82 | 83 | if (Array.isArray(value)) { 84 | return value.some( 85 | ({ media }) => 86 | media && typeof media === 'object' && (media.source || media.url) 87 | ) 88 | } 89 | return ( 90 | value && 91 | typeof value === 'object' && 92 | ((hasProp(value, 'source') && value.source) || 93 | (hasProp(value, 'url') && value.url) || 94 | (hasPropType(value, 'media', 'object') && 95 | ((hasProp(value.media, 'source') && value.media.source) || 96 | (hasProp(value.media, 'url') && value.media.url)))) 97 | ) 98 | }) 99 | } 100 | 101 | function replacer(_: unknown, value: unknown) { 102 | if (value == null) return undefined 103 | return value 104 | } 105 | 106 | function buildJSONConfig(payload: unknown): Promise { 107 | return Promise.resolve({ 108 | method: 'POST', 109 | compress: true, 110 | headers: { 'content-type': 'application/json', connection: 'keep-alive' }, 111 | body: JSON.stringify(payload, replacer), 112 | }) 113 | } 114 | 115 | const FORM_DATA_JSON_FIELDS = [ 116 | 'results', 117 | 'reply_markup', 118 | 'mask_position', 119 | 'shipping_options', 120 | 'errors', 121 | ] as const 122 | 123 | async function buildFormDataConfig( 124 | payload: Opts, 125 | agent: ApiClient.Agent 126 | ) { 127 | for (const field of FORM_DATA_JSON_FIELDS) { 128 | if (hasProp(payload, field) && typeof payload[field] !== 'string') { 129 | payload[field] = JSON.stringify(payload[field]) 130 | } 131 | } 132 | const boundary = crypto.randomBytes(32).toString('hex') 133 | const formData = new MultipartStream(boundary) 134 | await Promise.all( 135 | Object.keys(payload).map((key) => 136 | // @ts-expect-error payload[key] can obviously index payload, but TS doesn't trust us 137 | attachFormValue(formData, key, payload[key], agent) 138 | ) 139 | ) 140 | return { 141 | method: 'POST', 142 | compress: true, 143 | headers: { 144 | 'content-type': `multipart/form-data; boundary=${boundary}`, 145 | connection: 'keep-alive', 146 | }, 147 | body: formData, 148 | } 149 | } 150 | 151 | async function attachFormValue( 152 | form: MultipartStream, 153 | id: string, 154 | value: unknown, 155 | agent: ApiClient.Agent 156 | ) { 157 | if (value == null) { 158 | return 159 | } 160 | if ( 161 | typeof value === 'string' || 162 | typeof value === 'boolean' || 163 | typeof value === 'number' 164 | ) { 165 | form.addPart({ 166 | headers: { 'content-disposition': `form-data; name="${id}"` }, 167 | body: `${value}`, 168 | }) 169 | return 170 | } 171 | if (id === 'thumb' || id === 'thumbnail') { 172 | const attachmentId = crypto.randomBytes(16).toString('hex') 173 | await attachFormMedia(form, value as InputFile, attachmentId, agent) 174 | return form.addPart({ 175 | headers: { 'content-disposition': `form-data; name="${id}"` }, 176 | body: `attach://${attachmentId}`, 177 | }) 178 | } 179 | if (Array.isArray(value)) { 180 | const items = await Promise.all( 181 | value.map(async (item) => { 182 | if (typeof item.media !== 'object') { 183 | return await Promise.resolve(item) 184 | } 185 | const attachmentId = crypto.randomBytes(16).toString('hex') 186 | await attachFormMedia(form, item.media, attachmentId, agent) 187 | const thumb = item.thumb ?? item.thumbnail 188 | if (typeof thumb === 'object') { 189 | const thumbAttachmentId = crypto.randomBytes(16).toString('hex') 190 | await attachFormMedia(form, thumb, thumbAttachmentId, agent) 191 | return { 192 | ...item, 193 | media: `attach://${attachmentId}`, 194 | thumbnail: `attach://${thumbAttachmentId}`, 195 | } 196 | } 197 | return { ...item, media: `attach://${attachmentId}` } 198 | }) 199 | ) 200 | return form.addPart({ 201 | headers: { 'content-disposition': `form-data; name="${id}"` }, 202 | body: JSON.stringify(items), 203 | }) 204 | } 205 | if ( 206 | value && 207 | typeof value === 'object' && 208 | hasProp(value, 'media') && 209 | hasProp(value, 'type') && 210 | typeof value.media !== 'undefined' && 211 | typeof value.type !== 'undefined' 212 | ) { 213 | const attachmentId = crypto.randomBytes(16).toString('hex') 214 | await attachFormMedia(form, value.media as InputFile, attachmentId, agent) 215 | return form.addPart({ 216 | headers: { 'content-disposition': `form-data; name="${id}"` }, 217 | body: JSON.stringify({ 218 | ...value, 219 | media: `attach://${attachmentId}`, 220 | }), 221 | }) 222 | } 223 | return await attachFormMedia(form, value as InputFile, id, agent) 224 | } 225 | 226 | async function attachFormMedia( 227 | form: MultipartStream, 228 | media: InputFile, 229 | id: string, 230 | agent: ApiClient.Agent 231 | ) { 232 | let fileName = media.filename ?? `${id}.${DEFAULT_EXTENSIONS[id] ?? 'dat'}` 233 | if ('url' in media && media.url !== undefined) { 234 | const timeout = 500_000 // ms 235 | const res = await fetch(media.url, { agent, timeout }) 236 | return form.addPart({ 237 | headers: { 238 | 'content-disposition': `form-data; name="${id}"; filename="${fileName}"`, 239 | }, 240 | body: res.body, 241 | }) 242 | } 243 | if ('source' in media && media.source) { 244 | let mediaSource = media.source 245 | if (typeof media.source === 'string') { 246 | const source = await realpath(media.source) 247 | if ((await stat(source)).isFile()) { 248 | fileName = media.filename ?? path.basename(media.source) 249 | mediaSource = await fs.createReadStream(media.source) 250 | } else { 251 | throw new TypeError(`Unable to upload '${media.source}', not a file`) 252 | } 253 | } 254 | if (isStream(mediaSource) || Buffer.isBuffer(mediaSource)) { 255 | form.addPart({ 256 | headers: { 257 | 'content-disposition': `form-data; name="${id}"; filename="${fileName}"`, 258 | }, 259 | body: mediaSource, 260 | }) 261 | } 262 | } 263 | } 264 | 265 | async function answerToWebhook( 266 | response: Response, 267 | payload: Opts, 268 | options: ApiClient.Options 269 | ): Promise { 270 | if (!includesMedia(payload)) { 271 | if (!response.headersSent) { 272 | response.setHeader('content-type', 'application/json') 273 | } 274 | response.end(JSON.stringify(payload), 'utf-8') 275 | return true 276 | } 277 | 278 | const { headers, body } = await buildFormDataConfig( 279 | payload, 280 | options.attachmentAgent 281 | ) 282 | if (!response.headersSent) { 283 | for (const [key, value] of Object.entries(headers)) { 284 | response.setHeader(key, value) 285 | } 286 | } 287 | await new Promise((resolve) => { 288 | response.on('finish', resolve) 289 | body.pipe(response) 290 | }) 291 | return true 292 | } 293 | 294 | function redactToken(error: Error): never { 295 | error.message = error.message.replace( 296 | /\/(bot|user)(\d+):[^/]+\//, 297 | '/$1$2:[REDACTED]/' 298 | ) 299 | throw error 300 | } 301 | 302 | type Response = http.ServerResponse 303 | class ApiClient { 304 | readonly options: ApiClient.Options 305 | 306 | constructor( 307 | readonly token: string, 308 | options?: Partial, 309 | private readonly response?: Response 310 | ) { 311 | this.options = { 312 | ...DEFAULT_OPTIONS, 313 | ...compactOptions(options), 314 | } 315 | if (this.options.apiRoot.startsWith('http://')) { 316 | this.options.agent = undefined 317 | } 318 | } 319 | 320 | /** 321 | * If set to `true`, first _eligible_ call will avoid performing a POST request. 322 | * Note that such a call: 323 | * 1. cannot report errors or return meaningful values, 324 | * 2. resolves before bot API has a chance to process it, 325 | * 3. prematurely confirms the update as processed. 326 | * 327 | * https://core.telegram.org/bots/faq#how-can-i-make-requests-in-response-to-updates 328 | * https://github.com/telegraf/telegraf/pull/1250 329 | */ 330 | set webhookReply(enable: boolean) { 331 | this.options.webhookReply = enable 332 | } 333 | 334 | get webhookReply() { 335 | return this.options.webhookReply 336 | } 337 | 338 | async callApi( 339 | method: M, 340 | payload: Opts, 341 | { signal }: ApiClient.CallApiOptions = {} 342 | ): Promise> { 343 | const { token, options, response } = this 344 | 345 | if ( 346 | options.webhookReply && 347 | response?.writableEnded === false && 348 | WEBHOOK_REPLY_METHOD_ALLOWLIST.has(method) 349 | ) { 350 | debug('Call via webhook', method, payload) 351 | // @ts-expect-error using webhookReply is an optimisation that doesn't respond with normal result 352 | // up to the user to deal with this 353 | return await answerToWebhook(response, { method, ...payload }, options) 354 | } 355 | 356 | if (!token) { 357 | throw new TelegramError({ 358 | error_code: 401, 359 | description: 'Bot Token is required', 360 | }) 361 | } 362 | 363 | debug('HTTP call', method, payload) 364 | 365 | const config: RequestInit = includesMedia(payload) 366 | ? await buildFormDataConfig( 367 | { method, ...payload }, 368 | options.attachmentAgent 369 | ) 370 | : await buildJSONConfig(payload) 371 | const apiUrl = new URL( 372 | `./${options.apiMode}${token}${options.testEnv ? '/test' : ''}/${method}`, 373 | options.apiRoot 374 | ) 375 | config.agent = options.agent 376 | // @ts-expect-error AbortSignal shim is missing some props from Request.AbortSignal 377 | config.signal = signal 378 | config.timeout = 500_000 // ms 379 | const res = await fetch(apiUrl, config).catch(redactToken) 380 | if (res.status >= 500) { 381 | const errorPayload = { 382 | error_code: res.status, 383 | description: res.statusText, 384 | } 385 | throw new TelegramError(errorPayload, { method, payload }) 386 | } 387 | const data = await res.json() 388 | if (!data.ok) { 389 | debug('API call failed', data) 390 | throw new TelegramError(data, { method, payload }) 391 | } 392 | return data.result 393 | } 394 | } 395 | 396 | export default ApiClient 397 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 24 | 25 | ## For 3.x users 26 | 27 | - [3.x docs](https://telegraf.js.org/v3) 28 | - [4.0 release notes](https://github.com/telegraf/telegraf/releases/tag/v4.0.0) 29 | 30 | ## Introduction 31 | 32 | Bots are special [Telegram](https://telegram.org) accounts designed to handle messages automatically. 33 | Users can interact with bots by sending them command messages in private or group chats. 34 | These accounts serve as an interface for code running somewhere on your server. 35 | 36 | Telegraf is a library that makes it simple for you to develop your own Telegram bots using JavaScript or [TypeScript](https://www.typescriptlang.org/). 37 | 38 | ### Features 39 | 40 | - Full [Telegram Bot API 7.1](https://core.telegram.org/bots/api) support 41 | - [Excellent TypeScript typings](https://github.com/telegraf/telegraf/releases/tag/v4.0.0) 42 | - [Lightweight](https://packagephobia.com/result?p=telegraf,node-telegram-bot-api) 43 | - [AWS **λ**](https://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-handler.html) 44 | / [Firebase](https://firebase.google.com/products/functions/) 45 | / [Glitch](https://glitch.com/edit/#!/dashing-light) 46 | / [Fly.io](https://fly.io/docs/languages-and-frameworks/node) 47 | / Whatever ready 48 | - `http/https/fastify/Connect.js/express.js` compatible webhooks 49 | - Extensible 50 | 51 | ### Example 52 | 53 | ```js 54 | const { Telegraf } = require('telegraf') 55 | const { message } = require('telegraf/filters') 56 | 57 | const bot = new Telegraf(process.env.BOT_TOKEN) 58 | bot.start((ctx) => ctx.reply('Welcome')) 59 | bot.help((ctx) => ctx.reply('Send me a sticker')) 60 | bot.on(message('sticker'), (ctx) => ctx.reply('👍')) 61 | bot.hears('hi', (ctx) => ctx.reply('Hey there')) 62 | bot.launch() 63 | 64 | // Enable graceful stop 65 | process.once('SIGINT', () => bot.stop('SIGINT')) 66 | process.once('SIGTERM', () => bot.stop('SIGTERM')) 67 | ``` 68 | 69 | ```js 70 | const { Telegraf } = require('telegraf') 71 | 72 | const bot = new Telegraf(process.env.BOT_TOKEN) 73 | bot.command('oldschool', (ctx) => ctx.reply('Hello')) 74 | bot.command('hipster', Telegraf.reply('λ')) 75 | bot.launch() 76 | 77 | // Enable graceful stop 78 | process.once('SIGINT', () => bot.stop('SIGINT')) 79 | process.once('SIGTERM', () => bot.stop('SIGTERM')) 80 | ``` 81 | 82 | For additional bot examples see the new [`docs repo`](https://github.com/feathers-studio/telegraf-docs/). 83 | 84 | ### Resources 85 | 86 | - [Getting started](#getting-started) 87 | - [API reference](https://telegraf.js.org/modules.html) 88 | - Telegram groups (sorted by number of members): 89 | - [English](https://t.me/TelegrafJSChat) 90 | - [Russian](https://t.me/telegrafjs_ru) 91 | - [Uzbek](https://t.me/botjs_uz) 92 | - [Ethiopian](https://t.me/telegraf_et) 93 | - [GitHub Discussions](https://github.com/telegraf/telegraf/discussions) 94 | - [Dependent repositories](https://libraries.io/npm/telegraf/dependent_repositories) 95 | 96 | ## Getting started 97 | 98 | ### Telegram token 99 | 100 | To use the [Telegram Bot API](https://core.telegram.org/bots/api), 101 | you first have to [get a bot account](https://core.telegram.org/bots) 102 | by [chatting with BotFather](https://core.telegram.org/bots#6-botfather). 103 | 104 | BotFather will give you a _token_, something like `123456789:AbCdefGhIJKlmNoPQRsTUVwxyZ`. 105 | 106 | ### Installation 107 | 108 | ```shellscript 109 | $ npm install telegraf 110 | ``` 111 | 112 | or 113 | 114 | ```shellscript 115 | $ yarn add telegraf 116 | ``` 117 | 118 | or 119 | 120 | ```shellscript 121 | $ pnpm add telegraf 122 | ``` 123 | 124 | ### `Telegraf` class 125 | 126 | [`Telegraf`] instance represents your bot. It's responsible for obtaining updates and passing them to your handlers. 127 | 128 | Start by [listening to commands](https://telegraf.js.org/classes/Telegraf-1.html#command) and [launching](https://telegraf.js.org/classes/Telegraf-1.html#launch) your bot. 129 | 130 | ### `Context` class 131 | 132 | `ctx` you can see in every example is a [`Context`] instance. 133 | [`Telegraf`] creates one for each incoming update and passes it to your middleware. 134 | It contains the `update`, `botInfo`, and `telegram` for making arbitrary Bot API requests, 135 | as well as shorthand methods and getters. 136 | 137 | This is probably the class you'll be using the most. 138 | 139 | 159 | 160 | #### Shorthand methods 161 | 162 | ```js 163 | import { Telegraf } from 'telegraf' 164 | import { message } from 'telegraf/filters' 165 | 166 | const bot = new Telegraf(process.env.BOT_TOKEN) 167 | 168 | bot.command('quit', async (ctx) => { 169 | // Explicit usage 170 | await ctx.telegram.leaveChat(ctx.message.chat.id) 171 | 172 | // Using context shortcut 173 | await ctx.leaveChat() 174 | }) 175 | 176 | bot.on(message('text'), async (ctx) => { 177 | // Explicit usage 178 | await ctx.telegram.sendMessage(ctx.message.chat.id, `Hello ${ctx.state.role}`) 179 | 180 | // Using context shortcut 181 | await ctx.reply(`Hello ${ctx.state.role}`) 182 | }) 183 | 184 | bot.on('callback_query', async (ctx) => { 185 | // Explicit usage 186 | await ctx.telegram.answerCbQuery(ctx.callbackQuery.id) 187 | 188 | // Using context shortcut 189 | await ctx.answerCbQuery() 190 | }) 191 | 192 | bot.on('inline_query', async (ctx) => { 193 | const result = [] 194 | // Explicit usage 195 | await ctx.telegram.answerInlineQuery(ctx.inlineQuery.id, result) 196 | 197 | // Using context shortcut 198 | await ctx.answerInlineQuery(result) 199 | }) 200 | 201 | bot.launch() 202 | 203 | // Enable graceful stop 204 | process.once('SIGINT', () => bot.stop('SIGINT')) 205 | process.once('SIGTERM', () => bot.stop('SIGTERM')) 206 | ``` 207 | 208 | ## Production 209 | 210 | ### Webhooks 211 | 212 | ```TS 213 | import { Telegraf } from "telegraf"; 214 | import { message } from 'telegraf/filters'; 215 | 216 | const bot = new Telegraf(token); 217 | 218 | bot.on(message("text"), ctx => ctx.reply("Hello")); 219 | 220 | // Start webhook via launch method (preferred) 221 | bot.launch({ 222 | webhook: { 223 | // Public domain for webhook; e.g.: example.com 224 | domain: webhookDomain, 225 | 226 | // Port to listen on; e.g.: 8080 227 | port: port, 228 | 229 | // Optional path to listen for. 230 | // `bot.secretPathComponent()` will be used by default 231 | path: webhookPath, 232 | 233 | // Optional secret to be sent back in a header for security. 234 | // e.g.: `crypto.randomBytes(64).toString("hex")` 235 | secretToken: randomAlphaNumericString, 236 | }, 237 | }); 238 | ``` 239 | 240 | Use `createWebhook()` if you want to attach Telegraf to an existing http server. 241 | 242 | 243 | 244 | ```TS 245 | import { createServer } from "http"; 246 | 247 | createServer(await bot.createWebhook({ domain: "example.com" })).listen(3000); 248 | ``` 249 | 250 | ```TS 251 | import { createServer } from "https"; 252 | 253 | createServer(tlsOptions, await bot.createWebhook({ domain: "example.com" })).listen(8443); 254 | ``` 255 | 256 | - [AWS Lambda example integration](https://github.com/feathers-studio/telegraf-docs/tree/master/examples/functions/aws-lambda) 257 | - [Google Cloud Functions example integration](https://github.com/feathers-studio/telegraf-docs/blob/master/examples/functions/google-cloud-function.ts) 258 | - [`express` example integration](https://github.com/feathers-studio/telegraf-docs/blob/master/examples/webhook/express.ts) 259 | - [`fastify` example integration](https://github.com/feathers-studio/telegraf-docs/blob/master/examples/webhook/fastify.ts) 260 | - [`koa` example integration](https://github.com/feathers-studio/telegraf-docs/blob/master/examples/webhook/koa.ts) 261 | - [NestJS framework integration module](https://github.com/bukhalo/nestjs-telegraf) 262 | - [Cloudflare Workers integration module](https://github.com/Tsuk1ko/cfworker-middware-telegraf) 263 | - Use [`bot.handleUpdate`](https://telegraf.js.org/classes/Telegraf-1.html#handleupdate) to write new integrations 264 | 265 | ### Error handling 266 | 267 | If middleware throws an error or times out, Telegraf calls `bot.handleError`. If it rethrows, update source closes, and then the error is printed to console and process terminates. If it does not rethrow, the error is swallowed. 268 | 269 | Default `bot.handleError` always rethrows. You can overwrite it using `bot.catch` if you need to. 270 | 271 | ⚠️ Swallowing unknown errors might leave the process in invalid state! 272 | 273 | ℹ️ In production, `systemd` or [`pm2`](https://www.npmjs.com/package/pm2) can restart your bot if it exits for any reason. 274 | 275 | ## Advanced topics 276 | 277 | ### Working with files 278 | 279 | Supported file sources: 280 | 281 | - `Existing file_id` 282 | - `File path` 283 | - `Url` 284 | - `Buffer` 285 | - `ReadStream` 286 | 287 | Also, you can provide an optional name of a file as `filename` when you send the file. 288 | 289 | 290 | 291 | ```js 292 | bot.on('message', async (ctx) => { 293 | // resend existing file by file_id 294 | await ctx.replyWithSticker('123123jkbhj6b') 295 | 296 | // send file 297 | await ctx.replyWithVideo(Input.fromLocalFile('/path/to/video.mp4')) 298 | 299 | // send stream 300 | await ctx.replyWithVideo( 301 | Input.fromReadableStream(fs.createReadStream('/path/to/video.mp4')) 302 | ) 303 | 304 | // send buffer 305 | await ctx.replyWithVoice(Input.fromBuffer(Buffer.alloc())) 306 | 307 | // send url via Telegram server 308 | await ctx.replyWithPhoto(Input.fromURL('https://picsum.photos/200/300/')) 309 | 310 | // pipe url content 311 | await ctx.replyWithPhoto( 312 | Input.fromURLStream('https://picsum.photos/200/300/?random', 'kitten.jpg') 313 | ) 314 | }) 315 | ``` 316 | 317 | ### Middleware 318 | 319 | In addition to `ctx: Context`, each middleware receives `next: () => Promise`. 320 | 321 | As in Koa and some other middleware-based libraries, 322 | `await next()` will call next middleware and wait for it to finish: 323 | 324 | ```TS 325 | import { Telegraf } from 'telegraf'; 326 | import { message } from 'telegraf/filters'; 327 | 328 | const bot = new Telegraf(process.env.BOT_TOKEN); 329 | 330 | bot.use(async (ctx, next) => { 331 | console.time(`Processing update ${ctx.update.update_id}`); 332 | await next() // runs next middleware 333 | // runs after next middleware finishes 334 | console.timeEnd(`Processing update ${ctx.update.update_id}`); 335 | }) 336 | 337 | bot.on(message('text'), (ctx) => ctx.reply('Hello World')); 338 | bot.launch(); 339 | 340 | // Enable graceful stop 341 | process.once('SIGINT', () => bot.stop('SIGINT')); 342 | process.once('SIGTERM', () => bot.stop('SIGTERM')); 343 | ``` 344 | 345 | With this simple ability, you can: 346 | 347 | - extract information from updates and then `await next()` to avoid disrupting other middleware, 348 | - like [`Composer`] and [`Router`], `await next()` for updates you don't wish to handle, 349 | - like [`session`] and [`Scenes`], [extend the context](#extending-context) by mutating `ctx` before `await next()`, 350 | - [intercept API calls](https://github.com/telegraf/telegraf/discussions/1267#discussioncomment-254525), 351 | - reuse [other people's code](https://www.npmjs.com/search?q=telegraf-), 352 | - do whatever **you** come up with! 353 | 354 | [`Telegraf`]: https://telegraf.js.org/classes/Telegraf-1.html 355 | [`Composer`]: https://telegraf.js.org/classes/Composer.html 356 | [`Context`]: https://telegraf.js.org/classes/Context.html 357 | [`Router`]: https://telegraf.js.org/classes/Router.html 358 | [`session`]: https://telegraf.js.org/modules.html#session 359 | [`Scenes`]: https://telegraf.js.org/modules/Scenes.html 360 | 361 | ### Usage with TypeScript 362 | 363 | Telegraf is written in TypeScript and therefore ships with declaration files for the entire library. 364 | Moreover, it includes types for the complete Telegram API via the [`typegram`](https://github.com/KnorpelSenf/typegram) package. 365 | While most types of Telegraf's API surface are self-explanatory, there's some notable things to keep in mind. 366 | 367 | #### Extending `Context` 368 | 369 | The exact shape of `ctx` can vary based on the installed middleware. 370 | Some custom middleware might register properties on the context object that Telegraf is not aware of. 371 | Consequently, you can change the type of `ctx` to fit your needs in order for you to have proper TypeScript types for your data. 372 | This is done through Generics: 373 | 374 | ```ts 375 | import { Context, Telegraf } from 'telegraf' 376 | 377 | // Define your own context type 378 | interface MyContext extends Context { 379 | myProp?: string 380 | myOtherProp?: number 381 | } 382 | 383 | // Create your bot and tell it about your context type 384 | const bot = new Telegraf('SECRET TOKEN') 385 | 386 | // Register middleware and launch your bot as usual 387 | bot.use((ctx, next) => { 388 | // Yay, `myProp` is now available here as `string | undefined`! 389 | ctx.myProp = ctx.chat?.first_name?.toUpperCase() 390 | return next() 391 | }) 392 | // ... 393 | ``` 394 | --------------------------------------------------------------------------------
2 | 3 | 22 | 23 |