├── .all-contributorsrc ├── .editorconfig ├── .github ├── release.yml └── workflows │ ├── deno.yml │ ├── release.yml │ └── third_party.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bundling └── bundle-web.ts ├── deno.jsonc ├── package.json ├── src ├── README.md ├── bot.ts ├── composer.ts ├── context.ts ├── convenience │ ├── constants.ts │ ├── frameworks.ts │ ├── inline_query.ts │ ├── input_media.ts │ ├── keyboard.ts │ ├── session.ts │ └── webhook.ts ├── core │ ├── api.ts │ ├── client.ts │ ├── error.ts │ └── payload.ts ├── filter.ts ├── mod.ts ├── platform.deno.ts ├── platform.node.ts ├── platform.web.ts ├── shim.node.ts ├── types.deno.ts ├── types.node.ts ├── types.ts └── types.web.ts ├── test ├── bot.test.ts ├── composer.test.ts ├── composer.type.test.ts ├── context.test.ts ├── context.type.test.ts ├── convenience │ ├── inline_query.test.ts │ ├── input_media.test.ts │ ├── keyboard.test.ts │ ├── session.test.ts │ └── webhook.test.ts ├── core │ ├── client.test.ts │ ├── error.test.ts │ └── payload.test.ts ├── deps.test.ts ├── filter.test.ts └── types.test.ts ├── tsconfig.json └── types.d.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | end_of_line = lf 3 | charset = utf-8 4 | indent_style = space 5 | indent_size = 4 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | max_line_length = 80 9 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - allcontributors[bot] 5 | labels: 6 | - documentation 7 | -------------------------------------------------------------------------------- /.github/workflows/deno.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # This workflow will install Deno and run tests across stable and nightly builds on Windows, Ubuntu and macOS. 7 | # For more information see: https://github.com/denolib/setup-deno 8 | 9 | name: grammY 10 | 11 | on: 12 | push: 13 | branches: [main, v2] 14 | pull_request: 15 | branches: [main, v2] 16 | 17 | jobs: 18 | backport: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Setup repo 22 | uses: actions/checkout@v4 23 | 24 | - name: Install dependencies 25 | run: npm install --ignore-scripts 26 | 27 | - name: Run backporting 28 | run: npm run backport 29 | 30 | format-and-lint: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Setup repo 34 | uses: actions/checkout@v4 35 | 36 | - uses: denoland/setup-deno@v2 37 | with: 38 | deno-version: v2.x 39 | 40 | - name: Check Format 41 | run: deno fmt --check 42 | 43 | - name: Lint 44 | run: deno lint 45 | 46 | test: 47 | runs-on: ${{ matrix.os }} # runs a test on Ubuntu, Windows and macOS 48 | 49 | strategy: 50 | matrix: 51 | os: [macOS-latest, windows-latest, ubuntu-latest] 52 | 53 | steps: 54 | - name: Setup repo 55 | uses: actions/checkout@v4 56 | 57 | - uses: denoland/setup-deno@v2 58 | with: 59 | deno-version: v2.x 60 | 61 | - name: Cache Dependencies 62 | run: deno task check 63 | 64 | - name: Run Tests 65 | run: deno task test 66 | 67 | coverage: 68 | runs-on: ubuntu-latest 69 | steps: 70 | - name: Setup repo 71 | uses: actions/checkout@v4 72 | with: 73 | fetch-depth: 0 74 | 75 | - uses: denoland/setup-deno@v2 76 | with: 77 | deno-version: v2.x 78 | 79 | - name: Create coverage files 80 | run: deno task coverage 81 | 82 | - name: Collect coverage 83 | uses: codecov/codecov-action@v1.0.10 # upload the report on Codecov 84 | with: 85 | file: ./coverage.lcov 86 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v1.*" 7 | 8 | jobs: 9 | build: 10 | if: github.event.base_ref == 'refs/heads/main' 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | # Setup deno so we can bundle for the web 17 | 18 | - uses: denoland/setup-deno@v2 19 | with: 20 | deno-version: v2.x 21 | 22 | - run: npm install 23 | - run: deno task bundle-web 24 | 25 | - name: Publish to npm 26 | run: | 27 | npm config set //registry.npmjs.org/:_authToken '${NPM_TOKEN}' 28 | npm publish --ignore-scripts 29 | env: 30 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 31 | 32 | - name: Release 33 | uses: softprops/action-gh-release@v1 34 | with: 35 | generate_release_notes: true 36 | -------------------------------------------------------------------------------- /.github/workflows/third_party.yml: -------------------------------------------------------------------------------- 1 | name: Type-check third-party bots 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - src/ 8 | - .github/workflows/third_party.yml 9 | pull_request: 10 | branches: [main] 11 | paths: 12 | - src/ 13 | - .github/workflows/third_party.yml 14 | 15 | jobs: 16 | deno: 17 | runs-on: ubuntu-latest 18 | 19 | strategy: 20 | matrix: 21 | url: 22 | # please keep sorted 23 | - https://raw.githubusercontent.com/dcdunkan/y-by-example/refs/heads/main/contents/commands.ts 24 | - https://raw.githubusercontent.com/dcdunkan/y-by-example/refs/heads/main/contents/hello-world.ts 25 | - https://raw.githubusercontent.com/dcdunkan/y-by-example/refs/heads/main/contents/inline-keyboards.ts 26 | - https://raw.githubusercontent.com/dcdunkan/y-by-example/refs/heads/main/contents/message-formatting.ts 27 | - https://raw.githubusercontent.com/grammyjs/docs-bot/refs/heads/main/main.ts 28 | - https://raw.githubusercontent.com/grammyjs/guard-bot/refs/heads/main/bot.ts 29 | 30 | steps: 31 | - uses: denoland/setup-deno@v2 32 | with: 33 | deno-version: v2.x 34 | 35 | - name: Create import_map.json 36 | run: | 37 | set -o pipefail; 38 | curl --silent https://cdn.deno.land/grammy/meta/versions.json | 39 | jq ' 40 | (.latest / ".")[0] as $major 41 | | .versions 42 | | map(select(startswith($major + ".")) | { 43 | key: @uri "https://deno.land/x/grammy@\(.)/mod.ts", 44 | value: @uri "https://raw.githubusercontent.com/grammyjs/grammY/\(env.GITHUB_SHA)/src/mod.ts" 45 | }) 46 | | { imports: from_entries } 47 | ' > import_map.json 48 | 49 | - run: deno check --allow-import --import-map=import_map.json --remote ${{ matrix.url }} 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Dependency directories 15 | node_modules/ 16 | 17 | # lock files will not be published, so no need to store it 18 | package-lock.json 19 | 20 | # Output of 'npm pack' 21 | *.tgz 22 | 23 | # Test coverage 24 | test/coverage 25 | test/cov_profile 26 | coverage.lcov 27 | 28 | # Build output 29 | deno_cache/ 30 | out/ 31 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["denoland.vscode-deno", "editorconfig.editorconfig"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll": "explicit" 5 | }, 6 | "deno.enable": true, 7 | "deno.config": "./deno.jsonc", 8 | "deno.lint": true, 9 | "[typescript]": { 10 | "editor.defaultFormatter": "denoland.vscode-deno" 11 | }, 12 | "[markdown]": { 13 | "editor.defaultFormatter": "denoland.vscode-deno" 14 | }, 15 | "[json]": { 16 | "editor.defaultFormatter": "denoland.vscode-deno" 17 | }, 18 | "[jsonc]": { 19 | "editor.defaultFormatter": "denoland.vscode-deno" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # grammY code of conduct 2 | 3 | ## Who We Want to Be 4 | 5 | We as a community want to be inclusive for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, language, personal appearance, religion, or sexual identity and orientation. 6 | 7 | We are humans. 8 | We meet each other with respect and dignity. 9 | 10 | If it is not obvious what this means, here are a few examples of behavior that we do not tolerate. 11 | 12 | - Trolling, insulting or derogatory comments, and personal or political attacks 13 | - Public or private harassment 14 | - Publishing others’ private information, such as a physical or email 15 | address, without their explicit permission 16 | 17 | ## Enforcement 18 | 19 | Admins enforce our standards of acceptable behavior. 20 | They take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 21 | When they take action, they will communicate this appropriately. 22 | 23 | Instances of abusive, harassing, or otherwise unacceptable behavior on any platform may be reported to the administrators at . 24 | In the [chat](https://t.me/grammyjs), you can also notify the available administrators using the `/report` command. 25 | All complaints will be reviewed and investigated promptly and fairly. 26 | 27 | Admins respect the privacy and security of the reporter of any incident. 28 | 29 | ### Guidelines 30 | 31 | Admins will try to follow these guidelines when taking action. 32 | 33 | - Above all, use human judgement which is appropriate for the situation. 34 | - Warn or temp ban people who use inappropriate language or show other unwelcome behavior. 35 | If there is hope that the respective person can learn and avoid such behavior in the future, it is imperative to teach them rather than banning them. 36 | - Be transparent about your actions, and provide necessary clarity why you performed them. 37 | - Delete messages that are too harmful to stay online and be read by others. 38 | Only delete these messages, and no others, so that the context of your actions is preserved as much as possible. 39 | 40 | ## Attribution 41 | 42 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0, available at . 43 | 44 | For answers to common questions about this code of conduct, see the FAQ at . 45 | Translations are available at . 46 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to grammY 2 | 3 | First of all, thanks for your interest in helping out! 4 | We appreciate any kind of support, be it small bug fixes, large feature contributions, or even just if you drop us a message with some constructive criticism on how grammY can be improved for your use case. 5 | This library would not be possible without you. 6 | 7 | We believe it is a strength of grammY to provide an integrated experience to its users. 8 | Important plugins have a dedicated page right inside the main documentation, and they are published under @grammyjs both on GitHub and on npm. 9 | If you have a good idea, don't hesitate to tell us in the group chat! 10 | We can grant you access to the GitHub Organization, so you can get a dedicated repository under our name, and publish your code as an official plugin of grammY. 11 | You will be responsible for maintaining it. 12 | 13 | ## What Can I Do? 14 | 15 | In short: anything you find useful! 16 | 17 | If you’re unsure whether your changes are welcome, open an issue on GitHub or preferably ask in the [Telegram chat](https://t.me/grammyjs). 18 | 19 | In case you’d like to get some inspiration what we're working on, we have 20 | 21 | - a [long list of issues across all repositories](https://github.com/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen+archived%3Afalse+user%3Agrammyjs), and 22 | - an [issue with plugin ideas](https://github.com/grammyjs/grammY/issues/110). 23 | 24 | There’s usually also a [pinned issue](https://github.com/grammyjs/grammY/issues) which tells you what the next release is about. 25 | 26 | ## A Few Words on Deno and Node 27 | 28 | **TL;DR** working on grammY means working on a Deno project, and that is a good thing. 29 | 30 | grammY started out as a hybrid project, targeting both Deno and Node.js as runtimes not long after the Deno 1.0 release. 31 | In the beginning, this posed a challenge to grammY. 32 | There were no sufficiently good tools to convert a codebase back and forth between the ecosystems, so we had to maintain our own shell scripts to convert the Deno code to run under Node.js. 33 | 34 | However, after some time the amazing tool `deno2node` emerged out of grammY's need. 35 | It solves this problem substantially better by providing a Deno-aware wrapper of the TypeScript compiler. 36 | Hence, you can write a Deno project and directly compile it to JavaScript files that run under Node.js. 37 | 38 | In other words, working on grammY effectively means work on a Deno project. 39 | We use Deno testing, Deno linting, Deno formatting, and the [Deno extension](https://marketplace.visualstudio.com/items?itemName=denoland.vscode-deno) for VS Code. 40 | Your usual TypeScript tooling does not work—and once you tried developing for Deno and you have experienced how superior the development experience is, you will know why we are happy about our choice. 41 | 42 | ## How to Contribute 43 | 44 | There are several areas of contributions, and they have different ways to get you started. 45 | 46 | - **Docs.** 47 | You can always just edit the documentation by clicking the link at the bottom of the respective page. 48 | This will open a pull request on GitHub. 49 | (Related: [docs contribution guide](https://github.com/grammyjs/website/blob/main/CONTRIBUTING.md)) 50 | - **Core.** 51 | We are happy to take pull requests of any kind against the core code base of grammY. 52 | If you're unsure whether or not your work goes in the right direction, simply ask about it in an issue or in the [Telegram chat](https://telegram.me/grammyjs). 53 | - **Plugins.** 54 | There are both official plugins and third-party plugins. 55 | Our official plugins need to be of high quality (100 % TypeScript, ES6, Deno support, signed commits, docs that are on par with grammY, semver, etc). 56 | Third-party plugins are independent and anyone can do them however they want. 57 | If a third-party plugin was to be listed on the website, some docs would be nice. 58 | - **Storage adapters.** 59 | Please send a message to the [group chat](https://telegram.me/grammyjs) if you want to create an official storage adapter for the [session plugin](https://grammy.dev/plugins/session.html). 60 | All storage adapters are collected in [this repo](https://github.com/grammyjs/storages). 61 | You can simply open a pull request to add a new adapter. 62 | Consider checking out an existing implementation to make your life easier, e.g. [this one](https://github.com/grammyjs/storage-firestore/blob/main/src/index.ts). 63 | - **Issues, bugs, and everything else.** 64 | We're happy to hear from you if you want to report a bug, request a feature, or contribute anything else—also if it is not code. 65 | There are no technical steps to this. 66 | 67 | ### Working on the Core of grammY Using Deno (Recommended) 68 | 69 | If you just want to build from the newest version of the source code on GitHub, you can directly import from `https://raw.githubusercontent.com/grammyjs/grammY/main/src/mod.ts`. 70 | (Naturally, you can replace main by another branch name, e.g. in order to test a PR.) 71 | 72 | #### Coding 73 | 74 | If you want to read or modify grammY's code, you can do the following. 75 | 76 | 1. Install Deno from . 77 | 2. Use or a similar extension if you are using a different editor. 78 | 3. Clone this repo. 79 | 4. Run `deno task check` in the root directory of the repo. 80 | This will download and cache all dependencies and typecheck the complete code base. 81 | 82 | You are now ready to work on grammY. 83 | Before you open a PR, make sure to run `deno task dev` on the grammY codebase. 84 | 85 | #### Test Coverage 86 | 87 | A CI job will determine the test coverage for your changes, and comment them on GitHub. 88 | If you want to see the code coverage locally, you need to have [LCOV](https://github.com/linux-test-project/lcov) installed (trying installing the `lcov` package). 89 | This should give you `genhtml`, a tool to display LCOV code coverage. 90 | 91 | 1. Run `deno task coverage` to generate test coverage files. 92 | 2. Run `deno task report` to generate an HTML report for the coverage. 93 | 3. Point your browser to `./test/coverage/index.html` to view the test coverage. 94 | 95 | ### Working on the Core of grammY Using Node.js 96 | 97 | You can install grammY directly from source via 98 | 99 | ```sh 100 | # main branch 101 | npm install github:grammyjs/grammy 102 | # another branch, e.g. called branch-name 103 | npm install github:grammyjs/grammy#branch-name 104 | ``` 105 | 106 | which will download grammY and build the code locally. 107 | 108 | If you want to read or modify grammY's code, you can do the following. 109 | 110 | 1. Clone this repo. 111 | 2. Install the dependencies via `npm install`. 112 | This will also compile the project for you. 113 | 3. Use [`npm link`](https://docs.npmjs.com/cli/v7/commands/npm-link) to integrate grammY into your bot project. 114 | 4. After changing the code, you can run `npm run backport` to compile the code. 115 | 116 | ### Working on an Official Plugin of grammY 117 | 118 | This works analogously to the grammY core development. 119 | 120 | If you want to publish a new official plugin, a quick message in the [group chat](https://telegram.me/grammyjs) is enough and you will be granted the necessary permissions to work on a repository under the [@grammyjs](https://github.com/grammyjs) organization on GitHub, and to publish your plugin on npm. 121 | You will be responsible for maintaining it, but perhaps other people will join in. 122 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2024 KnorpelSenf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /bundling/bundle-web.ts: -------------------------------------------------------------------------------- 1 | import { createCache } from "jsr:@deno/cache-dir@0.13.2"; 2 | import { bundle } from "jsr:@deno/emit@0.46.0"; 3 | 4 | // Parse args 5 | const [release, source = `https://deno.land/x/grammy@${release}/mod.ts`] = 6 | Deno.args; 7 | if (!release) throw new Error("No release specified!"); 8 | 9 | // Rewrite imports from .deno.ts to .web.ts 10 | const cache = createCache(); 11 | const load = (specifier: string) => { 12 | if (specifier.endsWith(".deno.ts")) { 13 | const baseLength = specifier.length - ".deno.ts".length; 14 | specifier = specifier.substring(0, baseLength) + ".web.ts"; 15 | } 16 | return cache.load(specifier); 17 | }; 18 | 19 | console.log(`Bundling version '${release}' from ${source} ...`); 20 | // Bundle code 21 | const { code: bundledCode } = await bundle(source, { 22 | load, 23 | compilerOptions: { 24 | sourceMap: false, 25 | inlineSources: false, 26 | inlineSourceMap: false, 27 | }, 28 | }); 29 | 30 | console.log("Emitting ..."); 31 | await Deno.writeTextFile("../out/web.mjs", bundledCode); 32 | await Deno.writeTextFile("../out/web.d.ts", 'export * from "./mod";\n'); 33 | 34 | console.log("Done."); 35 | -------------------------------------------------------------------------------- /deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "lock": false, 3 | "nodeModulesDir": "none", 4 | "tasks": { 5 | "check": "deno cache --check=all --allow-import src/mod.ts", 6 | "backport": "deno --no-prompt --allow-read=. --allow-write=. https://deno.land/x/deno2node@v1.13.0/src/cli.ts tsconfig.json", 7 | "test": "deno test --seed=123456 --parallel --allow-import ./test/", 8 | "dev": "deno fmt && deno lint && deno task test && deno task check", 9 | "coverage": "rm -rf ./test/cov_profile && deno task test --coverage=./test/cov_profile && deno coverage --lcov --output=./coverage.lcov ./test/cov_profile", 10 | "report": "genhtml ./coverage.lcov --output-directory ./test/coverage/ && echo 'Point your browser to test/coverage/index.html to see the test coverage report.'", 11 | "bundle-web": "mkdir -p out deno_cache && cd bundling && deno -ENRW bundle-web.ts dev ../src/mod.ts", 12 | "contribs": "deno -ERS --allow-write=. --allow-net=api.github.com npm:all-contributors-cli" 13 | }, 14 | "exclude": [ 15 | "./bundling/bundles", 16 | "./deno_cache/", 17 | "./node_modules/", 18 | "./out/", 19 | "./package-lock.json", 20 | "./test/cov_profile" 21 | ], 22 | "fmt": { 23 | "indentWidth": 4, 24 | "proseWrap": "preserve" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "grammy", 3 | "description": "The Telegram Bot Framework.", 4 | "version": "1.36.3", 5 | "author": "KnorpelSenf", 6 | "license": "MIT", 7 | "engines": { 8 | "node": "^12.20.0 || >=14.13.1" 9 | }, 10 | "homepage": "https://grammy.dev/", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/grammyjs/grammY" 14 | }, 15 | "scripts": { 16 | "prepare": "npm run backport", 17 | "backport": "deno2node tsconfig.json" 18 | }, 19 | "dependencies": { 20 | "@grammyjs/types": "3.20.0", 21 | "abort-controller": "^3.0.0", 22 | "debug": "^4.3.4", 23 | "node-fetch": "^2.7.0" 24 | }, 25 | "devDependencies": { 26 | "@types/debug": "^4.1.12", 27 | "@types/node": "^12.20.55", 28 | "@types/node-fetch": "2.6.2", 29 | "deno2node": "^1.13.0" 30 | }, 31 | "files": [ 32 | "out/" 33 | ], 34 | "main": "./out/mod.js", 35 | "types": "./out/mod.d.ts", 36 | "exports": { 37 | ".": { 38 | "types": "./out/mod.d.ts", 39 | "node": "./out/mod.js", 40 | "browser": "./out/web.mjs", 41 | "default": "./out/web.mjs" 42 | }, 43 | "./types": { 44 | "types": "./out/types.d.ts", 45 | "default": "./out/types.js" 46 | }, 47 | "./web": { 48 | "types": "./out/web.d.ts", 49 | "default": "./out/web.mjs" 50 | } 51 | }, 52 | "typesVersions": { 53 | "*": { 54 | "web": [ 55 | "out/web" 56 | ], 57 | "types": [ 58 | "out/types" 59 | ] 60 | } 61 | }, 62 | "keywords": [ 63 | "telegram", 64 | "bot", 65 | "api", 66 | "client", 67 | "framework", 68 | "library", 69 | "grammy" 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # grammY 2 | 3 | The grammY module lets you easily write Telegram bots. Here is a quickstart for you to get started, but note that a better explanation is [in our repo on GitHub](https://github.com/grammyjs/grammY). 4 | 5 | You may also want to check out the [docs](https://grammy.dev). 6 | 7 | ## Quickstart 8 | 9 | Talk to [@BotFather](https://t.me/BotFather) to create a new Telegram bot and obtain a _bot token_. 10 | 11 | Paste the following code into a new file `bot.ts`. 12 | 13 | ```ts 14 | import { Bot } from "https://deno.land/x/grammy/mod.ts"; 15 | 16 | // Create bot object 17 | const bot = new Bot(""); // <-- place your bot token inside this string 18 | 19 | // Listen for messages 20 | bot.command("start", (ctx) => ctx.reply("Welcome! Send me a photo!")); 21 | bot.on("message:text", (ctx) => ctx.reply("That is text and not a photo!")); 22 | bot.on("message:photo", (ctx) => ctx.reply("Nice photo! Is that you?")); 23 | bot.on( 24 | "edited_message", 25 | (ctx) => 26 | ctx.reply("Ha! Gotcha! You just edited this!", { 27 | reply_parameters: { message_id: ctx.editedMessage.message_id }, 28 | }), 29 | ); 30 | 31 | // Launch! 32 | bot.start(); 33 | ``` 34 | 35 | **Congratulations!** You have successfully created your first Telegram bot. 36 | 37 | You can run it like so: 38 | 39 | ```bash 40 | deno run --allow-net bot.ts 41 | ``` 42 | -------------------------------------------------------------------------------- /src/convenience/constants.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_UPDATE_TYPES } from "../bot.ts"; 2 | 3 | const ALL_UPDATE_TYPES = [ 4 | ...DEFAULT_UPDATE_TYPES, 5 | "chat_member", 6 | "message_reaction", 7 | "message_reaction_count", 8 | ] as const; 9 | const ALL_CHAT_PERMISSIONS = { 10 | is_anonymous: true, 11 | can_manage_chat: true, 12 | can_delete_messages: true, 13 | can_manage_video_chats: true, 14 | can_restrict_members: true, 15 | can_promote_members: true, 16 | can_change_info: true, 17 | can_invite_users: true, 18 | can_post_stories: true, 19 | can_edit_stories: true, 20 | can_delete_stories: true, 21 | can_post_messages: true, 22 | can_edit_messages: true, 23 | can_pin_messages: true, 24 | can_manage_topics: true, 25 | } as const; 26 | 27 | /** 28 | * Types of the constants used in the Telegram Bot API. Currently holds all 29 | * available update types as well as all chat permissions. 30 | */ 31 | export interface ApiConstants { 32 | /** 33 | * List of update types a bot receives by default. Useful if you want to 34 | * receive all update types but `chat_member`, `message_reaction`, and 35 | * `message_reaction_count`. 36 | * 37 | * ```ts 38 | * // Built-in polling: 39 | * bot.start({ allowed_updates: DEFAULT_UPDATE_TYPES }); 40 | * // grammY runner: 41 | * run(bot, { runner: { fetch: { allowed_updates: DEFAULT_UPDATE_TYPES } } }); 42 | * // Webhooks: 43 | * await bot.api.setWebhook(url, { allowed_updates: DEFAULT_UPDATE_TYPES }); 44 | * ``` 45 | * 46 | * See the [Bot API reference](https://core.telegram.org/bots/api#update) 47 | * for more information. 48 | */ 49 | DEFAULT_UPDATE_TYPES: typeof DEFAULT_UPDATE_TYPES[number]; 50 | 51 | /** 52 | * List of all available update types. Useful if you want to receive all 53 | * updates from the Bot API, rather than just those that are delivered by 54 | * default. 55 | * 56 | * The main use case for this is when you want to receive `chat_member`, 57 | * `message_reaction`, and `message_reaction_count` updates, as they need to 58 | * be enabled first. Use it like so: 59 | * 60 | * ```ts 61 | * // Built-in polling: 62 | * bot.start({ allowed_updates: ALL_UPDATE_TYPES }); 63 | * // grammY runner: 64 | * run(bot, { runner: { fetch: { allowed_updates: ALL_UPDATE_TYPES } } }); 65 | * // Webhooks: 66 | * await bot.api.setWebhook(url, { allowed_updates: ALL_UPDATE_TYPES }); 67 | * ``` 68 | * 69 | * See the [Bot API reference](https://core.telegram.org/bots/api#update) 70 | * for more information. 71 | */ 72 | ALL_UPDATE_TYPES: typeof ALL_UPDATE_TYPES[number]; 73 | 74 | /** 75 | * An object containing all available chat permissions. Useful if you want 76 | * to lift restrictions from a user, as this action requires you to pass 77 | * `true` for all permissions. Use it like so: 78 | * 79 | * ```ts 80 | * // On `Bot`: 81 | * await bot.api.restrictChatMember(chat_id, user_id, ALL_CHAT_PERMISSIONS); 82 | * // On `Api`: 83 | * await ctx.api.restrictChatMember(chat_id, user_id, ALL_CHAT_PERMISSIONS); 84 | * // On `Context`: 85 | * await ctx.restrictChatMember(user_id, ALL_CHAT_PERMISSIONS); 86 | * await ctx.restrictAuthor(ALL_CHAT_PERMISSIONS); 87 | * ``` 88 | * 89 | * See the [Bot API reference](https://core.telegram.org/bots/api#update) 90 | * for more information. 91 | */ 92 | ALL_CHAT_PERMISSIONS: keyof typeof ALL_CHAT_PERMISSIONS; 93 | } 94 | 95 | interface TypeOf { 96 | DEFAULT_UPDATE_TYPES: typeof DEFAULT_UPDATE_TYPES; 97 | ALL_UPDATE_TYPES: typeof ALL_UPDATE_TYPES; 98 | ALL_CHAT_PERMISSIONS: typeof ALL_CHAT_PERMISSIONS; 99 | } 100 | type ValuesFor = { 101 | [K in keyof T]: K extends keyof TypeOf ? Readonly : never; 102 | }; 103 | 104 | /** 105 | * A container for constants used in the Telegram Bot API. Currently holds all 106 | * available update types as well as all chat permissions. 107 | */ 108 | export const API_CONSTANTS: ValuesFor = { 109 | DEFAULT_UPDATE_TYPES, 110 | ALL_UPDATE_TYPES, 111 | ALL_CHAT_PERMISSIONS, 112 | }; 113 | Object.freeze(API_CONSTANTS); 114 | -------------------------------------------------------------------------------- /src/convenience/frameworks.ts: -------------------------------------------------------------------------------- 1 | import { type Update } from "../types.ts"; 2 | 3 | const SECRET_HEADER = "X-Telegram-Bot-Api-Secret-Token"; 4 | const SECRET_HEADER_LOWERCASE = SECRET_HEADER.toLowerCase(); 5 | const WRONG_TOKEN_ERROR = "secret token is wrong"; 6 | 7 | const ok = () => new Response(null, { status: 200 }); 8 | const okJson = (json: string) => 9 | new Response(json, { 10 | status: 200, 11 | headers: { "Content-Type": "application/json" }, 12 | }); 13 | const unauthorized = () => 14 | new Response('"unauthorized"', { 15 | status: 401, 16 | statusText: WRONG_TOKEN_ERROR, 17 | }); 18 | 19 | /** 20 | * Abstraction over a request-response cycle, providing access to the update, as 21 | * well as a mechanism for responding to the request and to end it. 22 | */ 23 | export interface ReqResHandler { 24 | /** 25 | * The update object sent from Telegram, usually resolves the request's JSON 26 | * body 27 | */ 28 | update: Promise; 29 | /** 30 | * X-Telegram-Bot-Api-Secret-Token header of the request, or undefined if 31 | * not present 32 | */ 33 | header?: string; 34 | /** 35 | * Ends the request immediately without body, called after every request 36 | * unless a webhook reply was performed 37 | */ 38 | end?: () => void; 39 | /** 40 | * Sends the specified JSON as a payload in the body, used for webhook 41 | * replies 42 | */ 43 | respond: (json: string) => unknown | Promise; 44 | /** 45 | * Responds that the request is unauthorized due to mismatching 46 | * X-Telegram-Bot-Api-Secret-Token headers 47 | */ 48 | unauthorized: () => unknown | Promise; 49 | /** 50 | * Some frameworks (e.g. Deno's std/http `listenAndServe`) assume that 51 | * handler returns something 52 | */ 53 | handlerReturn?: Promise; 54 | } 55 | 56 | /** 57 | * Middleware for a web framework. Creates a request-response handler for a 58 | * request. The handler will be used to integrate with the compatible framework. 59 | */ 60 | // deno-lint-ignore no-explicit-any 61 | export type FrameworkAdapter = (...args: any[]) => ReqResHandler; 62 | 63 | export type LambdaAdapter = ( 64 | event: { 65 | body?: string; 66 | headers: Record; 67 | }, 68 | _context: unknown, 69 | callback: ( 70 | arg0: unknown, 71 | arg1: Record, 72 | ) => Promise, 73 | ) => ReqResHandler; 74 | 75 | export type LambdaAsyncAdapter = ( 76 | event: { 77 | body?: string; 78 | headers: Record; 79 | }, 80 | _context: unknown, 81 | ) => ReqResHandler; 82 | 83 | export type AzureAdapter = (context: { 84 | res?: { 85 | // deno-lint-ignore no-explicit-any 86 | [key: string]: any; 87 | }; 88 | }, request: { body?: unknown }) => ReqResHandler; 89 | export type AzureAdapterV4 = ( 90 | request: { 91 | headers: { get(name: string): string | null }; 92 | json(): Promise; 93 | }, 94 | ) => ReqResHandler<{ status: number; body?: string } | { jsonBody: string }>; 95 | 96 | export type BunAdapter = (request: { 97 | headers: Headers; 98 | json: () => Promise; 99 | }) => ReqResHandler; 100 | 101 | export type CloudflareAdapter = (event: { 102 | request: Request; 103 | respondWith: (response: Promise) => void; 104 | }) => ReqResHandler; 105 | 106 | export type CloudflareModuleAdapter = ( 107 | request: Request, 108 | ) => ReqResHandler; 109 | 110 | export type ElysiaAdapter = (ctx: { 111 | body: Update; 112 | headers: Record; 113 | set: { 114 | headers: Record; 115 | status: number; 116 | }; 117 | }) => ReqResHandler; 118 | 119 | export type ExpressAdapter = (req: { 120 | body: Update; 121 | header: (header: string) => string | undefined; 122 | }, res: { 123 | end: (cb?: () => void) => typeof res; 124 | set: (field: string, value?: string | string[]) => typeof res; 125 | send: (json: string) => typeof res; 126 | status: (code: number) => typeof res; 127 | }) => ReqResHandler; 128 | 129 | export type FastifyAdapter = (request: { 130 | body: unknown; 131 | // deno-lint-ignore no-explicit-any 132 | headers: any; 133 | }, reply: { 134 | status: (code: number) => typeof reply; 135 | headers: (headers: Record) => typeof reply; 136 | code: (code: number) => typeof reply; 137 | send: { 138 | (): typeof reply; 139 | (json: string): typeof reply; 140 | }; 141 | }) => ReqResHandler; 142 | 143 | export type HonoAdapter = (c: { 144 | req: { 145 | json: () => Promise; 146 | header: (header: string) => string | undefined; 147 | }; 148 | body(data: string): Response; 149 | body(data: null, status: 204): Response; 150 | // deno-lint-ignore no-explicit-any 151 | status: (status: any) => void; 152 | json: (json: string) => Response; 153 | }) => ReqResHandler; 154 | 155 | export type HttpAdapter = (req: { 156 | headers: Record; 157 | on: (event: string, listener: (chunk: unknown) => void) => typeof req; 158 | once: (event: string, listener: () => void) => typeof req; 159 | }, res: { 160 | writeHead: { 161 | (status: number): typeof res; 162 | (status: number, headers: Record): typeof res; 163 | }; 164 | end: (json?: string) => void; 165 | }) => ReqResHandler; 166 | 167 | export type KoaAdapter = (ctx: { 168 | get: (header: string) => string | undefined; 169 | set: (key: string, value: string) => void; 170 | status: number; 171 | body: string; 172 | request: { 173 | body?: unknown; 174 | }; 175 | response: { 176 | body: unknown; 177 | status: number; 178 | }; 179 | }) => ReqResHandler; 180 | 181 | export type NextAdapter = (req: { 182 | body: Update; 183 | headers: Record; 184 | }, res: { 185 | end: (cb?: () => void) => typeof res; 186 | status: (code: number) => typeof res; 187 | // deno-lint-ignore no-explicit-any 188 | json: (json: string) => any; 189 | // deno-lint-ignore no-explicit-any 190 | send: (json: string) => any; 191 | }) => ReqResHandler; 192 | 193 | export type NHttpAdapter = (rev: { 194 | body: unknown; 195 | headers: { 196 | get: (header: string) => string | null; 197 | }; 198 | response: { 199 | sendStatus: (status: number) => void; 200 | status: (status: number) => { 201 | send: (json: string) => void; 202 | }; 203 | }; 204 | }) => ReqResHandler; 205 | 206 | export type OakAdapter = (ctx: { 207 | request: { 208 | body: { 209 | json: () => Promise; 210 | }; 211 | headers: { 212 | get: (header: string) => string | null; 213 | }; 214 | }; 215 | response: { 216 | status: number; 217 | type: string | undefined; 218 | body: unknown; 219 | }; 220 | }) => ReqResHandler; 221 | 222 | export type ServeHttpAdapter = ( 223 | requestEvent: { 224 | request: Request; 225 | respondWith: (response: Response) => void; 226 | }, 227 | ) => ReqResHandler; 228 | 229 | export type StdHttpAdapter = ( 230 | req: Request, 231 | ) => ReqResHandler; 232 | 233 | export type SveltekitAdapter = ( 234 | { request }: { request: Request }, 235 | ) => ReqResHandler; 236 | 237 | export type WorktopAdapter = (req: { 238 | json: () => Promise; 239 | headers: { 240 | get: (header: string) => string | null; 241 | }; 242 | }, res: { 243 | end: (data: BodyInit | null) => void; 244 | send: (status: number, json: string) => void; 245 | }) => ReqResHandler; 246 | 247 | /** AWS lambda serverless functions */ 248 | const awsLambda: LambdaAdapter = (event, _context, callback) => ({ 249 | update: JSON.parse(event.body ?? "{}"), 250 | header: event.headers[SECRET_HEADER], 251 | end: () => callback(null, { statusCode: 200 }), 252 | respond: (json) => 253 | callback(null, { 254 | statusCode: 200, 255 | headers: { "Content-Type": "application/json" }, 256 | body: json, 257 | }), 258 | unauthorized: () => callback(null, { statusCode: 401 }), 259 | }); 260 | 261 | /** AWS lambda async/await serverless functions */ 262 | const awsLambdaAsync: LambdaAsyncAdapter = (event, _context) => { 263 | // deno-lint-ignore no-explicit-any 264 | let resolveResponse: (response: any) => void; 265 | 266 | return { 267 | update: JSON.parse(event.body ?? "{}"), 268 | header: event.headers[SECRET_HEADER], 269 | end: () => resolveResponse({ statusCode: 200 }), 270 | respond: (json) => 271 | resolveResponse({ 272 | statusCode: 200, 273 | headers: { "Content-Type": "application/json" }, 274 | body: json, 275 | }), 276 | unauthorized: () => resolveResponse({ statusCode: 401 }), 277 | handlerReturn: new Promise((resolve) => { 278 | resolveResponse = resolve; 279 | }), 280 | }; 281 | }; 282 | 283 | /** Azure Functions v3 and v4 */ 284 | const azure: AzureAdapter = (context, request) => ({ 285 | update: Promise.resolve(request.body as Update), 286 | header: context.res?.headers?.[SECRET_HEADER], 287 | end: () => (context.res = { 288 | status: 200, 289 | body: "", 290 | }), 291 | respond: (json) => { 292 | context.res?.set?.("Content-Type", "application/json"); 293 | context.res?.send?.(json); 294 | }, 295 | unauthorized: () => { 296 | context.res?.send?.(401, WRONG_TOKEN_ERROR); 297 | }, 298 | }); 299 | const azureV4: AzureAdapterV4 = (request) => { 300 | type Res = NonNullable< 301 | Awaited["handlerReturn"]> 302 | >; 303 | let resolveResponse: (response: Res) => void; 304 | return { 305 | update: Promise.resolve(request.json()) as Promise, 306 | header: request.headers.get(SECRET_HEADER) || undefined, 307 | end: () => resolveResponse({ status: 204 }), 308 | respond: (json) => resolveResponse({ jsonBody: json }), 309 | unauthorized: () => 310 | resolveResponse({ status: 401, body: WRONG_TOKEN_ERROR }), 311 | handlerReturn: new Promise((resolve) => { 312 | resolveResponse = resolve; 313 | }), 314 | }; 315 | }; 316 | 317 | /** Bun.serve */ 318 | const bun: BunAdapter = (request) => { 319 | let resolveResponse: (response: Response) => void; 320 | return { 321 | update: request.json(), 322 | header: request.headers.get(SECRET_HEADER) || undefined, 323 | end: () => { 324 | resolveResponse(ok()); 325 | }, 326 | respond: (json) => { 327 | resolveResponse(okJson(json)); 328 | }, 329 | unauthorized: () => { 330 | resolveResponse(unauthorized()); 331 | }, 332 | handlerReturn: new Promise((resolve) => { 333 | resolveResponse = resolve; 334 | }), 335 | }; 336 | }; 337 | 338 | /** Native CloudFlare workers (service worker) */ 339 | const cloudflare: CloudflareAdapter = (event) => { 340 | let resolveResponse: (response: Response) => void; 341 | event.respondWith( 342 | new Promise((resolve) => { 343 | resolveResponse = resolve; 344 | }), 345 | ); 346 | return { 347 | update: event.request.json(), 348 | header: event.request.headers.get(SECRET_HEADER) || undefined, 349 | end: () => { 350 | resolveResponse(ok()); 351 | }, 352 | respond: (json) => { 353 | resolveResponse(okJson(json)); 354 | }, 355 | unauthorized: () => { 356 | resolveResponse(unauthorized()); 357 | }, 358 | }; 359 | }; 360 | 361 | /** Native CloudFlare workers (module worker) */ 362 | const cloudflareModule: CloudflareModuleAdapter = (request) => { 363 | let resolveResponse: (res: Response) => void; 364 | return { 365 | update: request.json(), 366 | header: request.headers.get(SECRET_HEADER) || undefined, 367 | end: () => { 368 | resolveResponse(ok()); 369 | }, 370 | respond: (json) => { 371 | resolveResponse(okJson(json)); 372 | }, 373 | unauthorized: () => { 374 | resolveResponse(unauthorized()); 375 | }, 376 | handlerReturn: new Promise((resolve) => { 377 | resolveResponse = resolve; 378 | }), 379 | }; 380 | }; 381 | 382 | /** express web framework */ 383 | const express: ExpressAdapter = (req, res) => ({ 384 | update: Promise.resolve(req.body), 385 | header: req.header(SECRET_HEADER), 386 | end: () => res.end(), 387 | respond: (json) => { 388 | res.set("Content-Type", "application/json"); 389 | res.send(json); 390 | }, 391 | unauthorized: () => { 392 | res.status(401).send(WRONG_TOKEN_ERROR); 393 | }, 394 | }); 395 | 396 | /** fastify web framework */ 397 | const fastify: FastifyAdapter = (request, reply) => ({ 398 | update: Promise.resolve(request.body as Update), 399 | header: request.headers[SECRET_HEADER_LOWERCASE], 400 | end: () => reply.status(200).send(), 401 | respond: (json) => 402 | reply.headers({ "Content-Type": "application/json" }).send(json), 403 | unauthorized: () => reply.code(401).send(WRONG_TOKEN_ERROR), 404 | }); 405 | 406 | /** hono web framework */ 407 | const hono: HonoAdapter = (c) => { 408 | let resolveResponse: (response: Response) => void; 409 | return { 410 | update: c.req.json(), 411 | header: c.req.header(SECRET_HEADER), 412 | end: () => { 413 | resolveResponse(c.body("")); 414 | }, 415 | respond: (json) => { 416 | resolveResponse(c.json(json)); 417 | }, 418 | unauthorized: () => { 419 | c.status(401); 420 | resolveResponse(c.body("")); 421 | }, 422 | handlerReturn: new Promise((resolve) => { 423 | resolveResponse = resolve; 424 | }), 425 | }; 426 | }; 427 | 428 | /** Node.js native 'http' and 'https' modules */ 429 | const http: HttpAdapter = (req, res) => { 430 | const secretHeaderFromRequest = req.headers[SECRET_HEADER_LOWERCASE]; 431 | return { 432 | update: new Promise((resolve, reject) => { 433 | // deno-lint-ignore no-explicit-any 434 | type Chunk = any; 435 | const chunks: Chunk[] = []; 436 | req.on("data", (chunk: Chunk) => chunks.push(chunk)) 437 | .once("end", () => { 438 | // @ts-ignore `Buffer` is Node-only 439 | // deno-lint-ignore no-node-globals 440 | const raw = Buffer.concat(chunks).toString("utf-8"); 441 | resolve(JSON.parse(raw)); 442 | }) 443 | .once("error", reject); 444 | }), 445 | header: Array.isArray(secretHeaderFromRequest) 446 | ? secretHeaderFromRequest[0] 447 | : secretHeaderFromRequest, 448 | end: () => res.end(), 449 | respond: (json) => 450 | res 451 | .writeHead(200, { "Content-Type": "application/json" }) 452 | .end(json), 453 | unauthorized: () => res.writeHead(401).end(WRONG_TOKEN_ERROR), 454 | }; 455 | }; 456 | 457 | /** koa web framework */ 458 | const koa: KoaAdapter = (ctx) => ({ 459 | update: Promise.resolve(ctx.request.body as Update), 460 | header: ctx.get(SECRET_HEADER) || undefined, 461 | end: () => { 462 | ctx.body = ""; 463 | }, 464 | respond: (json) => { 465 | ctx.set("Content-Type", "application/json"); 466 | ctx.response.body = json; 467 | }, 468 | unauthorized: () => { 469 | ctx.status = 401; 470 | }, 471 | }); 472 | 473 | /** Next.js Serverless Functions */ 474 | const nextJs: NextAdapter = (request, response) => ({ 475 | update: Promise.resolve(request.body), 476 | header: request.headers[SECRET_HEADER_LOWERCASE] as string, 477 | end: () => response.end(), 478 | respond: (json) => response.status(200).json(json), 479 | unauthorized: () => response.status(401).send(WRONG_TOKEN_ERROR), 480 | }); 481 | 482 | /** nhttp web framework */ 483 | const nhttp: NHttpAdapter = (rev) => ({ 484 | update: Promise.resolve(rev.body as Update), 485 | header: rev.headers.get(SECRET_HEADER) || undefined, 486 | end: () => rev.response.sendStatus(200), 487 | respond: (json) => rev.response.status(200).send(json), 488 | unauthorized: () => rev.response.status(401).send(WRONG_TOKEN_ERROR), 489 | }); 490 | 491 | /** oak web framework */ 492 | const oak: OakAdapter = (ctx) => ({ 493 | update: ctx.request.body.json(), 494 | header: ctx.request.headers.get(SECRET_HEADER) || undefined, 495 | end: () => { 496 | ctx.response.status = 200; 497 | }, 498 | respond: (json) => { 499 | ctx.response.type = "json"; 500 | ctx.response.body = json; 501 | }, 502 | unauthorized: () => { 503 | ctx.response.status = 401; 504 | }, 505 | }); 506 | 507 | /** Deno.serve */ 508 | const serveHttp: ServeHttpAdapter = (requestEvent) => ({ 509 | update: requestEvent.request.json(), 510 | header: requestEvent.request.headers.get(SECRET_HEADER) || undefined, 511 | end: () => requestEvent.respondWith(ok()), 512 | respond: (json) => requestEvent.respondWith(okJson(json)), 513 | unauthorized: () => requestEvent.respondWith(unauthorized()), 514 | }); 515 | 516 | /** std/http web server */ 517 | const stdHttp: StdHttpAdapter = (req) => { 518 | let resolveResponse: (response: Response) => void; 519 | return { 520 | update: req.json(), 521 | header: req.headers.get(SECRET_HEADER) || undefined, 522 | end: () => { 523 | if (resolveResponse) resolveResponse(ok()); 524 | }, 525 | respond: (json) => { 526 | if (resolveResponse) resolveResponse(okJson(json)); 527 | }, 528 | unauthorized: () => { 529 | if (resolveResponse) resolveResponse(unauthorized()); 530 | }, 531 | handlerReturn: new Promise((resolve) => { 532 | resolveResponse = resolve; 533 | }), 534 | }; 535 | }; 536 | 537 | /** Sveltekit Serverless Functions */ 538 | const sveltekit: SveltekitAdapter = ({ request }) => { 539 | let resolveResponse: (res: Response) => void; 540 | return { 541 | update: Promise.resolve(request.json()), 542 | header: request.headers.get(SECRET_HEADER) || undefined, 543 | end: () => { 544 | if (resolveResponse) resolveResponse(ok()); 545 | }, 546 | respond: (json) => { 547 | if (resolveResponse) resolveResponse(okJson(json)); 548 | }, 549 | unauthorized: () => { 550 | if (resolveResponse) resolveResponse(unauthorized()); 551 | }, 552 | handlerReturn: new Promise((resolve) => { 553 | resolveResponse = resolve; 554 | }), 555 | }; 556 | }; 557 | /** worktop Cloudflare workers framework */ 558 | const worktop: WorktopAdapter = (req, res) => ({ 559 | update: Promise.resolve(req.json()), 560 | header: req.headers.get(SECRET_HEADER) ?? undefined, 561 | end: () => res.end(null), 562 | respond: (json) => res.send(200, json), 563 | unauthorized: () => res.send(401, WRONG_TOKEN_ERROR), 564 | }); 565 | 566 | const elysia: ElysiaAdapter = (ctx) => { 567 | // @note upgrade target to use modern code? 568 | // const { promise, resolve } = Promise.withResolvers(); 569 | 570 | let resolve: (result: string) => void; 571 | const handlerReturn = new Promise((res) => resolve = res); 572 | 573 | return { 574 | // @note technically the type shouldn't be limited to Promise, because it's fine to await plain values as well 575 | update: Promise.resolve(ctx.body as Update), 576 | header: ctx.headers[SECRET_HEADER_LOWERCASE], 577 | end() { 578 | resolve(""); 579 | }, 580 | respond(json) { 581 | // @note since json is passed as string here, we gotta define proper content-type 582 | ctx.set.headers["content-type"] = "application/json"; 583 | resolve(json); 584 | }, 585 | unauthorized() { 586 | ctx.set.status = 401; 587 | resolve(""); 588 | }, 589 | handlerReturn, 590 | }; 591 | }; 592 | 593 | // Please open a pull request if you want to add another adapter 594 | export const adapters = { 595 | "aws-lambda": awsLambda, 596 | "aws-lambda-async": awsLambdaAsync, 597 | azure, 598 | "azure-v4": azureV4, 599 | bun, 600 | cloudflare, 601 | "cloudflare-mod": cloudflareModule, 602 | elysia, 603 | express, 604 | fastify, 605 | hono, 606 | http, 607 | https: http, 608 | koa, 609 | "next-js": nextJs, 610 | nhttp, 611 | oak, 612 | serveHttp, 613 | "std/http": stdHttp, 614 | sveltekit, 615 | worktop, 616 | }; 617 | -------------------------------------------------------------------------------- /src/convenience/input_media.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type InputFile, 3 | type InputMediaAnimation, 4 | type InputMediaAudio, 5 | type InputMediaDocument, 6 | type InputMediaPhoto, 7 | type InputMediaVideo, 8 | } from "../types.ts"; 9 | 10 | type InputMediaOptions = Omit; 11 | 12 | /** 13 | * Holds a number of helper methods for building `InputMedia*` objects. They are 14 | * useful when sending media groups and when editing media messages. 15 | * 16 | * For example, media groups can be sent like this. 17 | * 18 | * ```ts 19 | * const paths = [ 20 | * '/tmp/pic0.jpg', 21 | * '/tmp/pic1.jpg', 22 | * '/tmp/pic2.jpg', 23 | * ] 24 | * const files = paths.map((path) => new InputFile(path)) 25 | * const media = files.map((file) => InputMediaBuilder.photo(file)) 26 | * await bot.api.sendMediaGroup(chatId, media) 27 | * ``` 28 | * 29 | * Media can be edited like this. 30 | * 31 | * ```ts 32 | * const file = new InputFile('/tmp/pic0.jpg') 33 | * const media = InputMediaBuilder.photo(file, { 34 | * caption: 'new caption' 35 | * }) 36 | * await bot.api.editMessageMedia(chatId, messageId, media) 37 | * ``` 38 | */ 39 | export const InputMediaBuilder = { 40 | /** 41 | * Creates a new `InputMediaPhoto` object as specified by 42 | * https://core.telegram.org/bots/api#inputmediaphoto. 43 | * 44 | * @param media An `InputFile` instance or a file identifier 45 | * @param options Remaining optional options 46 | */ 47 | photo( 48 | media: string | InputFile, 49 | options: InputMediaOptions = {}, 50 | ): InputMediaPhoto { 51 | return { type: "photo", media, ...options }; 52 | }, 53 | /** 54 | * Creates a new `InputMediaVideo` object as specified by 55 | * https://core.telegram.org/bots/api#inputmediavideo. 56 | * 57 | * @param media An `InputFile` instance or a file identifier 58 | * @param options Remaining optional options 59 | */ 60 | video( 61 | media: string | InputFile, 62 | options: InputMediaOptions = {}, 63 | ): InputMediaVideo { 64 | return { type: "video", media, ...options }; 65 | }, 66 | /** 67 | * Creates a new `InputMediaAnimation` object as specified by 68 | * https://core.telegram.org/bots/api#inputmediaanimation. 69 | * 70 | * @param media An `InputFile` instance or a file identifier 71 | * @param options Remaining optional options 72 | */ 73 | animation( 74 | media: string | InputFile, 75 | options: InputMediaOptions = {}, 76 | ): InputMediaAnimation { 77 | return { type: "animation", media, ...options }; 78 | }, 79 | /** 80 | * Creates a new `InputMediaAudio` object as specified by 81 | * https://core.telegram.org/bots/api#inputmediaaudio. 82 | * 83 | * @param media An `InputFile` instance or a file identifier 84 | * @param options Remaining optional options 85 | */ 86 | audio( 87 | media: string | InputFile, 88 | options: InputMediaOptions = {}, 89 | ): InputMediaAudio { 90 | return { type: "audio", media, ...options }; 91 | }, 92 | /** 93 | * Creates a new `InputMediaDocument` object as specified by 94 | * https://core.telegram.org/bots/api#inputmediadocument. 95 | * 96 | * @param media An `InputFile` instance or a file identifier 97 | * @param options Remaining optional options 98 | */ 99 | document( 100 | media: string | InputFile, 101 | options: InputMediaOptions = {}, 102 | ): InputMediaDocument { 103 | return { type: "document", media, ...options }; 104 | }, 105 | }; 106 | -------------------------------------------------------------------------------- /src/convenience/webhook.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any 2 | import { type Bot } from "../bot.ts"; 3 | import { type Context } from "../context.ts"; 4 | import { type WebhookReplyEnvelope } from "../core/client.ts"; 5 | import { debug as d, defaultAdapter } from "../platform.deno.ts"; 6 | import { type Update } from "../types.ts"; 7 | import { 8 | adapters as nativeAdapters, 9 | type FrameworkAdapter, 10 | } from "./frameworks.ts"; 11 | const debugErr = d("grammy:error"); 12 | 13 | const callbackAdapter: FrameworkAdapter = ( 14 | update: Update, 15 | callback: (json: string) => unknown, 16 | header: string, 17 | unauthorized = () => callback('"unauthorized"'), 18 | ) => ({ 19 | update: Promise.resolve(update), 20 | respond: callback, 21 | header, 22 | unauthorized, 23 | }); 24 | const adapters = { ...nativeAdapters, callback: callbackAdapter }; 25 | 26 | export interface WebhookOptions { 27 | /** An optional strategy to handle timeouts (default: 'throw') */ 28 | onTimeout?: "throw" | "return" | ((...args: any[]) => unknown); 29 | /** An optional number of timeout milliseconds (default: 10_000) */ 30 | timeoutMilliseconds?: number; 31 | /** An optional string to compare to X-Telegram-Bot-Api-Secret-Token */ 32 | secretToken?: string; 33 | } 34 | 35 | type Adapters = typeof adapters; 36 | type AdapterNames = keyof Adapters; 37 | type ResolveName = A extends 38 | AdapterNames ? Adapters[A] : A; 39 | 40 | /** 41 | * Creates a callback function that you can pass to a web framework (such as 42 | * express) if you want to run your bot via webhooks. Use it like this: 43 | * ```ts 44 | * const app = express() // or whatever you're using 45 | * const bot = new Bot('') 46 | * 47 | * app.use(webhookCallback(bot, 'express')) 48 | * ``` 49 | * 50 | * Confer the grammY 51 | * [documentation](https://grammy.dev/guide/deployment-types) to read more 52 | * about how to run your bot with webhooks. 53 | * 54 | * @param bot The bot for which to create a callback 55 | * @param adapter An optional string identifying the framework (default: 'express') 56 | * @param webhookOptions Further options for the webhook setup 57 | */ 58 | export function webhookCallback< 59 | C extends Context = Context, 60 | A extends FrameworkAdapter | AdapterNames = FrameworkAdapter | AdapterNames, 61 | >( 62 | bot: Bot, 63 | adapter: A, 64 | webhookOptions?: WebhookOptions, 65 | ): ( 66 | ...args: Parameters> 67 | ) => ReturnType>["handlerReturn"] extends undefined 68 | ? Promise 69 | : NonNullable>["handlerReturn"]>; 70 | export function webhookCallback< 71 | C extends Context = Context, 72 | A extends FrameworkAdapter | AdapterNames = FrameworkAdapter | AdapterNames, 73 | >( 74 | bot: Bot, 75 | adapter: A, 76 | onTimeout?: WebhookOptions["onTimeout"], 77 | timeoutMilliseconds?: WebhookOptions["timeoutMilliseconds"], 78 | secretToken?: WebhookOptions["secretToken"], 79 | ): ( 80 | ...args: Parameters> 81 | ) => ReturnType>["handlerReturn"] extends undefined 82 | ? Promise 83 | : NonNullable>["handlerReturn"]>; 84 | export function webhookCallback( 85 | bot: Bot, 86 | adapter: FrameworkAdapter | AdapterNames = defaultAdapter, 87 | onTimeout?: 88 | | WebhookOptions 89 | | WebhookOptions["onTimeout"], 90 | timeoutMilliseconds?: WebhookOptions["timeoutMilliseconds"], 91 | secretToken?: WebhookOptions["secretToken"], 92 | ) { 93 | if (bot.isRunning()) { 94 | throw new Error( 95 | "Bot is already running via long polling, the webhook setup won't receive any updates!", 96 | ); 97 | } else { 98 | bot.start = () => { 99 | throw new Error( 100 | "You already started the bot via webhooks, calling `bot.start()` starts the bot with long polling and this will prevent your webhook setup from receiving any updates!", 101 | ); 102 | }; 103 | } 104 | const { 105 | onTimeout: timeout = "throw", 106 | timeoutMilliseconds: ms = 10_000, 107 | secretToken: token, 108 | } = typeof onTimeout === "object" 109 | ? onTimeout 110 | : { onTimeout, timeoutMilliseconds, secretToken }; 111 | let initialized = false; 112 | const server: FrameworkAdapter = typeof adapter === "string" 113 | ? adapters[adapter] 114 | : adapter; 115 | return async (...args: any[]) => { 116 | const { update, respond, unauthorized, end, handlerReturn, header } = 117 | server(...args); 118 | if (!initialized) { 119 | // Will dedupe concurrently incoming calls from several updates 120 | await bot.init(); 121 | initialized = true; 122 | } 123 | if (header !== token) { 124 | await unauthorized(); 125 | // TODO: investigate deno bug that happens when this console logging is removed 126 | console.log(handlerReturn); 127 | return handlerReturn; 128 | } 129 | let usedWebhookReply = false; 130 | const webhookReplyEnvelope: WebhookReplyEnvelope = { 131 | async send(json) { 132 | usedWebhookReply = true; 133 | await respond(json); 134 | }, 135 | }; 136 | await timeoutIfNecessary( 137 | bot.handleUpdate(await update, webhookReplyEnvelope), 138 | typeof timeout === "function" ? () => timeout(...args) : timeout, 139 | ms, 140 | ); 141 | if (!usedWebhookReply) end?.(); 142 | return handlerReturn; 143 | }; 144 | } 145 | 146 | function timeoutIfNecessary( 147 | task: Promise, 148 | onTimeout: "throw" | "return" | (() => unknown), 149 | timeout: number, 150 | ): Promise { 151 | if (timeout === Infinity) return task; 152 | return new Promise((resolve, reject) => { 153 | const handle = setTimeout(() => { 154 | debugErr(`Request timed out after ${timeout} ms`); 155 | if (onTimeout === "throw") { 156 | reject(new Error(`Request timed out after ${timeout} ms`)); 157 | } else { 158 | if (typeof onTimeout === "function") onTimeout(); 159 | resolve(); 160 | } 161 | const now = Date.now(); 162 | task.finally(() => { 163 | const diff = Date.now() - now; 164 | debugErr(`Request completed ${diff} ms after timeout!`); 165 | }); 166 | }, timeout); 167 | task.then(resolve) 168 | .catch(reject) 169 | .finally(() => clearTimeout(handle)); 170 | }); 171 | } 172 | -------------------------------------------------------------------------------- /src/core/client.ts: -------------------------------------------------------------------------------- 1 | import { baseFetchConfig, debug as d } from "../platform.deno.ts"; 2 | import { 3 | type ApiMethods as Telegram, 4 | type ApiResponse, 5 | type Opts, 6 | } from "../types.ts"; 7 | import { toGrammyError, toHttpError } from "./error.ts"; 8 | import { 9 | createFormDataPayload, 10 | createJsonPayload, 11 | requiresFormDataUpload, 12 | } from "./payload.ts"; 13 | const debug = d("grammy:core"); 14 | 15 | export type Methods = string & keyof R; 16 | 17 | // Available under `bot.api.raw` 18 | /** 19 | * Represents the raw Telegram Bot API with all methods specified 1:1 as 20 | * documented on the website (https://core.telegram.org/bots/api). 21 | * 22 | * Every method takes an optional `AbortSignal` object that allows to cancel the 23 | * API call if desired. 24 | */ 25 | export type RawApi = { 26 | [M in keyof Telegram]: Parameters[0] extends undefined 27 | ? (signal?: AbortSignal) => Promise> 28 | : ( 29 | args: Opts, 30 | signal?: AbortSignal, 31 | ) => Promise>; 32 | }; 33 | 34 | export type Payload, R extends RawApi> = M extends unknown 35 | ? R[M] extends (signal?: AbortSignal) => unknown // deno-lint-ignore ban-types 36 | ? {} // deno-lint-ignore no-explicit-any 37 | : R[M] extends (args: any, signal?: AbortSignal) => unknown 38 | ? Parameters[0] 39 | : never 40 | : never; 41 | 42 | /** 43 | * Small utility interface that abstracts from webhook reply calls of different 44 | * web frameworks. 45 | */ 46 | export interface WebhookReplyEnvelope { 47 | send?: (payload: string) => void | Promise; 48 | } 49 | 50 | /** 51 | * Type of a function that can perform an API call. Used for Transformers. 52 | */ 53 | export type ApiCallFn = >( 54 | method: M, 55 | payload: Payload, 56 | signal?: AbortSignal, 57 | ) => Promise>>; 58 | 59 | type ApiCallResult, R extends RawApi> = R[M] extends 60 | (...args: unknown[]) => unknown ? Awaited> : never; 61 | 62 | /** 63 | * API call transformers are functions that can access and modify the method and 64 | * payload of an API call on the fly. This can be useful if you want to 65 | * implement rate limiting or other things against the Telegram Bot API. 66 | * 67 | * Confer the grammY 68 | * [documentation](https://grammy.dev/advanced/transformers) to read more 69 | * about how to use transformers. 70 | */ 71 | export type Transformer = >( 72 | prev: ApiCallFn, 73 | method: M, 74 | payload: Payload, 75 | signal?: AbortSignal, 76 | ) => Promise>>; 77 | export type TransformerConsumer = TransformableApi< 78 | R 79 | >["use"]; 80 | /** 81 | * A transformable API enhances the `RawApi` type by transformers. 82 | */ 83 | export interface TransformableApi { 84 | /** 85 | * Access to the raw API that the transformers will be installed on. 86 | */ 87 | raw: R; 88 | /** 89 | * Can be used to register any number of transformers on the API. 90 | */ 91 | use: (...transformers: Transformer[]) => this; 92 | /** 93 | * Returns a readonly list or the currently installed transformers. The list 94 | * is sorted by time of installation where index 0 represents the 95 | * transformer that was installed first. 96 | */ 97 | installedTransformers: Transformer[]; 98 | } 99 | 100 | // Transformer base functions 101 | function concatTransformer( 102 | prev: ApiCallFn, 103 | trans: Transformer, 104 | ): ApiCallFn { 105 | return (method, payload, signal) => trans(prev, method, payload, signal); 106 | } 107 | 108 | /** 109 | * Options to pass to the API client that eventually connects to the Telegram 110 | * Bot API server and makes the HTTP requests. 111 | */ 112 | export interface ApiClientOptions { 113 | /** 114 | * Root URL of the Telegram Bot API server. Default: 115 | * https://api.telegram.org 116 | */ 117 | apiRoot?: string; 118 | /** 119 | * Specifies whether to use the [test 120 | * environment](https://core.telegram.org/bots/webapps#using-bots-in-the-test-environment). 121 | * Can be either `"prod"` (default) or `"test"`. 122 | * 123 | * The testing infrastructure is separate from the regular production 124 | * infrastructure. No chats, accounts, or other data is shared between them. 125 | * If you set this option to `"test"`, you will need to make your Telegram 126 | * client connect to the testing data centers of Telegram, register your 127 | * phone number again, open a new chat with @BotFather, and create a 128 | * separate bot. 129 | */ 130 | environment?: "prod" | "test"; 131 | /** 132 | * URL builder function for API calls. Can be used to modify which API 133 | * server should be called. 134 | * 135 | * @param root The URL that was passed in `apiRoot`, or its default value 136 | * @param token The bot's token that was passed when creating the bot 137 | * @param method The API method to be called, e.g. `getMe` 138 | * @param env The value that was passed in `environment`, or its default value 139 | * @returns The URL that will be fetched during the API call 140 | */ 141 | buildUrl?: ( 142 | root: string, 143 | token: string, 144 | method: string, 145 | env: "prod" | "test", 146 | ) => string | URL; 147 | /** 148 | * Maximum number of seconds that a request to the Bot API server may take. 149 | * If a request has not completed before this time has elapsed, grammY 150 | * aborts the request and errors. Without such a timeout, networking issues 151 | * may cause your bot to leave open a connection indefinitely, which may 152 | * effectively make your bot freeze. 153 | * 154 | * You probably do not have to care about this option. In rare cases, you 155 | * may want to adjust it if you are transferring large files via slow 156 | * connections to your own Bot API server. 157 | * 158 | * The default number of seconds is `500`, which corresponds to 8 minutes 159 | * and 20 seconds. Note that this is also the value that is hard-coded in 160 | * the official Bot API server, so you cannot perform any successful 161 | * requests that exceed this time frame (even if you would allow it in 162 | * grammY). Setting this option to higher than the default only makes sense 163 | * with a custom Bot API server. 164 | */ 165 | timeoutSeconds?: number; 166 | /** 167 | * If the bot is running on webhooks, as soon as the bot receives an update 168 | * from Telegram, it is possible to make up to one API call in the response 169 | * to the webhook request. As a benefit, this saves your bot from making up 170 | * to one HTTP request per update. However, there are a number of drawbacks 171 | * to using this: 172 | * 1) You will not be able to handle potential errors of the respective API 173 | * call. This includes rate limiting errors, so sent messages can be 174 | * swallowed by the Bot API server and there is no way to detect if a 175 | * message was actually sent or not. 176 | * 2) More importantly, you also won't have access to the response object, 177 | * so e.g. calling `sendMessage` will not give you access to the message 178 | * you sent. 179 | * 3) Furthermore, it is not possible to cancel the request. The 180 | * `AbortSignal` will be disregarded. 181 | * 4) Note also that the types in grammY do not reflect the consequences of 182 | * a performed webhook callback! For instance, they indicate that you 183 | * always receive a response object, so it is your own responsibility to 184 | * make sure you're not screwing up while using this minor performance 185 | * optimization. 186 | * 187 | * With this warning out of the way, here is what you can do with the 188 | * `canUseWebhookReply` option: it can be used to pass a function that 189 | * determines whether to use webhook reply for the given method. It will 190 | * only be invoked if the payload can be sent as JSON. It will not be 191 | * invoked again for a given update after it returned `true`, indicating 192 | * that the API call should be performed as a webhook send. In other words, 193 | * subsequent API calls (during the same update) will always perform their 194 | * own HTTP requests. 195 | * 196 | * @param method The method to call 197 | */ 198 | canUseWebhookReply?: (method: string) => boolean; 199 | /** 200 | * Base configuration for `fetch` calls. Specify any additional parameters 201 | * to use when fetching a method of the Telegram Bot API. Default: `{ 202 | * compress: true }` (Node), `{}` (Deno) 203 | */ 204 | baseFetchConfig?: Omit< 205 | NonNullable[1]>, 206 | "method" | "headers" | "body" 207 | >; 208 | 209 | /** 210 | * `fetch` function to use for making HTTP requests. Default: `node-fetch` in Node.js, `fetch` in Deno. 211 | */ 212 | fetch?: typeof fetch; 213 | 214 | /** 215 | * When the network connection is unreliable and some API requests fail 216 | * because of that, grammY will throw errors that tell you exactly which 217 | * requests failed. However, the error messages do not disclose the fetched 218 | * URL as it contains your bot's token. Logging it may lead to token leaks. 219 | * 220 | * If you are sure that no logs are ever posted in Telegram chats, GitHub 221 | * issues, or otherwise shared, you can set this option to `true` in order 222 | * to obtain more detailed logs that may help you debug your bot. The 223 | * default value is `false`, meaning that the bot token is not logged. 224 | */ 225 | sensitiveLogs?: boolean; 226 | } 227 | 228 | class ApiClient { 229 | private readonly options: Required; 230 | 231 | private readonly fetch: typeof fetch; 232 | 233 | private hasUsedWebhookReply = false; 234 | 235 | readonly installedTransformers: Transformer[] = []; 236 | 237 | constructor( 238 | private readonly token: string, 239 | options: ApiClientOptions = {}, 240 | private readonly webhookReplyEnvelope: WebhookReplyEnvelope = {}, 241 | ) { 242 | const apiRoot = options.apiRoot ?? "https://api.telegram.org"; 243 | const environment = options.environment ?? "prod"; 244 | 245 | // In an ideal world, `fetch` is independent of the context being called, 246 | // but in a Cloudflare worker, any context other than global throws an error. 247 | // That is why we need to call custom fetch or fetch without context. 248 | const { fetch: customFetch } = options; 249 | const fetchFn = customFetch ?? fetch; 250 | 251 | this.options = { 252 | apiRoot, 253 | environment, 254 | buildUrl: options.buildUrl ?? defaultBuildUrl, 255 | timeoutSeconds: options.timeoutSeconds ?? 500, 256 | baseFetchConfig: { 257 | ...baseFetchConfig(apiRoot), 258 | ...options.baseFetchConfig, 259 | }, 260 | canUseWebhookReply: options.canUseWebhookReply ?? (() => false), 261 | sensitiveLogs: options.sensitiveLogs ?? false, 262 | fetch: 263 | ((...args: Parameters) => 264 | fetchFn(...args)) as typeof fetch, 265 | }; 266 | this.fetch = this.options.fetch; 267 | if (this.options.apiRoot.endsWith("/")) { 268 | throw new Error( 269 | `Remove the trailing '/' from the 'apiRoot' option (use '${ 270 | this.options.apiRoot.substring( 271 | 0, 272 | this.options.apiRoot.length - 1, 273 | ) 274 | }' instead of '${this.options.apiRoot}')`, 275 | ); 276 | } 277 | } 278 | 279 | private call: ApiCallFn = async >( 280 | method: M, 281 | p: Payload, 282 | signal?: AbortSignal, 283 | ) => { 284 | const payload = p ?? {}; 285 | debug(`Calling ${method}`); 286 | if (signal !== undefined) validateSignal(method, payload, signal); 287 | // General config 288 | const opts = this.options; 289 | const formDataRequired = requiresFormDataUpload(payload); 290 | // Short-circuit on webhook reply 291 | if ( 292 | this.webhookReplyEnvelope.send !== undefined && 293 | !this.hasUsedWebhookReply && 294 | !formDataRequired && 295 | opts.canUseWebhookReply(method) 296 | ) { 297 | this.hasUsedWebhookReply = true; 298 | const config = createJsonPayload({ ...payload, method }); 299 | await this.webhookReplyEnvelope.send(config.body); 300 | return { ok: true, result: true as ApiCallResult }; 301 | } 302 | // Handle timeouts and errors in the underlying form-data stream 303 | const controller = createAbortControllerFromSignal(signal); 304 | const timeout = createTimeout(controller, opts.timeoutSeconds, method); 305 | const streamErr = createStreamError(controller); 306 | // Build request URL and config 307 | const url = opts.buildUrl( 308 | opts.apiRoot, 309 | this.token, 310 | method, 311 | opts.environment, 312 | ); 313 | const config = formDataRequired 314 | ? createFormDataPayload(payload, (err) => streamErr.catch(err)) 315 | : createJsonPayload(payload); 316 | const sig = controller.signal; 317 | const options = { ...opts.baseFetchConfig, signal: sig, ...config }; 318 | // Perform fetch call, and handle networking errors 319 | const successPromise = this.fetch( 320 | url instanceof URL ? url.href : url, 321 | options, 322 | ).catch(toHttpError(method, opts.sensitiveLogs)); 323 | // Those are the three possible outcomes of the fetch call: 324 | const operations = [successPromise, streamErr.promise, timeout.promise]; 325 | // Wait for result 326 | try { 327 | const res = await Promise.race(operations); 328 | return await res.json(); 329 | } finally { 330 | if (timeout.handle !== undefined) clearTimeout(timeout.handle); 331 | } 332 | }; 333 | 334 | use(...transformers: Transformer[]) { 335 | this.call = transformers.reduce(concatTransformer, this.call); 336 | this.installedTransformers.push(...transformers); 337 | return this; 338 | } 339 | 340 | async callApi>( 341 | method: M, 342 | payload: Payload, 343 | signal?: AbortSignal, 344 | ) { 345 | const data = await this.call(method, payload, signal); 346 | if (data.ok) return data.result; 347 | else throw toGrammyError(data, method, payload); 348 | } 349 | } 350 | 351 | /** 352 | * Creates a new transformable API, i.e. an object that lets you perform raw API 353 | * calls to the Telegram Bot API server but pass the calls through a stack of 354 | * transformers before. This will create a new API client instance under the 355 | * hood that will be used to connect to the Telegram servers. You therefore need 356 | * to pass the bot token. In addition, you may pass API client options as well 357 | * as a webhook reply envelope that allows the client to perform up to one HTTP 358 | * request in response to a webhook call if this is desired. 359 | * 360 | * @param token The bot's token 361 | * @param options A number of options to pass to the created API client 362 | * @param webhookReplyEnvelope The webhook reply envelope that will be used 363 | */ 364 | export function createRawApi( 365 | token: string, 366 | options?: ApiClientOptions, 367 | webhookReplyEnvelope?: WebhookReplyEnvelope, 368 | ): TransformableApi { 369 | const client = new ApiClient(token, options, webhookReplyEnvelope); 370 | 371 | const proxyHandler: ProxyHandler = { 372 | get(_, m: Methods | "toJSON") { 373 | return m === "toJSON" 374 | ? "__internal" 375 | // Methods with zero parameters are called without any payload, 376 | // so we have to manually inject an empty payload. 377 | : m === "getMe" || 378 | m === "getWebhookInfo" || 379 | m === "getForumTopicIconStickers" || 380 | m === "getAvailableGifts" || 381 | m === "logOut" || 382 | m === "close" 383 | ? client.callApi.bind(client, m, {} as Payload) 384 | : client.callApi.bind(client, m); 385 | }, 386 | ...proxyMethods, 387 | }; 388 | const raw = new Proxy({} as R, proxyHandler); 389 | const installedTransformers = client.installedTransformers; 390 | const api: TransformableApi = { 391 | raw, 392 | installedTransformers, 393 | use: (...t) => { 394 | client.use(...t); 395 | return api; 396 | }, 397 | }; 398 | 399 | return api; 400 | } 401 | 402 | const defaultBuildUrl: NonNullable = ( 403 | root, 404 | token, 405 | method, 406 | env, 407 | ) => { 408 | const prefix = env === "test" ? "test/" : ""; 409 | return `${root}/bot${token}/${prefix}${method}`; 410 | }; 411 | 412 | const proxyMethods = { 413 | set() { 414 | return false; 415 | }, 416 | defineProperty() { 417 | return false; 418 | }, 419 | deleteProperty() { 420 | return false; 421 | }, 422 | ownKeys() { 423 | return []; 424 | }, 425 | }; 426 | 427 | /** A container for a rejecting promise */ 428 | interface AsyncError { 429 | promise: Promise; 430 | } 431 | /** An async error caused by a timeout */ 432 | interface Timeout extends AsyncError { 433 | handle: ReturnType | undefined; 434 | } 435 | /** An async error caused by an error in an underlying resource stream */ 436 | interface StreamError extends AsyncError { 437 | catch: (err: unknown) => void; 438 | } 439 | 440 | /** Creates a timeout error which aborts a given controller */ 441 | function createTimeout( 442 | controller: AbortController, 443 | seconds: number, 444 | method: string, 445 | ): Timeout { 446 | let handle: Timeout["handle"] = undefined; 447 | const promise = new Promise((_, reject) => { 448 | handle = setTimeout(() => { 449 | const msg = 450 | `Request to '${method}' timed out after ${seconds} seconds`; 451 | reject(new Error(msg)); 452 | controller.abort(); 453 | }, 1000 * seconds); 454 | }); 455 | return { promise, handle }; 456 | } 457 | /** Creates a stream error which abort a given controller */ 458 | function createStreamError(abortController: AbortController): StreamError { 459 | let onError: StreamError["catch"] = (err) => { 460 | // Re-throw by default, but will be overwritten immediately 461 | throw err; 462 | }; 463 | const promise = new Promise((_, reject) => { 464 | onError = (err: unknown) => { 465 | reject(err); 466 | abortController.abort(); 467 | }; 468 | }); 469 | return { promise, catch: onError }; 470 | } 471 | 472 | function createAbortControllerFromSignal(signal?: AbortSignal) { 473 | const abortController = new AbortController(); 474 | if (signal === undefined) return abortController; 475 | const sig = signal; 476 | function abort() { 477 | abortController.abort(); 478 | sig.removeEventListener("abort", abort); 479 | } 480 | if (sig.aborted) abort(); 481 | else sig.addEventListener("abort", abort); 482 | return { abort, signal: abortController.signal }; 483 | } 484 | 485 | function validateSignal( 486 | method: string, 487 | payload: Record, 488 | signal: AbortSignal, 489 | ) { 490 | // We use a very simple heuristic to check for AbortSignal instances 491 | // in order to avoid doing a runtime-specific version of `instanceof`. 492 | if (typeof signal?.addEventListener === "function") { 493 | return; 494 | } 495 | 496 | let payload0 = JSON.stringify(payload); 497 | if (payload0.length > 20) { 498 | payload0 = payload0.substring(0, 16) + " ..."; 499 | } 500 | let payload1 = JSON.stringify(signal); 501 | if (payload1.length > 20) { 502 | payload1 = payload1.substring(0, 16) + " ..."; 503 | } 504 | throw new Error( 505 | `Incorrect abort signal instance found! \ 506 | You passed two payloads to '${method}' but you should merge \ 507 | the second one containing '${payload1}' into the first one \ 508 | containing '${payload0}'! If you are using context shortcuts, \ 509 | you may want to use a method on 'ctx.api' instead. 510 | 511 | If you want to prevent such mistakes in the future, \ 512 | consider using TypeScript. https://www.typescriptlang.org/`, 513 | ); 514 | } 515 | -------------------------------------------------------------------------------- /src/core/error.ts: -------------------------------------------------------------------------------- 1 | import { type ApiError, type ResponseParameters } from "../types.ts"; 2 | import { debug as d } from "../platform.deno.ts"; 3 | const debug = d("grammy:warn"); 4 | 5 | /** 6 | * This class represents errors that are thrown by grammY because the Telegram 7 | * Bot API responded with an error. 8 | * 9 | * Instances of this class hold the information that the Telegram backend 10 | * returned. 11 | * 12 | * If this error is thrown, grammY could successfully communicate with the 13 | * Telegram Bot API servers, however, an error code was returned for the 14 | * respective method call. 15 | */ 16 | export class GrammyError extends Error implements ApiError { 17 | /** Flag that this request was unsuccessful. Always `false`. */ 18 | public readonly ok: false = false; 19 | /** An integer holding Telegram's error code. Subject to change. */ 20 | public readonly error_code: number; 21 | /** A human-readable description of the error. */ 22 | public readonly description: string; 23 | /** Further parameters that may help to automatically handle the error. */ 24 | public readonly parameters: ResponseParameters; 25 | constructor( 26 | message: string, 27 | err: ApiError, 28 | /** The called method name which caused this error to be thrown. */ 29 | public readonly method: string, 30 | /** The payload that was passed when calling the method. */ 31 | public readonly payload: Record, 32 | ) { 33 | super(`${message} (${err.error_code}: ${err.description})`); 34 | this.name = "GrammyError"; 35 | this.error_code = err.error_code; 36 | this.description = err.description; 37 | this.parameters = err.parameters ?? {}; 38 | } 39 | } 40 | export function toGrammyError( 41 | err: ApiError, 42 | method: string, 43 | payload: Record, 44 | ) { 45 | switch (err.error_code) { 46 | case 401: 47 | debug( 48 | "Error 401 means that your bot token is wrong, talk to https://t.me/BotFather to check it.", 49 | ); 50 | break; 51 | case 409: 52 | debug( 53 | "Error 409 means that you are running your bot several times on long polling. Consider revoking the bot token if you believe that no other instance is running.", 54 | ); 55 | break; 56 | } 57 | return new GrammyError( 58 | `Call to '${method}' failed!`, 59 | err, 60 | method, 61 | payload, 62 | ); 63 | } 64 | 65 | /** 66 | * This class represents errors that are thrown by grammY because an HTTP call 67 | * to the Telegram Bot API failed. 68 | * 69 | * Instances of this class hold the error object that was created because the 70 | * fetch call failed. It can be inspected to determine why exactly the network 71 | * request failed. 72 | * 73 | * If an [API transformer 74 | * function](https://grammy.dev/advanced/transformers) throws an error, 75 | * grammY will regard this as if the network request failed. The contained error 76 | * will then be the error that was thrown by the transformer function. 77 | */ 78 | export class HttpError extends Error { 79 | constructor( 80 | message: string, 81 | /** The thrown error object. */ 82 | public readonly error: unknown, 83 | ) { 84 | super(message); 85 | this.name = "HttpError"; 86 | } 87 | } 88 | 89 | function isTelegramError( 90 | err: unknown, 91 | ): err is { status: string; statusText: string } { 92 | return ( 93 | typeof err === "object" && 94 | err !== null && 95 | "status" in err && 96 | "statusText" in err 97 | ); 98 | } 99 | export function toHttpError(method: string, sensitiveLogs: boolean) { 100 | return (err: unknown) => { 101 | let msg = `Network request for '${method}' failed!`; 102 | if (isTelegramError(err)) msg += ` (${err.status}: ${err.statusText})`; 103 | if (sensitiveLogs && err instanceof Error) msg += ` ${err.message}`; 104 | throw new HttpError(msg, err); 105 | }; 106 | } 107 | -------------------------------------------------------------------------------- /src/core/payload.ts: -------------------------------------------------------------------------------- 1 | import { itrToStream } from "../platform.deno.ts"; 2 | import { InputFile } from "../types.ts"; 3 | 4 | // === Payload types (JSON vs. form data) 5 | /** 6 | * Determines for a given payload if it may be sent as JSON, or if it has to be 7 | * uploaded via multipart/form-data. Returns `true` in the latter case and 8 | * `false` in the former. 9 | * 10 | * @param payload The payload to analyze 11 | */ 12 | export function requiresFormDataUpload(payload: unknown): boolean { 13 | return payload instanceof InputFile || ( 14 | typeof payload === "object" && 15 | payload !== null && 16 | Object.values(payload).some((v) => 17 | Array.isArray(v) 18 | ? v.some(requiresFormDataUpload) 19 | : v instanceof InputFile || requiresFormDataUpload(v) 20 | ) 21 | ); 22 | } 23 | /** 24 | * Calls `JSON.stringify` but removes `null` values from objects before 25 | * serialization 26 | * 27 | * @param value value 28 | * @returns stringified value 29 | */ 30 | function str(value: unknown) { 31 | return JSON.stringify(value, (_, v) => v ?? undefined); 32 | } 33 | /** 34 | * Turns a payload into an options object that can be passed to a `fetch` call 35 | * by setting the necessary headers and method. May only be called for payloads 36 | * `P` that let `requiresFormDataUpload(P)` return `false`. 37 | * 38 | * @param payload The payload to wrap 39 | */ 40 | export function createJsonPayload(payload: Record) { 41 | return { 42 | method: "POST", 43 | headers: { 44 | "content-type": "application/json", 45 | connection: "keep-alive", 46 | }, 47 | body: str(payload), 48 | }; 49 | } 50 | async function* protectItr( 51 | itr: AsyncIterableIterator, 52 | onError: (err: unknown) => void, 53 | ) { 54 | try { 55 | yield* itr; 56 | } catch (err) { 57 | onError(err); 58 | } 59 | } 60 | /** 61 | * Turns a payload into an options object that can be passed to a `fetch` call 62 | * by setting the necessary headers and method. Note that this method creates a 63 | * multipart/form-data stream under the hood. If possible, a JSON payload should 64 | * be created instead for performance reasons. 65 | * 66 | * @param payload The payload to wrap 67 | */ 68 | export function createFormDataPayload( 69 | payload: Record, 70 | onError: (err: unknown) => void, 71 | ) { 72 | const boundary = createBoundary(); 73 | const itr = payloadToMultipartItr(payload, boundary); 74 | const safeItr = protectItr(itr, onError); 75 | const stream = itrToStream(safeItr); 76 | return { 77 | method: "POST", 78 | headers: { 79 | "content-type": `multipart/form-data; boundary=${boundary}`, 80 | connection: "keep-alive", 81 | }, 82 | body: stream, 83 | }; 84 | } 85 | 86 | // === Form data creation 87 | function createBoundary() { 88 | // Taken from Deno std lib 89 | return "----------" + randomId(32); 90 | } 91 | function randomId(length = 16) { 92 | return Array.from(Array(length)) 93 | .map(() => Math.random().toString(36)[2] || 0) 94 | .join(""); 95 | } 96 | 97 | const enc = new TextEncoder(); 98 | /** 99 | * Takes a payload object and produces a valid multipart/form-data stream. The 100 | * stream is an iterator of `Uint8Array` objects. You also need to specify the 101 | * boundary string that was used in the Content-Type header of the HTTP request. 102 | * 103 | * @param payload a payload object 104 | * @param boundary the boundary string to use between the parts 105 | */ 106 | async function* payloadToMultipartItr( 107 | payload: Record, 108 | boundary: string, 109 | ): AsyncIterableIterator { 110 | const files = extractFiles(payload); 111 | // Start multipart/form-data protocol 112 | yield enc.encode(`--${boundary}\r\n`); 113 | // Send all payload fields 114 | const separator = enc.encode(`\r\n--${boundary}\r\n`); 115 | let first = true; 116 | for (const [key, value] of Object.entries(payload)) { 117 | if (value == null) continue; 118 | if (!first) yield separator; 119 | yield valuePart(key, typeof value === "object" ? str(value) : value); 120 | first = false; 121 | } 122 | // Send all files 123 | for (const { id, origin, file } of files) { 124 | if (!first) yield separator; 125 | yield* filePart(id, origin, file); 126 | first = false; 127 | } 128 | // End multipart/form-data protocol 129 | yield enc.encode(`\r\n--${boundary}--\r\n`); 130 | } 131 | 132 | /** Information about a file extracted from a payload */ 133 | type ExtractedFile = { 134 | /** To be used in the attach:// string */ 135 | id: string; 136 | /** Hints about where the file came from, useful for filename guessing */ 137 | origin: string; 138 | /** The extracted file */ 139 | file: InputFile; 140 | }; 141 | /** 142 | * Replaces all instances of `InputFile` in a given payload by attach:// 143 | * strings. This alters the passed object. After calling this method, the 144 | * payload object can be stringified. 145 | * 146 | * Returns a list of `InputFile` instances along with the random identifiers 147 | * that were used in the corresponding attach:// strings, as well as the origin 148 | * keys of the original payload object. 149 | * 150 | * @param value a payload object, or a part of it 151 | * @param key the origin key of the payload object, if a part of it is passed 152 | * @returns the cleaned payload object 153 | */ 154 | function extractFiles(value: unknown): ExtractedFile[] { 155 | if (typeof value !== "object" || value === null) return []; 156 | return Object.entries(value).flatMap(([k, v]) => { 157 | if (Array.isArray(v)) return v.flatMap((p) => extractFiles(p)); 158 | else if (v instanceof InputFile) { 159 | const id = randomId(); 160 | // Overwrite `InputFile` instance with attach:// string 161 | Object.assign(value, { [k]: `attach://${id}` }); 162 | const origin = k === "media" && 163 | "type" in value && typeof value.type === "string" 164 | ? value.type // use `type` for `InputMedia*` 165 | : k; // use property key otherwise 166 | return { id, origin, file: v }; 167 | } else return extractFiles(v); 168 | }); 169 | } 170 | 171 | /** Turns a regular value into a `Uint8Array` */ 172 | function valuePart(key: string, value: unknown): Uint8Array { 173 | return enc.encode( 174 | `content-disposition:form-data;name="${key}"\r\n\r\n${value}`, 175 | ); 176 | } 177 | /** Turns an InputFile into a generator of `Uint8Array`s */ 178 | async function* filePart( 179 | id: string, 180 | origin: string, 181 | input: InputFile, 182 | ): AsyncIterableIterator { 183 | const filename = input.filename || `${origin}.${getExt(origin)}`; 184 | if (filename.includes("\r") || filename.includes("\n")) { 185 | throw new Error( 186 | `File paths cannot contain carriage-return (\\r) \ 187 | or newline (\\n) characters! Filename for property '${origin}' was: 188 | """ 189 | ${filename} 190 | """`, 191 | ); 192 | } 193 | yield enc.encode( 194 | `content-disposition:form-data;name="${id}";filename=${filename}\r\ncontent-type:application/octet-stream\r\n\r\n`, 195 | ); 196 | const data = await input.toRaw(); 197 | if (data instanceof Uint8Array) yield data; 198 | else yield* data; 199 | } 200 | /** Returns the default file extension for an API property name */ 201 | function getExt(key: string) { 202 | switch (key) { 203 | case "certificate": 204 | return "pem"; 205 | case "photo": 206 | case "thumbnail": 207 | return "jpg"; 208 | case "voice": 209 | return "ogg"; 210 | case "audio": 211 | return "mp3"; 212 | case "animation": 213 | case "video": 214 | case "video_note": 215 | return "mp4"; 216 | case "sticker": 217 | return "webp"; 218 | default: 219 | return "dat"; 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/mod.ts: -------------------------------------------------------------------------------- 1 | // Commonly used stuff 2 | export { 3 | Bot, 4 | type BotConfig, 5 | BotError, 6 | type ErrorHandler, 7 | type PollingOptions, 8 | } from "./bot.ts"; 9 | 10 | export { InputFile } from "./types.ts"; 11 | 12 | export { 13 | type CallbackQueryContext, 14 | type ChatTypeContext, 15 | type ChosenInlineResultContext, 16 | type CommandContext, 17 | Context, 18 | type GameQueryContext, 19 | type HearsContext, 20 | type InlineQueryContext, 21 | type ReactionContext, 22 | } from "./context.ts"; 23 | 24 | // Convenience stuff, built-in plugins, and helpers 25 | export * from "./convenience/constants.ts"; 26 | export * from "./convenience/inline_query.ts"; 27 | export * from "./convenience/input_media.ts"; 28 | export * from "./convenience/keyboard.ts"; 29 | export * from "./convenience/session.ts"; 30 | export * from "./convenience/webhook.ts"; 31 | 32 | // A little more advanced stuff 33 | export { 34 | type CallbackQueryMiddleware, 35 | type ChatTypeMiddleware, 36 | type CommandMiddleware, 37 | Composer, 38 | type GameQueryMiddleware, 39 | type HearsMiddleware, 40 | type InlineQueryMiddleware, 41 | type Middleware, 42 | type MiddlewareFn, 43 | type MiddlewareObj, 44 | type NextFunction, 45 | type ReactionMiddleware, 46 | } from "./composer.ts"; 47 | 48 | export { type Filter, type FilterQuery, matchFilter } from "./filter.ts"; 49 | 50 | // Internal stuff for expert users 51 | export { Api } from "./core/api.ts"; 52 | export { 53 | type ApiCallFn, 54 | type ApiClientOptions, 55 | type RawApi, 56 | type TransformableApi, 57 | type Transformer, 58 | type WebhookReplyEnvelope, 59 | } from "./core/client.ts"; 60 | export { GrammyError, HttpError } from "./core/error.ts"; 61 | -------------------------------------------------------------------------------- /src/platform.deno.ts: -------------------------------------------------------------------------------- 1 | /** Are we running on Deno or in a web browser? */ 2 | export const isDeno = typeof Deno !== "undefined"; 3 | 4 | // === Export debug 5 | import debug from "https://cdn.skypack.dev/debug@4.3.4"; 6 | export { debug }; 7 | const DEBUG = "DEBUG"; 8 | if (isDeno) { 9 | debug.useColors = () => !Deno.noColor; 10 | const env = { name: "env", variable: DEBUG } as const; 11 | const res = await Deno.permissions.query(env); 12 | let namespace: string | undefined = undefined; 13 | if (res.state === "granted") namespace = Deno.env.get(DEBUG); 14 | if (namespace) debug.enable(namespace); 15 | else debug.disable(); 16 | } 17 | 18 | // === Export system-specific operations 19 | // Turn an AsyncIterable into a stream 20 | export const itrToStream = (itr: AsyncIterable) => 21 | ReadableStream.from(itr); 22 | 23 | // === Base configuration for `fetch` calls 24 | export const baseFetchConfig = (_apiRoot: string) => ({}); 25 | 26 | // === Default webhook adapter 27 | export const defaultAdapter = "oak"; 28 | -------------------------------------------------------------------------------- /src/platform.node.ts: -------------------------------------------------------------------------------- 1 | // === Needed imports 2 | import { Agent as HttpAgent } from "http"; 3 | import { Agent as HttpsAgent } from "https"; 4 | import { Readable } from "stream"; 5 | 6 | // === Export debug 7 | export { debug } from "debug"; 8 | 9 | // === Export system-specific operations 10 | // Turn an AsyncIterable into a stream 11 | export const itrToStream = (itr: AsyncIterable) => 12 | Readable.from(itr, { objectMode: false }); 13 | 14 | // === Base configuration for `fetch` calls 15 | const httpAgents = new Map(); 16 | const httpsAgents = new Map(); 17 | function getCached(map: Map, key: K, otherwise: () => V) { 18 | let value = map.get(key); 19 | if (value === undefined) { 20 | value = otherwise(); 21 | map.set(key, value); 22 | } 23 | return value; 24 | } 25 | export function baseFetchConfig(apiRoot: string) { 26 | if (apiRoot.startsWith("https:")) { 27 | return { 28 | compress: true, 29 | agent: getCached( 30 | httpsAgents, 31 | apiRoot, 32 | () => new HttpsAgent({ keepAlive: true }), 33 | ), 34 | }; 35 | } else if (apiRoot.startsWith("http:")) { 36 | return { 37 | agent: getCached( 38 | httpAgents, 39 | apiRoot, 40 | () => new HttpAgent({ keepAlive: true }), 41 | ), 42 | }; 43 | } else return {}; 44 | } 45 | 46 | // === Default webhook adapter 47 | export const defaultAdapter = "express"; 48 | -------------------------------------------------------------------------------- /src/platform.web.ts: -------------------------------------------------------------------------------- 1 | import d from "https://cdn.skypack.dev/debug@4.3.4"; 2 | export { d as debug }; 3 | 4 | // === Export system-specific operations 5 | // Turn an AsyncIterable into a stream 6 | export const itrToStream = (itr: AsyncIterable) => { 7 | // do not assume ReadableStream.from to exist yet 8 | const it = itr[Symbol.asyncIterator](); 9 | return new ReadableStream({ 10 | async pull(controller) { 11 | const chunk = await it.next(); 12 | if (chunk.done) controller.close(); 13 | else controller.enqueue(chunk.value); 14 | }, 15 | }); 16 | }; 17 | 18 | // === Base configuration for `fetch` calls 19 | export const baseFetchConfig = (_apiRoot: string) => ({}); 20 | 21 | export const defaultAdapter = "cloudflare"; 22 | -------------------------------------------------------------------------------- /src/shim.node.ts: -------------------------------------------------------------------------------- 1 | export { AbortController, type AbortSignal } from "abort-controller"; 2 | export { default as fetch } from "node-fetch"; 3 | -------------------------------------------------------------------------------- /src/types.deno.ts: -------------------------------------------------------------------------------- 1 | // === Needed imports 2 | import { basename } from "jsr:@std/path@1.0.9/basename"; 3 | 4 | import { 5 | type ApiMethods as ApiMethodsF, 6 | type InputMedia as InputMediaF, 7 | type InputMediaAnimation as InputMediaAnimationF, 8 | type InputMediaAudio as InputMediaAudioF, 9 | type InputMediaDocument as InputMediaDocumentF, 10 | type InputMediaPhoto as InputMediaPhotoF, 11 | type InputMediaVideo as InputMediaVideoF, 12 | type InputPaidMedia as InputPaidMediaF, 13 | type InputPaidMediaPhoto as InputPaidMediaPhotoF, 14 | type InputPaidMediaVideo as InputPaidMediaVideoF, 15 | type InputProfilePhoto as InputProfilePhotoAnimatedF, 16 | type InputProfilePhoto as InputProfilePhotoF, 17 | type InputProfilePhoto as InputProfilePhotoStaticF, 18 | type InputSticker as InputStickerF, 19 | type InputStoryContent as InputStoryContentF, 20 | type InputStoryContentPhoto as InputStoryContentPhotoF, 21 | type InputStoryContentVideo as InputStoryContentVideoF, 22 | type Opts as OptsF, 23 | } from "https://deno.land/x/grammy_types@v3.20.0/mod.ts"; 24 | import { debug as d, isDeno } from "./platform.deno.ts"; 25 | 26 | const debug = d("grammy:warn"); 27 | 28 | // === Export all API types 29 | export * from "https://deno.land/x/grammy_types@v3.20.0/mod.ts"; 30 | 31 | /** A value, or a potentially async function supplying that value */ 32 | type MaybeSupplier = T | (() => T | Promise); 33 | /** Something that looks like a URL. */ 34 | interface URLLike { 35 | /** 36 | * Identifier of the resource. Must be in a format that can be parsed by the 37 | * URL constructor. 38 | */ 39 | url: string; 40 | } 41 | 42 | // === InputFile handling and File augmenting 43 | /** 44 | * An `InputFile` wraps a number of different sources for [sending 45 | * files](https://grammy.dev/guide/files#uploading-your-own-files). 46 | * 47 | * It corresponds to the `InputFile` type in the [Telegram Bot API 48 | * Reference](https://core.telegram.org/bots/api#inputfile). 49 | */ 50 | export class InputFile { 51 | private consumed = false; 52 | private readonly fileData: ConstructorParameters[0]; 53 | /** 54 | * Optional name of the constructed `InputFile` instance. 55 | * 56 | * Check out the 57 | * [documentation](https://grammy.dev/guide/files#uploading-your-own-files) 58 | * on sending files with `InputFile`. 59 | */ 60 | public readonly filename?: string; 61 | /** 62 | * Constructs an `InputFile` that can be used in the API to send files. 63 | * 64 | * @param file A path to a local file or a `Buffer` or a `ReadableStream` that specifies the file data 65 | * @param filename Optional name of the file 66 | */ 67 | constructor( 68 | file: MaybeSupplier< 69 | | string 70 | | Blob 71 | | Deno.FsFile 72 | | Response 73 | | URL 74 | | URLLike 75 | | Uint8Array 76 | | ReadableStream 77 | | Iterable 78 | | AsyncIterable 79 | >, 80 | filename?: string, 81 | ) { 82 | this.fileData = file; 83 | filename ??= this.guessFilename(file); 84 | this.filename = filename; 85 | if ( 86 | typeof file === "string" && 87 | (file.startsWith("http:") || file.startsWith("https:")) 88 | ) { 89 | debug( 90 | `InputFile received the local file path '${file}' that looks like a URL. Is this a mistake?`, 91 | ); 92 | } 93 | } 94 | private guessFilename( 95 | file: ConstructorParameters[0], 96 | ): string | undefined { 97 | if (typeof file === "string") return basename(file); 98 | if ("url" in file) return basename(file.url); 99 | if (!(file instanceof URL)) return undefined; 100 | if (file.pathname !== "/") { 101 | const filename = basename(file.pathname); 102 | if (filename) return filename; 103 | } 104 | return basename(file.hostname); 105 | } 106 | /** 107 | * Internal method. Do not use. 108 | * 109 | * Converts this instance into a binary representation that can be sent to 110 | * the Bot API server in the request body. 111 | */ 112 | async toRaw(): Promise< 113 | Uint8Array | Iterable | AsyncIterable 114 | > { 115 | if (this.consumed) { 116 | throw new Error("Cannot reuse InputFile data source!"); 117 | } 118 | const data = this.fileData; 119 | // Handle local files 120 | if (typeof data === "string") { 121 | if (!isDeno) { 122 | throw new Error( 123 | "Reading files by path requires a Deno environment", 124 | ); 125 | } 126 | const file = await Deno.open(data); 127 | return file.readable[Symbol.asyncIterator](); 128 | } 129 | if (data instanceof Blob) return data.stream(); 130 | if (isDenoFile(data)) return data.readable[Symbol.asyncIterator](); 131 | // Handle Response objects 132 | if (data instanceof Response) { 133 | if (data.body === null) throw new Error(`No response body!`); 134 | return data.body; 135 | } 136 | // Handle URL and URLLike objects 137 | if (data instanceof URL) return await fetchFile(data); 138 | if ("url" in data) return await fetchFile(data.url); 139 | // Return buffers as-is 140 | if (data instanceof Uint8Array) return data; 141 | // Unwrap supplier functions 142 | if (typeof data === "function") { 143 | return new InputFile(await data()).toRaw(); 144 | } 145 | // Mark streams and iterators as consumed and return them as-is 146 | this.consumed = true; 147 | return data; 148 | } 149 | } 150 | 151 | async function fetchFile( 152 | url: string | URL, 153 | ): Promise> { 154 | const { body } = await fetch(url); 155 | if (body === null) { 156 | throw new Error(`Download failed, no response body from '${url}'`); 157 | } 158 | return body[Symbol.asyncIterator](); 159 | } 160 | function isDenoFile(data: unknown): data is Deno.FsFile { 161 | return isDeno && data instanceof Deno.FsFile; 162 | } 163 | 164 | // === Export InputFile types 165 | /** Wrapper type to bundle all methods of the Telegram API */ 166 | export type ApiMethods = ApiMethodsF; 167 | 168 | /** Utility type providing the argument type for the given method name or `{}` if the method does not take any parameters */ 169 | export type Opts = OptsF[M]; 170 | 171 | /** This object describes a sticker to be added to a sticker set. */ 172 | export type InputSticker = InputStickerF; 173 | 174 | /** This object represents the content of a media message to be sent. It should be one of 175 | - InputMediaAnimation 176 | - InputMediaDocument 177 | - InputMediaAudio 178 | - InputMediaPhoto 179 | - InputMediaVideo */ 180 | export type InputMedia = InputMediaF; 181 | /** Represents a photo to be sent. */ 182 | export type InputMediaPhoto = InputMediaPhotoF; 183 | /** Represents a video to be sent. */ 184 | export type InputMediaVideo = InputMediaVideoF; 185 | /** Represents an animation file (GIF or H.264/MPEG-4 AVC video without sound) to be sent. */ 186 | export type InputMediaAnimation = InputMediaAnimationF; 187 | /** Represents an audio file to be treated as music to be sent. */ 188 | export type InputMediaAudio = InputMediaAudioF; 189 | /** Represents a general file to be sent. */ 190 | export type InputMediaDocument = InputMediaDocumentF; 191 | /** This object describes the paid media to be sent. Currently, it can be one of 192 | - InputPaidMediaPhoto 193 | - InputPaidMediaVideo */ 194 | export type InputPaidMedia = InputPaidMediaF; 195 | /** The paid media to send is a photo. */ 196 | export type InputPaidMediaPhoto = InputPaidMediaPhotoF; 197 | /** The paid media to send is a video. */ 198 | export type InputPaidMediaVideo = InputPaidMediaVideoF; 199 | /** This object describes a profile photo to set. Currently, it can be one of 200 | - InputProfilePhotoStatic 201 | - InputProfilePhotoAnimated */ 202 | export type InputProfilePhoto = InputProfilePhotoF; 203 | /** A static profile photo in the .JPG format. */ 204 | export type InputProfilePhotoStatic = InputProfilePhotoStaticF; 205 | /** An animated profile photo in the MPEG4 format. */ 206 | export type InputProfilePhotoAnimated = InputProfilePhotoAnimatedF; 207 | /** This object describes the content of a story to post. Currently, it can be one of 208 | - InputStoryContentPhoto 209 | - InputStoryContentVideo */ 210 | export type InputStoryContent = InputStoryContentF; 211 | /** Describes a photo to post as a story. */ 212 | export type InputStoryContentPhoto = InputStoryContentPhotoF; 213 | /** Describes a video to post as a story. */ 214 | export type InputStoryContentVideo = InputStoryContentVideoF; 215 | -------------------------------------------------------------------------------- /src/types.node.ts: -------------------------------------------------------------------------------- 1 | // === Needed imports 2 | import { 3 | type ApiMethods as ApiMethodsF, 4 | type InputMedia as InputMediaF, 5 | type InputMediaAnimation as InputMediaAnimationF, 6 | type InputMediaAudio as InputMediaAudioF, 7 | type InputMediaDocument as InputMediaDocumentF, 8 | type InputMediaPhoto as InputMediaPhotoF, 9 | type InputMediaVideo as InputMediaVideoF, 10 | type InputPaidMedia as InputPaidMediaF, 11 | type InputPaidMediaPhoto as InputPaidMediaPhotoF, 12 | type InputPaidMediaVideo as InputPaidMediaVideoF, 13 | type InputProfilePhoto as InputProfilePhotoAnimatedF, 14 | type InputProfilePhoto as InputProfilePhotoF, 15 | type InputProfilePhoto as InputProfilePhotoStaticF, 16 | type InputSticker as InputStickerF, 17 | type InputStoryContent as InputStoryContentF, 18 | type InputStoryContentPhoto as InputStoryContentPhotoF, 19 | type InputStoryContentVideo as InputStoryContentVideoF, 20 | type Opts as OptsF, 21 | } from "@grammyjs/types"; 22 | import { createReadStream, type ReadStream } from "fs"; 23 | import fetch from "node-fetch"; 24 | import { basename } from "path"; 25 | import { debug as d } from "./platform.node"; 26 | 27 | const debug = d("grammy:warn"); 28 | 29 | // === Export all API types 30 | export * from "@grammyjs/types"; 31 | 32 | /** A value, or a potentially async function supplying that value */ 33 | type MaybeSupplier = T | (() => T | Promise); 34 | /** Something that looks like a URL. */ 35 | interface URLLike { 36 | /** 37 | * Identifier of the resource. Must be in a format that can be parsed by the 38 | * URL constructor. 39 | */ 40 | url: string; 41 | } 42 | 43 | // === InputFile handling and File augmenting 44 | /** 45 | * An `InputFile` wraps a number of different sources for [sending 46 | * files](https://grammy.dev/guide/files#uploading-your-own-files). 47 | * 48 | * It corresponds to the `InputFile` type in the [Telegram Bot API 49 | * Reference](https://core.telegram.org/bots/api#inputfile). 50 | */ 51 | export class InputFile { 52 | private consumed = false; 53 | private readonly fileData: ConstructorParameters[0]; 54 | /** 55 | * Optional name of the constructed `InputFile` instance. 56 | * 57 | * Check out the 58 | * [documentation](https://grammy.dev/guide/files#uploading-your-own-files) 59 | * on sending files with `InputFile`. 60 | */ 61 | public readonly filename?: string; 62 | /** 63 | * Constructs an `InputFile` that can be used in the API to send files. 64 | * 65 | * @param file A path to a local file or a `Buffer` or a `fs.ReadStream` that specifies the file data 66 | * @param filename Optional name of the file 67 | */ 68 | constructor( 69 | file: MaybeSupplier< 70 | | string 71 | | URL 72 | | URLLike 73 | | Uint8Array 74 | | ReadStream 75 | | Iterable 76 | | AsyncIterable 77 | >, 78 | filename?: string, 79 | ) { 80 | this.fileData = file; 81 | filename ??= this.guessFilename(file); 82 | this.filename = filename; 83 | if ( 84 | typeof file === "string" && 85 | (file.startsWith("http:") || file.startsWith("https:")) 86 | ) { 87 | debug( 88 | `InputFile received the local file path '${file}' that looks like a URL. Is this a mistake?`, 89 | ); 90 | } 91 | } 92 | private guessFilename( 93 | file: ConstructorParameters[0], 94 | ): string | undefined { 95 | if (typeof file === "string") return basename(file); 96 | if ("url" in file) return basename(file.url); 97 | if (!(file instanceof URL)) return undefined; 98 | if (file.pathname !== "/") { 99 | const filename = basename(file.pathname); 100 | if (filename) return filename; 101 | } 102 | return basename(file.hostname); 103 | } 104 | /** 105 | * Internal method. Do not use. 106 | * 107 | * Converts this instance into a binary representation that can be sent to 108 | * the Bot API server in the request body. 109 | */ 110 | async toRaw(): Promise< 111 | Uint8Array | Iterable | AsyncIterable 112 | > { 113 | if (this.consumed) { 114 | throw new Error("Cannot reuse InputFile data source!"); 115 | } 116 | const data = this.fileData; 117 | // Handle local files 118 | if (typeof data === "string") return createReadStream(data); 119 | // Handle URLs and URLLike objects 120 | if (data instanceof URL) { 121 | return data.protocol === "file" // node-fetch does not support file URLs 122 | ? createReadStream(data.pathname) 123 | : fetchFile(data); 124 | } 125 | if ("url" in data) return fetchFile(data.url); 126 | // Return buffers as-is 127 | if (data instanceof Uint8Array) return data; 128 | // Unwrap supplier functions 129 | if (typeof data === "function") { 130 | return new InputFile(await data()).toRaw(); 131 | } 132 | // Mark streams and iterators as consumed and return them as-is 133 | this.consumed = true; 134 | return data; 135 | } 136 | } 137 | 138 | async function* fetchFile(url: string | URL): AsyncIterable { 139 | const { body } = await fetch(url); 140 | for await (const chunk of body) { 141 | if (typeof chunk === "string") { 142 | throw new Error( 143 | `Could not transfer file, received string data instead of bytes from '${url}'`, 144 | ); 145 | } 146 | yield chunk; 147 | } 148 | } 149 | 150 | // === Export InputFile types 151 | /** Wrapper type to bundle all methods of the Telegram API */ 152 | export type ApiMethods = ApiMethodsF; 153 | 154 | /** Utility type providing the argument type for the given method name or `{}` if the method does not take any parameters */ 155 | export type Opts = OptsF[M]; 156 | 157 | /** This object describes a sticker to be added to a sticker set. */ 158 | export type InputSticker = InputStickerF; 159 | 160 | /** This object represents the content of a media message to be sent. It should be one of 161 | - InputMediaAnimation 162 | - InputMediaDocument 163 | - InputMediaAudio 164 | - InputMediaPhoto 165 | - InputMediaVideo */ 166 | export type InputMedia = InputMediaF; 167 | /** Represents a photo to be sent. */ 168 | export type InputMediaPhoto = InputMediaPhotoF; 169 | /** Represents a video to be sent. */ 170 | export type InputMediaVideo = InputMediaVideoF; 171 | /** Represents an animation file (GIF or H.264/MPEG-4 AVC video without sound) to be sent. */ 172 | export type InputMediaAnimation = InputMediaAnimationF; 173 | /** Represents an audio file to be treated as music to be sent. */ 174 | export type InputMediaAudio = InputMediaAudioF; 175 | /** Represents a general file to be sent. */ 176 | export type InputMediaDocument = InputMediaDocumentF; 177 | /** This object describes the paid media to be sent. Currently, it can be one of 178 | - InputPaidMediaPhoto 179 | - InputPaidMediaVideo */ 180 | export type InputPaidMedia = InputPaidMediaF; 181 | /** The paid media to send is a photo. */ 182 | export type InputPaidMediaPhoto = InputPaidMediaPhotoF; 183 | /** The paid media to send is a video. */ 184 | export type InputPaidMediaVideo = InputPaidMediaVideoF; 185 | /** This object describes a profile photo to set. Currently, it can be one of 186 | - InputProfilePhotoStatic 187 | - InputProfilePhotoAnimated */ 188 | export type InputProfilePhoto = InputProfilePhotoF; 189 | /** A static profile photo in the .JPG format. */ 190 | export type InputProfilePhotoStatic = InputProfilePhotoStaticF; 191 | /** An animated profile photo in the MPEG4 format. */ 192 | export type InputProfilePhotoAnimated = InputProfilePhotoAnimatedF; 193 | /** This object describes the content of a story to post. Currently, it can be one of 194 | - InputStoryContentPhoto 195 | - InputStoryContentVideo */ 196 | export type InputStoryContent = InputStoryContentF; 197 | /** Describes a photo to post as a story. */ 198 | export type InputStoryContentPhoto = InputStoryContentPhotoF; 199 | /** Describes a video to post as a story. */ 200 | export type InputStoryContentVideo = InputStoryContentVideoF; 201 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export * from "./types.deno.ts"; 2 | -------------------------------------------------------------------------------- /src/types.web.ts: -------------------------------------------------------------------------------- 1 | // === Needed imports 2 | import { basename } from "jsr:@std/path@1.0.9/basename"; 3 | 4 | import { 5 | type ApiMethods as ApiMethodsF, 6 | type InputMedia as InputMediaF, 7 | type InputMediaAnimation as InputMediaAnimationF, 8 | type InputMediaAudio as InputMediaAudioF, 9 | type InputMediaDocument as InputMediaDocumentF, 10 | type InputMediaPhoto as InputMediaPhotoF, 11 | type InputMediaVideo as InputMediaVideoF, 12 | type InputPaidMedia as InputPaidMediaF, 13 | type InputPaidMediaPhoto as InputPaidMediaPhotoF, 14 | type InputPaidMediaVideo as InputPaidMediaVideoF, 15 | type InputProfilePhoto as InputProfilePhotoAnimatedF, 16 | type InputProfilePhoto as InputProfilePhotoF, 17 | type InputProfilePhoto as InputProfilePhotoStaticF, 18 | type InputSticker as InputStickerF, 19 | type InputStoryContent as InputStoryContentF, 20 | type InputStoryContentPhoto as InputStoryContentPhotoF, 21 | type InputStoryContentVideo as InputStoryContentVideoF, 22 | type Opts as OptsF, 23 | } from "https://deno.land/x/grammy_types@v3.20.0/mod.ts"; 24 | 25 | // === Export all API types 26 | export * from "https://deno.land/x/grammy_types@v3.20.0/mod.ts"; 27 | 28 | /** Something that looks like a URL. */ 29 | interface URLLike { 30 | /** 31 | * Identifier of the resource. Must be in a format that can be parsed by the 32 | * URL constructor. 33 | */ 34 | url: string; 35 | } 36 | 37 | // === InputFile handling and File augmenting 38 | /** 39 | * An `InputFile` wraps a number of different sources for [sending 40 | * files](https://grammy.dev/guide/files#uploading-your-own-files). 41 | * 42 | * It corresponds to the `InputFile` type in the [Telegram Bot API 43 | * Reference](https://core.telegram.org/bots/api#inputfile). 44 | */ 45 | export class InputFile { 46 | private consumed = false; 47 | private readonly fileData: ConstructorParameters[0]; 48 | /** 49 | * Optional name of the constructed `InputFile` instance. 50 | * 51 | * Check out the 52 | * [documentation](https://grammy.dev/guide/files#uploading-your-own-files) 53 | * on sending files with `InputFile`. 54 | */ 55 | public readonly filename?: string; 56 | /** 57 | * Constructs an `InputFile` that can be used in the API to send files. 58 | * 59 | * @param file A URL to a file or a `Blob` or other forms of file data 60 | * @param filename Optional name of the file 61 | */ 62 | constructor( 63 | file: 64 | | Blob 65 | | URL 66 | | URLLike 67 | | Uint8Array 68 | | ReadableStream 69 | | Iterable 70 | | AsyncIterable, 71 | filename?: string, 72 | ) { 73 | this.fileData = file; 74 | filename ??= this.guessFilename(file); 75 | this.filename = filename; 76 | } 77 | private guessFilename( 78 | file: ConstructorParameters[0], 79 | ): string | undefined { 80 | if (typeof file === "string") return basename(file); 81 | if (typeof file !== "object") return undefined; 82 | if ("url" in file) return basename(file.url); 83 | if (!(file instanceof URL)) return undefined; 84 | return basename(file.pathname) || basename(file.hostname); 85 | } 86 | /** 87 | * Internal method. Do not use. 88 | * 89 | * Converts this instance into a binary representation that can be sent to 90 | * the Bot API server in the request body. 91 | */ 92 | toRaw(): Uint8Array | Iterable | AsyncIterable { 93 | if (this.consumed) { 94 | throw new Error("Cannot reuse InputFile data source!"); 95 | } 96 | const data = this.fileData; 97 | // Handle local files 98 | if (data instanceof Blob) return data.stream(); 99 | // Handle URL and URLLike objects 100 | if (data instanceof URL) return fetchFile(data); 101 | if ("url" in data) return fetchFile(data.url); 102 | // Mark streams and iterators as consumed 103 | if (!(data instanceof Uint8Array)) this.consumed = true; 104 | // Return buffers and byte streams as-is 105 | return data; 106 | } 107 | } 108 | 109 | async function* fetchFile(url: string | URL): AsyncIterable { 110 | const { body } = await fetch(url); 111 | if (body === null) { 112 | throw new Error(`Download failed, no response body from '${url}'`); 113 | } 114 | yield* body; 115 | } 116 | 117 | // === Export InputFile types 118 | /** Wrapper type to bundle all methods of the Telegram API */ 119 | export type ApiMethods = ApiMethodsF; 120 | 121 | /** Utility type providing the argument type for the given method name or `{}` if the method does not take any parameters */ 122 | export type Opts = OptsF[M]; 123 | 124 | /** This object describes a sticker to be added to a sticker set. */ 125 | export type InputSticker = InputStickerF; 126 | 127 | /** This object represents the content of a media message to be sent. It should be one of 128 | - InputMediaAnimation 129 | - InputMediaDocument 130 | - InputMediaAudio 131 | - InputMediaPhoto 132 | - InputMediaVideo */ 133 | export type InputMedia = InputMediaF; 134 | /** Represents a photo to be sent. */ 135 | export type InputMediaPhoto = InputMediaPhotoF; 136 | /** Represents a video to be sent. */ 137 | export type InputMediaVideo = InputMediaVideoF; 138 | /** Represents an animation file (GIF or H.264/MPEG-4 AVC video without sound) to be sent. */ 139 | export type InputMediaAnimation = InputMediaAnimationF; 140 | /** Represents an audio file to be treated as music to be sent. */ 141 | export type InputMediaAudio = InputMediaAudioF; 142 | /** Represents a general file to be sent. */ 143 | export type InputMediaDocument = InputMediaDocumentF; 144 | /** This object describes the paid media to be sent. Currently, it can be one of 145 | - InputPaidMediaPhoto 146 | - InputPaidMediaVideo */ 147 | export type InputPaidMedia = InputPaidMediaF; 148 | /** The paid media to send is a photo. */ 149 | export type InputPaidMediaPhoto = InputPaidMediaPhotoF; 150 | /** The paid media to send is a video. */ 151 | export type InputPaidMediaVideo = InputPaidMediaVideoF; 152 | /** This object describes a profile photo to set. Currently, it can be one of 153 | - InputProfilePhotoStatic 154 | - InputProfilePhotoAnimated */ 155 | export type InputProfilePhoto = InputProfilePhotoF; 156 | /** A static profile photo in the .JPG format. */ 157 | export type InputProfilePhotoStatic = InputProfilePhotoStaticF; 158 | /** An animated profile photo in the MPEG4 format. */ 159 | export type InputProfilePhotoAnimated = InputProfilePhotoAnimatedF; 160 | /** This object describes the content of a story to post. Currently, it can be one of 161 | - InputStoryContentPhoto 162 | - InputStoryContentVideo */ 163 | export type InputStoryContent = InputStoryContentF; 164 | /** Describes a photo to post as a story. */ 165 | export type InputStoryContentPhoto = InputStoryContentPhotoF; 166 | /** Describes a video to post as a story. */ 167 | export type InputStoryContentVideo = InputStoryContentVideoF; 168 | -------------------------------------------------------------------------------- /test/bot.test.ts: -------------------------------------------------------------------------------- 1 | import { Bot } from "../src/bot.ts"; 2 | import { assertEquals, assertThrows } from "./deps.test.ts"; 3 | 4 | function createBot(token: string) { 5 | return new Bot(token); 6 | } 7 | 8 | Deno.test("should take a token", () => { 9 | const bot = createBot("fake-token"); 10 | assertEquals(bot.token, "fake-token"); 11 | }); 12 | 13 | Deno.test("should not take an empty token", () => { 14 | assertThrows(() => createBot(undefined as unknown as string)); 15 | assertThrows(() => createBot("")); 16 | }); 17 | 18 | // TODO: add tests 19 | 20 | // for error handling 21 | // for initialization, including retries 22 | // for starting and stopping 23 | -------------------------------------------------------------------------------- /test/composer.test.ts: -------------------------------------------------------------------------------- 1 | import { BotError, Composer, type MiddlewareFn, run } from "../src/composer.ts"; 2 | import { Context } from "../src/mod.ts"; 3 | import { 4 | assertEquals, 5 | assertFalse, 6 | assertInstanceOf, 7 | assertRejects, 8 | assertStringIncludes, 9 | beforeEach, 10 | describe, 11 | it, 12 | type Spy, 13 | spy, 14 | } from "./deps.test.ts"; 15 | 16 | describe("BotError", () => { 17 | // @ts-expect-error this message is missing many properties 18 | const ctx = new Context({ message: { text: "test" } }, 0, 0); 19 | it("should copy stack and message", () => { 20 | const e = new Error("nope"); 21 | const err = new BotError(e, ctx); 22 | assertStringIncludes(err.message, e.message); 23 | assertEquals(err.stack, e.stack); 24 | }); 25 | it("should find good error message for non-error objects", () => { 26 | const e = (value: unknown) => new BotError(value, ctx).message; 27 | assertStringIncludes(e("test"), "test"); 28 | assertStringIncludes(e("test"), "of type string"); 29 | assertFalse(e(Array(50).fill("a").join("") + "bbb").includes("b")); // cut long strings 30 | assertStringIncludes(e(42), "42"); 31 | assertStringIncludes(e(42), "of type number"); 32 | assertStringIncludes(e(() => {}), "of type function"); 33 | }); 34 | }); 35 | 36 | describe("Composer", () => { 37 | let composer: Composer; 38 | const ctx = new Context( 39 | // deno-lint-ignore no-explicit-any 40 | { message: { text: "test" } } as any, 41 | // deno-lint-ignore no-explicit-any 42 | 0 as any, 43 | // deno-lint-ignore no-explicit-any 44 | 0 as any, 45 | ); 46 | const next = () => Promise.resolve(); 47 | let middleware: Spy>; 48 | const exec = (c = ctx) => composer.middleware()(c, next); 49 | 50 | beforeEach(() => { 51 | composer = new Composer(); 52 | middleware = spy((_ctx) => {}); 53 | }); 54 | 55 | it("should call handlers", async () => { 56 | composer.use(middleware); 57 | await exec(); 58 | assertEquals(middleware.calls[0].args[0], ctx); 59 | }); 60 | it("should call constructor handlers", async () => { 61 | composer = new Composer(middleware); 62 | await exec(); 63 | assertEquals(middleware.calls[0].args[0], ctx); 64 | }); 65 | it("should prevent next from being called more than once", async () => { 66 | let first = true; 67 | const com = new Composer(); 68 | com.use(async (_, next) => { 69 | await next(); 70 | await assertRejects(() => next(), "already called"); 71 | }, () => { 72 | if (first) first = false; 73 | else throw new Error("failed!"); 74 | }); 75 | composer.use(com); 76 | await exec(); 77 | }); 78 | 79 | describe(".use", () => { 80 | it("should work with multiple handlers", async () => { 81 | composer.use((_, next) => next(), (_, next) => next(), middleware); 82 | await exec(); 83 | assertEquals(middleware.calls[0].args[0], ctx); 84 | }); 85 | it("should with multiple handlers in different calls", async () => { 86 | composer.use((_, next) => next(), (_, next) => next()); 87 | composer.use((_, next) => next(), (_, next) => next()); 88 | composer.use((_, next) => next(), (_, next) => next(), middleware); 89 | await exec(); 90 | assertEquals(middleware.calls[0].args[0], ctx); 91 | }); 92 | it("should call sub-trees", async () => { 93 | const sub = composer.use((_, next) => next()); 94 | assertInstanceOf(sub, Composer); 95 | sub.use((_, next) => next()); 96 | sub.use((_, next) => next(), middleware); 97 | await exec(); 98 | assertEquals(middleware.calls[0].args[0], ctx); 99 | }); 100 | it("should allow errors to bubble up", async () => { 101 | composer.use((_, next) => next()) 102 | .use((_, next) => next(), () => { 103 | throw new Error("evil"); 104 | }); 105 | await assertRejects( 106 | async () => await composer.middleware()(ctx, next), 107 | Error, 108 | "evil", 109 | ); 110 | }); 111 | }); 112 | 113 | describe(".on", () => { 114 | it("should run filter queries", async () => { 115 | composer.on("::bot_command", middleware); // nope 116 | composer.on(["::code", "message:text"], middleware); 117 | await exec(ctx); 118 | await exec({ update: { channel_post: { text: "" } } } as Context); // nope 119 | assertEquals(middleware.calls.length, 1); 120 | assertEquals(middleware.calls[0].args[0], ctx); 121 | }); 122 | it("should allow chaining filter queries", async () => { 123 | composer.on([":text"]).on("message").use(middleware); 124 | await exec(); 125 | await exec({ update: { channel_post: { text: "" } } } as Context); 126 | assertEquals(middleware.calls.length, 1); 127 | assertEquals(middleware.calls[0].args[0], ctx); 128 | }); 129 | }); 130 | 131 | describe(".hears", () => { 132 | it("should check for text", async () => { 133 | composer.hears("test", middleware); 134 | await exec(); 135 | assertEquals(middleware.calls.length, 1); 136 | assertEquals(middleware.calls[0].args[0], ctx); 137 | }); 138 | it("should allow chaining hears", async () => { 139 | composer.hears(/^x.*/).hears(/.*st$/, middleware); // nope 140 | composer.hears(/^x.*/).hears(/.*x$/, middleware); // nope 141 | composer.hears(/^te.*/).hears(/.*st$/, middleware); 142 | await exec(); 143 | assertEquals(middleware.calls.length, 1); 144 | assertEquals(middleware.calls[0].args[0], ctx); 145 | }); 146 | }); 147 | 148 | describe(".command", () => { 149 | const c = new Context( 150 | { 151 | message: { 152 | text: "/start", 153 | entities: [{ type: "bot_command", offset: 0, length: 6 }], 154 | }, 155 | // deno-lint-ignore no-explicit-any 156 | } as any, 157 | // deno-lint-ignore no-explicit-any 158 | 0 as any, 159 | // deno-lint-ignore no-explicit-any 160 | 0 as any, 161 | ); 162 | it("should check for commands", async () => { 163 | composer.command("start", middleware); 164 | await exec(c); 165 | assertEquals(middleware.calls.length, 1); 166 | assertEquals(middleware.calls[0].args[0], c); 167 | }); 168 | it("should allow chaining commands", async () => { 169 | composer.command(["help"]) 170 | .command(["start", "settings"], middleware); // nope 171 | composer.command(["help", "start"]) 172 | .command(["settings"], middleware); // nope 173 | composer.command(["help", "start"]) 174 | .command(["start", "settings"], middleware); 175 | await exec(c); 176 | assertEquals(middleware.calls.length, 1); 177 | assertEquals(middleware.calls[0].args[0], c); 178 | }); 179 | }); 180 | 181 | describe(".chatType", () => { 182 | const c = new Context( 183 | // deno-lint-ignore no-explicit-any 184 | { message: { chat: { type: "group" } } } as any, 185 | // deno-lint-ignore no-explicit-any 186 | 0 as any, 187 | // deno-lint-ignore no-explicit-any 188 | 0 as any, 189 | ); 190 | it("should check for chat types", async () => { 191 | composer.chatType("private", middleware); 192 | composer.chatType(["group", "supergroup"], middleware); 193 | await exec(c); 194 | assertEquals(middleware.calls.length, 1); 195 | assertEquals(middleware.calls[0].args[0], c); 196 | }); 197 | it("should allow chaining chat type checks", async () => { 198 | composer.chatType(["channel"]) 199 | .chatType(["group", "supergroup"], middleware); // nope 200 | composer.chatType(["channel", "group"]) 201 | .chatType(["supergroup"], middleware); // nope 202 | composer.chatType(["channel", "group"]) 203 | .chatType(["group", "supergroup"], middleware); 204 | await exec(c); 205 | assertEquals(middleware.calls.length, 1); 206 | assertEquals(middleware.calls[0].args[0], c); 207 | }); 208 | }); 209 | 210 | describe(".callbackQuery", () => { 211 | const c = new Context( 212 | // deno-lint-ignore no-explicit-any 213 | { callback_query: { data: "test" } } as any, 214 | // deno-lint-ignore no-explicit-any 215 | 0 as any, 216 | // deno-lint-ignore no-explicit-any 217 | 0 as any, 218 | ); 219 | it("should check for callback query data", async () => { 220 | composer.callbackQuery("no-data", middleware); 221 | composer.callbackQuery(["nope", "test"], middleware); 222 | await exec(c); 223 | assertEquals(middleware.calls.length, 1); 224 | assertEquals(middleware.calls[0].args[0], c); 225 | }); 226 | it("should allow chaining callback query data checks", async () => { 227 | composer.callbackQuery(["nope"]) 228 | .callbackQuery(["test", "nei"], middleware); // nope 229 | composer.callbackQuery(["nope", "test"]) 230 | .callbackQuery(["nei"], middleware); // nope 231 | composer.callbackQuery(["nope", /test/]) 232 | .callbackQuery(["test", "nei"], middleware); 233 | await exec(c); 234 | assertEquals(middleware.calls.length, 1); 235 | assertEquals(middleware.calls[0].args[0], c); 236 | }); 237 | }); 238 | 239 | describe(".gameQuery", () => { 240 | const c = new Context( 241 | // deno-lint-ignore no-explicit-any 242 | { callback_query: { game_short_name: "test" } } as any, 243 | // deno-lint-ignore no-explicit-any 244 | 0 as any, 245 | // deno-lint-ignore no-explicit-any 246 | 0 as any, 247 | ); 248 | it("should check for game query data", async () => { 249 | composer.gameQuery("no-data", middleware); 250 | composer.gameQuery(["nope", "test"], middleware); 251 | await exec(c); 252 | assertEquals(middleware.calls.length, 1); 253 | assertEquals(middleware.calls[0].args[0], c); 254 | }); 255 | it("should allow chaining game query data checks", async () => { 256 | composer.gameQuery(["nope"]) 257 | .gameQuery(["test", "nei"], middleware); // nope 258 | composer.gameQuery(["nope", "test"]) 259 | .gameQuery(["nei"], middleware); // nope 260 | composer.gameQuery(["nope", /test/]) 261 | .gameQuery(["test", "nei"], middleware); 262 | await exec(c); 263 | assertEquals(middleware.calls.length, 1); 264 | assertEquals(middleware.calls[0].args[0], c); 265 | }); 266 | }); 267 | 268 | describe(".inlineQuery", () => { 269 | const c = new Context( 270 | // deno-lint-ignore no-explicit-any 271 | { inline_query: { query: "test" } } as any, 272 | // deno-lint-ignore no-explicit-any 273 | 0 as any, 274 | // deno-lint-ignore no-explicit-any 275 | 0 as any, 276 | ); 277 | it("should check for inline query data", async () => { 278 | composer.inlineQuery("no-data", middleware); 279 | composer.inlineQuery(["nope", "test"], middleware); 280 | await exec(c); 281 | assertEquals(middleware.calls.length, 1); 282 | assertEquals(middleware.calls[0].args[0], c); 283 | }); 284 | it("should allow chaining inline query data checks", async () => { 285 | composer.inlineQuery(["nope"]) 286 | .inlineQuery(["test", "nei"], middleware); // nope 287 | composer.inlineQuery(["nope", "test"]) 288 | .inlineQuery(["nei"], middleware); // nope 289 | composer.inlineQuery(["nope", /test/]) 290 | .inlineQuery(["test", "nei"], middleware); 291 | await exec(c); 292 | assertEquals(middleware.calls.length, 1); 293 | assertEquals(middleware.calls[0].args[0], c); 294 | }); 295 | }); 296 | 297 | describe(".preCheckoutQuery", () => { 298 | const c = new Context( 299 | // deno-lint-ignore no-explicit-any 300 | { pre_checkout_query: { invoice_payload: "test" } } as any, 301 | // deno-lint-ignore no-explicit-any 302 | 0 as any, 303 | // deno-lint-ignore no-explicit-any 304 | 0 as any, 305 | ); 306 | it("should check for pre-checkout query data", async () => { 307 | composer.preCheckoutQuery("no-data", middleware); 308 | composer.preCheckoutQuery(["nope", "test"], middleware); 309 | await exec(c); 310 | assertEquals(middleware.calls.length, 1); 311 | assertEquals(middleware.calls[0].args[0], c); 312 | }); 313 | it("should allow chaining pre-checkout query data checks", async () => { 314 | composer.preCheckoutQuery(["nope"]) 315 | .preCheckoutQuery(["test", "nei"], middleware); // nope 316 | composer.preCheckoutQuery(["nope", "test"]) 317 | .preCheckoutQuery(["nei"], middleware); // nope 318 | composer.preCheckoutQuery(["nope", /test/]) 319 | .preCheckoutQuery(["test", "nei"], middleware); 320 | await exec(c); 321 | assertEquals(middleware.calls.length, 1); 322 | assertEquals(middleware.calls[0].args[0], c); 323 | }); 324 | }); 325 | 326 | describe(".shippingQuery", () => { 327 | const c = new Context( 328 | // deno-lint-ignore no-explicit-any 329 | { shipping_query: { invoice_payload: "test" } } as any, 330 | // deno-lint-ignore no-explicit-any 331 | 0 as any, 332 | // deno-lint-ignore no-explicit-any 333 | 0 as any, 334 | ); 335 | it("should check for shipping query data", async () => { 336 | composer.shippingQuery("no-data", middleware); 337 | composer.shippingQuery(["nope", "test"], middleware); 338 | await exec(c); 339 | assertEquals(middleware.calls.length, 1); 340 | assertEquals(middleware.calls[0].args[0], c); 341 | }); 342 | it("should allow chaining shipping query data checks", async () => { 343 | composer.shippingQuery(["nope"]) 344 | .shippingQuery(["test", "nei"], middleware); // nope 345 | composer.shippingQuery(["nope", "test"]) 346 | .shippingQuery(["nei"], middleware); // nope 347 | composer.shippingQuery(["nope", /test/]) 348 | .shippingQuery(["test", "nei"], middleware); 349 | await exec(c); 350 | assertEquals(middleware.calls.length, 1); 351 | assertEquals(middleware.calls[0].args[0], c); 352 | }); 353 | }); 354 | 355 | describe(".filter", () => { 356 | const t = () => true; 357 | const f = () => false; 358 | it("should check filters", async () => { 359 | composer.filter(f, middleware); 360 | composer.filter(t, middleware); 361 | await exec(); 362 | assertEquals(middleware.calls.length, 1); 363 | assertEquals(middleware.calls[0].args[0], ctx); 364 | }); 365 | it("should allow chaining filters", async () => { 366 | composer.filter(t).filter(f, middleware); // nope 367 | composer.filter(f).filter(t, middleware); // nope 368 | composer.filter(t).filter(t, middleware); 369 | await exec(); 370 | assertEquals(middleware.calls.length, 1); 371 | assertEquals(middleware.calls[0].args[0], ctx); 372 | }); 373 | }); 374 | 375 | describe(".drop", () => { 376 | const t = () => true; 377 | const f = () => false; 378 | it("should allow to drop", async () => { 379 | composer.drop(t, middleware); 380 | composer.drop(f, middleware); 381 | await exec(); 382 | assertEquals(middleware.calls.length, 1); 383 | assertEquals(middleware.calls[0].args[0], ctx); 384 | }); 385 | it("should allow chaining drop calls", async () => { 386 | composer.drop(t).drop(f, middleware); // nope 387 | composer.drop(f).drop(t, middleware); // nope 388 | composer.drop(f).drop(f, middleware); 389 | await exec(); 390 | assertEquals(middleware.calls.length, 1); 391 | assertEquals(middleware.calls[0].args[0], ctx); 392 | }); 393 | }); 394 | 395 | describe(".fork", () => { 396 | it("should call downstream and passed middleware", async () => { 397 | composer.fork(middleware); 398 | composer.use(middleware); 399 | await exec(); 400 | assertEquals(middleware.calls.length, 2); 401 | }); 402 | it("should call middleware concurrently", async () => { 403 | let seq = ""; 404 | const tick = () => new Promise((r) => setTimeout(r)); 405 | composer.fork(async (_ctx, next) => { 406 | seq += "0"; // 2 407 | await tick(); 408 | seq += "1"; // 4 409 | await next(); 410 | }).use(async () => { 411 | seq += "2"; // 5 412 | await tick(); 413 | seq += "3"; // 7 414 | }); 415 | composer.use(async () => { 416 | seq += "a"; // 1 417 | await tick(); 418 | seq += "b"; // 3 419 | await tick(); 420 | seq += "c"; // 6 421 | await tick(); 422 | seq += "d"; // 8 423 | }); 424 | await exec(); 425 | assertEquals(seq, "a0b12c3d"); 426 | }); 427 | }); 428 | 429 | describe(".lazy", () => { 430 | it("should run lazily created middleware", async () => { 431 | composer.lazy((c) => { 432 | assertEquals(c, ctx); 433 | return middleware; 434 | }); 435 | await exec(); 436 | assertEquals(middleware.calls.length, 1); 437 | }); 438 | it("should run lazily created middleware arrays", async () => { 439 | composer.lazy( 440 | () => [new Composer(), new Composer().middleware(), middleware], 441 | ); 442 | await exec(); 443 | assertEquals(middleware.calls.length, 1); 444 | }); 445 | }); 446 | 447 | describe(".route", () => { 448 | const nope = () => { 449 | throw new Error("nope"); 450 | }; 451 | const base = { a: nope, b: nope }; 452 | it("should route context objects", async () => { 453 | composer.route((c) => { 454 | assertEquals(c, ctx); 455 | return "key"; 456 | }, { ...base, key: middleware }); 457 | await exec(); 458 | assertEquals(middleware.calls.length, 1); 459 | }); 460 | it("should support a fallback route", async () => { 461 | composer.route(() => "nope" as "a", base, middleware); 462 | await exec(); 463 | assertEquals(middleware.calls.length, 1); 464 | }); 465 | }); 466 | 467 | describe(".branch", () => { 468 | it("should branch based on a predicate", async () => { 469 | let count = 0; 470 | let l = 0; 471 | let r = 0; 472 | composer.branch( 473 | (c) => { 474 | assertEquals(c, ctx); 475 | return count++ % 2 === 0; 476 | }, 477 | () => l++, 478 | () => r++, 479 | ); 480 | for (let i = 0; i < 8; i++) await exec(); 481 | assertEquals(l, 4); 482 | assertEquals(r, 4); 483 | }); 484 | }); 485 | 486 | describe(".errorBoundary", () => { 487 | it("should catch errors from passed middleware", async () => { 488 | const err = new Error("damn"); 489 | const handler = spy((e: BotError) => { 490 | assertInstanceOf(e, BotError); 491 | assertEquals(e.error, err); 492 | assertStringIncludes(e.message, err.message); 493 | }); 494 | composer.errorBoundary(handler, () => { 495 | throw err; 496 | }); 497 | await exec(); 498 | assertEquals(handler.calls.length, 1); 499 | }); 500 | it("should catch errors from child middleware", async () => { 501 | const err = new Error("damn"); 502 | const handler = spy((e: BotError) => { 503 | assertInstanceOf(e, BotError); 504 | assertEquals(e.error, err); 505 | assertStringIncludes(e.message, err.message); 506 | }); 507 | composer.errorBoundary(handler).use(() => { 508 | throw err; 509 | }); 510 | await exec(); 511 | assertEquals(handler.calls.length, 1); 512 | }); 513 | it("should not touch downstream errors", async () => { 514 | const err = new Error("yay"); 515 | const handler = spy(() => {}); 516 | composer.errorBoundary(handler); 517 | composer.use(() => { 518 | throw err; 519 | }); 520 | await assertRejects(async () => { 521 | await exec(); 522 | }, "yay"); 523 | assertEquals(handler.calls.length, 0); 524 | }); 525 | it("should support passing on the control flow via next", async () => { 526 | const err = new Error("damn"); 527 | composer.errorBoundary((_e, next) => next()).use(() => { 528 | throw err; 529 | }); 530 | composer.use(middleware); 531 | await exec(); 532 | assertEquals(middleware.calls.length, 1); 533 | assertEquals(middleware.calls[0].args[0], ctx); 534 | }); 535 | }); 536 | }); 537 | 538 | describe("run", () => { 539 | it("should run middleware", async () => { 540 | const ctx = { update: { message: { text: "" } } } as Context; 541 | const middleware: Spy> = spy((_ctx) => {}); 542 | await run(middleware, ctx); 543 | assertEquals(middleware.calls.length, 1); 544 | assertEquals(middleware.calls[0].args[0], ctx); 545 | }); 546 | }); 547 | -------------------------------------------------------------------------------- /test/composer.type.test.ts: -------------------------------------------------------------------------------- 1 | import { Composer, Context } from "../src/mod.ts"; 2 | import type { Chat, MaybeInaccessibleMessage, User } from "../src/types.ts"; 3 | import { 4 | assertType, 5 | beforeEach, 6 | describe, 7 | type IsExact, 8 | type IsMutuallyAssignable, 9 | it, 10 | } from "./deps.test.ts"; 11 | 12 | // Compile-time type tests. No run-time assertion will actually run. Either compile fails or test passes. 13 | describe("Composer types", () => { 14 | let composer: Composer; 15 | 16 | beforeEach(() => { 17 | composer = new Composer(); 18 | }); 19 | 20 | describe(".hears", () => { 21 | it("should have correct type for properties", () => { 22 | composer.hears("test", (ctx) => { 23 | const msgCaption = ctx.msg.caption; 24 | const msgText = ctx.msg.text; 25 | const messageCaption = ctx.message?.caption; 26 | const messageText = ctx.message?.text; 27 | const channelPostCaption = ctx.channelPost?.caption; 28 | const channelPostText = ctx.channelPost?.text; 29 | const match = ctx.match; 30 | assertType>( 31 | true, 32 | ); 33 | assertType>(true); 34 | assertType>( 35 | true, 36 | ); 37 | assertType>( 38 | true, 39 | ); 40 | assertType< 41 | IsExact 42 | >( 43 | true, 44 | ); 45 | assertType>( 46 | true, 47 | ); 48 | assertType>( 49 | true, 50 | ); 51 | }); 52 | }); 53 | }); 54 | 55 | describe(".callbackQuery", () => { 56 | it("should have correct type for properties", () => { 57 | composer.callbackQuery("test", (ctx) => { 58 | const msg = ctx.msg; 59 | const message = ctx.message; 60 | const callbackQueryMessage = ctx.callbackQuery.message; 61 | const callbackQueryData = ctx.callbackQuery.data; 62 | const match = ctx.match; 63 | const chatId = ctx.chatId; 64 | assertType< 65 | IsMutuallyAssignable< 66 | typeof msg, 67 | MaybeInaccessibleMessage | undefined 68 | > 69 | >( 70 | true, 71 | ); 72 | assertType< 73 | IsExact< 74 | typeof message, 75 | undefined // This is ctx.update.message, but not ctx.update.callback_query.message 76 | > 77 | >( 78 | true, 79 | ); 80 | assertType< 81 | IsExact< 82 | typeof callbackQueryMessage, 83 | MaybeInaccessibleMessage | undefined 84 | > 85 | >( 86 | true, 87 | ); 88 | assertType< 89 | IsExact< 90 | typeof callbackQueryData, 91 | string 92 | > 93 | >( 94 | true, 95 | ); 96 | assertType>( 97 | true, 98 | ); 99 | assertType>(true); 100 | }); 101 | }); 102 | }); 103 | 104 | describe(".command", () => { 105 | it("should have correct type for properties", () => { 106 | composer.command("test", (ctx) => { 107 | const msgText = ctx.msg.text; 108 | const messageCaption = ctx.message?.caption; 109 | const messageText = ctx.message?.text; 110 | const channelPostCaption = ctx.channelPost?.caption; 111 | const channelPostText = ctx.channelPost?.text; 112 | const match = ctx.match; 113 | assertType>(true); 114 | assertType>( 115 | true, 116 | ); 117 | assertType>( 118 | true, 119 | ); 120 | assertType< 121 | IsExact 122 | >(true); 123 | assertType>( 124 | true, 125 | ); 126 | assertType>(true); 127 | }); 128 | }); 129 | }); 130 | 131 | describe(".chatType", () => { 132 | it("should have correct type for properties in private chats", () => { 133 | composer.chatType("private", (ctx) => { 134 | const chat = ctx.chat; 135 | const chatId = ctx.chatId; 136 | const from = ctx.from; 137 | const channelPost = ctx.channelPost; 138 | 139 | assertType>( 140 | true, 141 | ); 142 | assertType>(true); 143 | assertType>(true); 144 | assertType>(true); 145 | if (ctx.message) { 146 | assertType< 147 | IsMutuallyAssignable< 148 | typeof ctx.message.chat, 149 | Chat.PrivateChat 150 | > 151 | >( 152 | true, 153 | ); 154 | } 155 | if (ctx.callbackQuery?.message) { 156 | assertType< 157 | IsMutuallyAssignable< 158 | typeof ctx.callbackQuery.message.chat, 159 | Chat.PrivateChat 160 | > 161 | >(true); 162 | } 163 | }); 164 | }); 165 | it("should have correct type for properties in group chats", () => { 166 | composer.chatType("group", (ctx) => { 167 | const chat = ctx.chat; 168 | const chatId = ctx.chatId; 169 | const channelPost = ctx.channelPost; 170 | 171 | assertType>( 172 | true, 173 | ); 174 | assertType>(true); 175 | assertType>(true); 176 | if (ctx.message) { 177 | assertType< 178 | IsMutuallyAssignable< 179 | typeof ctx.message.chat, 180 | Chat.GroupChat 181 | > 182 | >( 183 | true, 184 | ); 185 | } 186 | if (ctx.callbackQuery?.message) { 187 | assertType< 188 | IsMutuallyAssignable< 189 | typeof ctx.callbackQuery.message.chat, 190 | Chat.GroupChat 191 | > 192 | >(true); 193 | } 194 | }); 195 | }); 196 | it("should have correct type for properties in supergroup chats", () => { 197 | composer.chatType("supergroup", (ctx) => { 198 | const chat = ctx.chat; 199 | const chatId = ctx.chatId; 200 | const channelPost = ctx.channelPost; 201 | 202 | assertType< 203 | IsMutuallyAssignable 204 | >(true); 205 | assertType>(true); 206 | assertType>(true); 207 | if (ctx.message) { 208 | assertType< 209 | IsMutuallyAssignable< 210 | typeof ctx.message.chat, 211 | Chat.SupergroupChat 212 | > 213 | >(true); 214 | } 215 | if (ctx.callbackQuery?.message) { 216 | assertType< 217 | IsMutuallyAssignable< 218 | typeof ctx.callbackQuery.message.chat, 219 | Chat.SupergroupChat 220 | > 221 | >(true); 222 | } 223 | }); 224 | }); 225 | it("should have correct type for properties in channel chats", () => { 226 | composer.chatType("channel", (ctx) => { 227 | const chat = ctx.chat; 228 | const chatId = ctx.chatId; 229 | const message = ctx.message; 230 | 231 | assertType>( 232 | true, 233 | ); 234 | assertType>(true); 235 | assertType>(true); 236 | if (ctx.channelPost) { 237 | assertType< 238 | IsMutuallyAssignable< 239 | typeof ctx.channelPost.chat, 240 | Chat.ChannelChat 241 | > 242 | >(true); 243 | } 244 | if (ctx.callbackQuery?.message) { 245 | assertType< 246 | IsMutuallyAssignable< 247 | typeof ctx.callbackQuery.message.chat, 248 | Chat.ChannelChat 249 | > 250 | >(true); 251 | } 252 | }); 253 | }); 254 | it("should combine different chat types correctly", () => { 255 | composer.chatType(["private", "channel"], (ctx) => { 256 | const chat = ctx.chat; 257 | const chatId = ctx.chatId; 258 | 259 | assertType< 260 | IsMutuallyAssignable< 261 | typeof chat, 262 | Chat.PrivateChat | Chat.ChannelChat 263 | > 264 | >(true); 265 | assertType>(true); 266 | if (ctx.message) { 267 | assertType< 268 | IsMutuallyAssignable< 269 | typeof ctx.message.chat, 270 | Chat.PrivateChat 271 | > 272 | >( 273 | true, 274 | ); 275 | } 276 | if (ctx.channelPost) { 277 | assertType< 278 | IsMutuallyAssignable< 279 | typeof ctx.channelPost.chat, 280 | Chat.ChannelChat 281 | > 282 | >(true); 283 | } 284 | }); 285 | }); 286 | }); 287 | 288 | describe(".gameQuery", () => { 289 | it("should have correct type for properties", () => { 290 | composer.gameQuery("test", (ctx) => { 291 | const msg = ctx.msg; 292 | const message = ctx.message; 293 | const callbackQueryMessage = ctx.callbackQuery.message; 294 | const gameShortName = ctx.callbackQuery.game_short_name; 295 | const match = ctx.match; 296 | assertType< 297 | IsMutuallyAssignable< 298 | typeof msg, 299 | MaybeInaccessibleMessage | undefined 300 | > 301 | >( 302 | true, 303 | ); 304 | assertType< 305 | IsExact< 306 | typeof message, 307 | undefined // This is ctx.update.message, but not ctx.update.callback_query.message 308 | > 309 | >( 310 | true, 311 | ); 312 | assertType< 313 | IsExact< 314 | typeof callbackQueryMessage, 315 | MaybeInaccessibleMessage | undefined 316 | > 317 | >( 318 | true, 319 | ); 320 | assertType< 321 | IsExact< 322 | typeof gameShortName, 323 | string 324 | > 325 | >( 326 | true, 327 | ); 328 | assertType>( 329 | true, 330 | ); 331 | }); 332 | }); 333 | }); 334 | 335 | describe(".inlineQuery", () => { 336 | it("should have correct type for properties", () => { 337 | composer.inlineQuery("test", (ctx) => { 338 | const query = ctx.inlineQuery.query; 339 | const match = ctx.match; 340 | assertType>(true); 341 | assertType>( 342 | true, 343 | ); 344 | }); 345 | }); 346 | }); 347 | 348 | describe(".preCheckoutQuery", () => { 349 | it("should have correct type for properties", () => { 350 | composer.preCheckoutQuery("test", (ctx) => { 351 | const invoicePayload = ctx.preCheckoutQuery.invoice_payload; 352 | const match = ctx.match; 353 | assertType>(true); 354 | assertType>( 355 | true, 356 | ); 357 | }); 358 | }); 359 | }); 360 | 361 | describe(".shippingQuery", () => { 362 | it("should have correct type for properties", () => { 363 | composer.shippingQuery("test", (ctx) => { 364 | const invoicePayload = ctx.shippingQuery.invoice_payload; 365 | const match = ctx.match; 366 | assertType>(true); 367 | assertType>( 368 | true, 369 | ); 370 | }); 371 | }); 372 | }); 373 | 374 | describe(".filter", () => { 375 | it("should have correct type for properties", () => { 376 | type TmpCtx = Context & { prop: number }; 377 | composer.filter((_ctx): _ctx is TmpCtx => true, (ctx) => { 378 | assertType>(true); 379 | assertType>(true); 380 | }); 381 | }); 382 | }); 383 | 384 | describe("known combined usages", () => { 385 | it("should work with .chatType.callbackQuery", () => { 386 | composer.chatType("private").callbackQuery("query", (ctx) => { 387 | assertType>(true); 388 | }); 389 | composer.callbackQuery("query").chatType("private", (ctx) => { 390 | assertType>(true); 391 | }); 392 | }); 393 | }); 394 | }); 395 | -------------------------------------------------------------------------------- /test/context.type.test.ts: -------------------------------------------------------------------------------- 1 | import { Composer, type Context } from "../src/mod.ts"; 2 | import { 3 | assertType, 4 | describe, 5 | type IsExact, 6 | type IsMutuallyAssignable, 7 | it, 8 | } from "./deps.test.ts"; 9 | 10 | describe("ctx.has* checks", () => { 11 | it("should narrow down types", () => { 12 | const c = new Composer(); 13 | c.use((ctx) => { 14 | assertType>(true); 15 | if (ctx.has(":contact")) { 16 | assertType< 17 | IsExact 18 | >(true); 19 | assertType>(true); 20 | } 21 | if (ctx.hasText("123")) { 22 | assertType< 23 | IsMutuallyAssignable< 24 | typeof ctx.match, 25 | string | RegExpMatchArray 26 | > 27 | >(true); 28 | } 29 | if (ctx.hasCommand("123")) { 30 | assertType>( 31 | true, 32 | ); 33 | } 34 | if (ctx.hasChatType("private")) { 35 | assertType>(true); 36 | } 37 | if (ctx.hasGameQuery("123")) { 38 | assertType< 39 | IsExact< 40 | typeof ctx.callbackQuery.game_short_name, 41 | string 42 | > 43 | >(true); 44 | } 45 | if (ctx.hasInlineQuery("123")) { 46 | assertType>( 47 | true, 48 | ); 49 | } 50 | }); 51 | c.command("c", (ctx) => { 52 | assertType>(true); 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /test/convenience/input_media.test.ts: -------------------------------------------------------------------------------- 1 | import { InputMediaBuilder } from "../../src/convenience/input_media.ts"; 2 | import { InputFile } from "../../src/mod.ts"; 3 | import { assertEquals, describe, it } from "../deps.test.ts"; 4 | 5 | describe("InputMediaBuilder", () => { 6 | const file = new InputFile(new Uint8Array([65, 66, 67])); 7 | 8 | it("should build photos", () => { 9 | const media = InputMediaBuilder.photo(file, { 10 | caption: "photo caption", 11 | }); 12 | assertEquals(media, { 13 | type: "photo", 14 | media: file, 15 | caption: "photo caption", 16 | }); 17 | }); 18 | 19 | it("should build videos", () => { 20 | const media = InputMediaBuilder.video(file, { 21 | caption: "video caption", 22 | }); 23 | assertEquals(media, { 24 | type: "video", 25 | media: file, 26 | caption: "video caption", 27 | }); 28 | }); 29 | 30 | it("should build animations", () => { 31 | const media = InputMediaBuilder.animation(file, { 32 | caption: "animation caption", 33 | }); 34 | assertEquals(media, { 35 | type: "animation", 36 | media: file, 37 | caption: "animation caption", 38 | }); 39 | }); 40 | 41 | it("should build audios", () => { 42 | const media = InputMediaBuilder.audio(file, { 43 | caption: "audio caption", 44 | }); 45 | assertEquals(media, { 46 | type: "audio", 47 | media: file, 48 | caption: "audio caption", 49 | }); 50 | }); 51 | 52 | it("should build documents", () => { 53 | const media = InputMediaBuilder.document(file, { 54 | caption: "document caption", 55 | }); 56 | assertEquals(media, { 57 | type: "document", 58 | media: file, 59 | caption: "document caption", 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /test/convenience/keyboard.test.ts: -------------------------------------------------------------------------------- 1 | import { InlineKeyboard, Keyboard } from "../../src/convenience/keyboard.ts"; 2 | import { type LoginUrl } from "../../src/types.ts"; 3 | import { 4 | assertEquals, 5 | assertNotStrictEquals, 6 | describe, 7 | it, 8 | } from "../deps.test.ts"; 9 | 10 | describe("Keyboard", () => { 11 | it("should take initial buttons", () => { 12 | const keyboard = new Keyboard([[{ text: "button" }]]); 13 | assertEquals(keyboard.build(), [[{ text: "button" }]]); 14 | }); 15 | 16 | it("should create rows and columns", () => { 17 | const keyboard = new Keyboard([[{ text: "0" }]]) 18 | .add({ text: "1" }, { text: "2" }).row() 19 | .add({ text: "3" }, { text: "4" }).add({ text: "5" }); 20 | assertEquals(keyboard.build(), [ 21 | [{ text: "0" }, { text: "1" }, { text: "2" }], 22 | [{ text: "3" }, { text: "4" }, { text: "5" }], 23 | ]); 24 | }); 25 | 26 | it("should support different buttons", () => { 27 | const keyboard = new Keyboard() 28 | .text("button") 29 | .requestContact("contact") 30 | .requestLocation("location") 31 | .requestPoll("poll", "quiz") 32 | .webApp("web app", "https://grammy.dev") 33 | .requestUsers("user", 12, { user_is_bot: true }) 34 | .requestChat("chat", 42); 35 | assertEquals(keyboard.build(), [[ 36 | { text: "button" }, 37 | { text: "contact", request_contact: true }, 38 | { text: "location", request_location: true }, 39 | { text: "poll", request_poll: { type: "quiz" } }, 40 | { text: "web app", web_app: { url: "https://grammy.dev" } }, 41 | { 42 | text: "user", 43 | request_users: { request_id: 12, user_is_bot: true }, 44 | }, 45 | { 46 | text: "chat", 47 | request_chat: { request_id: 42, chat_is_channel: false }, 48 | }, 49 | ]]); 50 | }); 51 | 52 | it("should support reply markup options", () => { 53 | const keyboard = new Keyboard(); 54 | assertEquals(keyboard.is_persistent, undefined); 55 | assertEquals(keyboard.selective, undefined); 56 | assertEquals(keyboard.one_time_keyboard, undefined); 57 | assertEquals(keyboard.resize_keyboard, undefined); 58 | assertEquals(keyboard.input_field_placeholder, undefined); 59 | keyboard 60 | .persistent() 61 | .selected(false) 62 | .oneTime(true) 63 | .resized(false) 64 | .placeholder("placeholder"); 65 | assertEquals(keyboard.is_persistent, true); 66 | assertEquals(keyboard.selective, false); 67 | assertEquals(keyboard.one_time_keyboard, true); 68 | assertEquals(keyboard.resize_keyboard, false); 69 | assertEquals(keyboard.input_field_placeholder, "placeholder"); 70 | }); 71 | 72 | it("can be transposed", () => { 73 | function t(btns: string[][], expected: string[][]) { 74 | assertEquals( 75 | Keyboard.from(btns).toTransposed(), 76 | Keyboard.from(expected), 77 | ); 78 | } 79 | t([["a"]], [["a"]]); 80 | t([["a", "b", "c"]], [["a"], ["b"], ["c"]]); 81 | t([["a", "b"], ["c", "d"], ["e"]], [["a", "c", "e"], ["b", "d"]]); 82 | t( 83 | [["a", "b"], ["c"], ["d", "e", "f"]], 84 | [["a", "c", "d"], ["b", "e"], ["f"]], 85 | ); 86 | const keyboard = Keyboard.from([["a", "b", "c"], ["d", "e"], ["f"]]); 87 | assertEquals(keyboard.toTransposed().toTransposed(), keyboard); 88 | }); 89 | 90 | it("can be wrapped", () => { 91 | function r( 92 | cols: number, 93 | flow: "bottom" | "top", 94 | btns: string[][], 95 | expected: string[][], 96 | ) { 97 | assertEquals( 98 | Keyboard.from(btns).toFlowed(cols, { 99 | fillLastRow: flow === "bottom", 100 | }), 101 | Keyboard.from(expected), 102 | ); 103 | } 104 | r(4, "top", [["a"]], [["a"]]); 105 | r(1, "top", [["a", "b", "c"]], [["a"], ["b"], ["c"]]); 106 | r(3, "top", [["a", "b"], ["c", "d"], ["e"]], [["a", "b", "c"], [ 107 | "d", 108 | "e", 109 | ]]); 110 | r( 111 | 5, 112 | "top", 113 | [["a", "b"], ["c"], ["d", "e", "f"]], 114 | [["a", "b", "c", "d", "e"], ["f"]], 115 | ); 116 | r( 117 | 3, 118 | "bottom", 119 | [[..."abcdefghij"]], 120 | [["a"], ["b", "c", "d"], ["e", "f", "g"], ["h", "i", "j"]], 121 | ); 122 | const keyboard = Keyboard.from([["a", "b", "c"], ["d", "e"], ["f"]]); 123 | assertEquals( 124 | keyboard.toFlowed(3).toFlowed(3), 125 | keyboard.toFlowed(3), 126 | ); 127 | }); 128 | 129 | it("can be created from data sources", () => { 130 | const data = [["a", "b"], ["c", "d"]].map((row) => 131 | row.map((text) => ({ text })) 132 | ); 133 | assertEquals(Keyboard.from(data).keyboard, data); 134 | }); 135 | 136 | it("can be appended", () => { 137 | const initial = Keyboard.from([["a", "b"], ["c"]]); 138 | assertEquals( 139 | initial.clone().append(initial).append(initial).keyboard, 140 | [...initial.keyboard, ...initial.keyboard, ...initial.keyboard], 141 | ); 142 | }); 143 | }); 144 | 145 | describe("InlineKeyboard", () => { 146 | const btn = { text: "text", callback_data: "data" }; 147 | 148 | it("should take initial buttons", () => { 149 | const keyboard = new InlineKeyboard([[btn], [btn, btn]]); 150 | assertEquals(keyboard.inline_keyboard, [[btn], [btn, btn]]); 151 | }); 152 | 153 | it("should create rows and columns", () => { 154 | const keyboard = new InlineKeyboard([[btn]]) 155 | .add(btn, btn).row() 156 | .add(btn, btn).add(btn); 157 | assertEquals(keyboard.inline_keyboard, [ 158 | [btn, btn, btn], 159 | [btn, btn, btn], 160 | ]); 161 | }); 162 | 163 | it("should support different buttons", () => { 164 | const url: LoginUrl = { 165 | url: "https://grammy.dev", 166 | forward_text: "forward", 167 | bot_username: "bot", 168 | request_write_access: true, 169 | }; 170 | const keyboard = new InlineKeyboard() 171 | .url("url", "https://grammy.dev") 172 | .text("button") 173 | .text("button", "data") 174 | .webApp("web app", "https://grammy.dev") 175 | .login("login", "https://grammy.dev") 176 | .login("login", url) 177 | .switchInline("inline") 178 | .switchInline("inline", "query") 179 | .switchInlineCurrent("inline current") 180 | .switchInlineCurrent("inline current", "query") 181 | .switchInlineChosen("inline chosen chat") 182 | .switchInlineChosen("inline chosen chat", { 183 | allow_bot_chats: true, 184 | }) 185 | .game("game") 186 | .pay("pay"); 187 | assertEquals(keyboard.inline_keyboard, [ 188 | [ 189 | { text: "url", url: "https://grammy.dev" }, 190 | { text: "button", callback_data: "button" }, 191 | { text: "button", callback_data: "data" }, 192 | { text: "web app", web_app: { url: "https://grammy.dev" } }, 193 | { text: "login", login_url: { url: "https://grammy.dev" } }, 194 | { text: "login", login_url: url }, 195 | { text: "inline", switch_inline_query: "" }, 196 | { text: "inline", switch_inline_query: "query" }, 197 | { 198 | switch_inline_query_current_chat: "", 199 | text: "inline current", 200 | }, 201 | { 202 | switch_inline_query_current_chat: "query", 203 | text: "inline current", 204 | }, 205 | { 206 | switch_inline_query_chosen_chat: {}, 207 | text: "inline chosen chat", 208 | }, 209 | { 210 | switch_inline_query_chosen_chat: { allow_bot_chats: true }, 211 | text: "inline chosen chat", 212 | }, 213 | { text: "game", callback_game: {} }, 214 | { text: "pay", pay: true }, 215 | ], 216 | ]); 217 | }); 218 | 219 | it("can be transposed", () => { 220 | function t(btns: string[][], target: string[][]) { 221 | const actual = InlineKeyboard.from( 222 | btns.map((row) => row.map((data) => InlineKeyboard.text(data))), 223 | ); 224 | const expected = InlineKeyboard.from( 225 | target.map((row) => 226 | row.map((data) => InlineKeyboard.text(data)) 227 | ), 228 | ); 229 | assertEquals( 230 | InlineKeyboard.from(actual).toTransposed(), 231 | InlineKeyboard.from(expected), 232 | ); 233 | } 234 | t([["a"]], [["a"]]); 235 | t([["a", "b", "c"]], [["a"], ["b"], ["c"]]); 236 | t([["a", "b"], ["c", "d"], ["e"]], [["a", "c", "e"], ["b", "d"]]); 237 | t( 238 | [["a", "b"], ["c"], ["d", "e", "f"]], 239 | [["a", "c", "d"], ["b", "e"], ["f"]], 240 | ); 241 | const keyboard = new InlineKeyboard().text("a").text("b").text("c") 242 | .row() 243 | .text("d").text("e").row() 244 | .text("f"); 245 | assertEquals(keyboard.toTransposed().toTransposed(), keyboard); 246 | }); 247 | 248 | it("can be wrapped", () => { 249 | function r( 250 | cols: number, 251 | flow: "top" | "bottom", 252 | btns: string[][], 253 | target: string[][], 254 | ) { 255 | const actual = InlineKeyboard.from( 256 | btns.map((row) => row.map((data) => InlineKeyboard.text(data))), 257 | ); 258 | const expected = InlineKeyboard.from( 259 | target.map((row) => 260 | row.map((data) => InlineKeyboard.text(data)) 261 | ), 262 | ); 263 | assertEquals( 264 | actual.toFlowed(cols, { fillLastRow: flow === "bottom" }), 265 | expected, 266 | ); 267 | } 268 | r(4, "top", [["a"]], [["a"]]); 269 | r(1, "top", [["a", "b", "c"]], [["a"], ["b"], ["c"]]); 270 | r( 271 | 3, 272 | "top", 273 | [["a", "b"], ["c", "d"], ["e"]], 274 | [["a", "b", "c"], ["d", "e"]], 275 | ); 276 | r( 277 | 5, 278 | "top", 279 | [["a", "b"], ["c"], ["d", "e", "f"]], 280 | [["a", "b", "c", "d", "e"], ["f"]], 281 | ); 282 | r( 283 | 3, 284 | "bottom", 285 | [[..."abcdefghij"]], 286 | [["a"], ["b", "c", "d"], ["e", "f", "g"], ["h", "i", "j"]], 287 | ); 288 | const keyboard = new InlineKeyboard() 289 | .text("a").text("b").text("c").row() 290 | .text("d").text("e").row() 291 | .text("f"); 292 | assertEquals( 293 | keyboard.toFlowed(3).toFlowed(3), 294 | keyboard.toFlowed(3), 295 | ); 296 | }); 297 | 298 | it("can be created from data sources", () => { 299 | const labels = [["a", "b"], ["c", "d"]]; 300 | const raw = labels.map((row) => 301 | row.map((text) => ({ text, callback_data: text })) 302 | ); 303 | assertEquals(InlineKeyboard.from(raw).inline_keyboard, raw); 304 | 305 | const keyboard = new InlineKeyboard().text("button"); 306 | assertNotStrictEquals(InlineKeyboard.from(keyboard), keyboard); 307 | assertEquals(InlineKeyboard.from(keyboard), keyboard); 308 | }); 309 | 310 | it("can be appended", () => { 311 | const initial = new InlineKeyboard() 312 | .text("a").text("b").text("c"); 313 | assertEquals( 314 | initial.clone().append(initial).append(initial).inline_keyboard, 315 | [ 316 | ...initial.inline_keyboard, 317 | ...initial.inline_keyboard, 318 | ...initial.inline_keyboard, 319 | ], 320 | ); 321 | }); 322 | }); 323 | -------------------------------------------------------------------------------- /test/convenience/webhook.test.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from "jsr:@hono/hono"; 2 | import type { 3 | APIGatewayProxyEventV2, 4 | Context as LambdaContext, 5 | } from "npm:@types/aws-lambda"; 6 | import type { NHttp } from "jsr:@nhttp/nhttp"; 7 | import type { Application } from "jsr:@oak/oak"; 8 | import type { createServer } from "node:http"; 9 | import type { Elysia } from "npm:elysia"; 10 | import type { Express } from "npm:@types/express"; 11 | import type bodyParser from "npm:@types/koa-bodyparser"; 12 | import type Koa from "npm:@types/koa"; 13 | import type { FastifyInstance } from "npm:fastify"; 14 | import type { NextApiRequest, NextApiResponse } from "npm:next"; 15 | import { Bot, webhookCallback } from "../../src/mod.ts"; 16 | import type { UserFromGetMe } from "../../src/types.ts"; 17 | import { describe, it } from "../deps.test.ts"; 18 | 19 | describe("webhook", () => { 20 | const bot = new Bot("dummy", { botInfo: {} as unknown as UserFromGetMe }); 21 | 22 | it("AWS Lambda should be compatible with grammY adapter", () => { 23 | ((event: APIGatewayProxyEventV2, context: LambdaContext) => 24 | webhookCallback(bot, "aws-lambda-async")( 25 | event, 26 | context, 27 | )); 28 | }); 29 | 30 | it("Bun.serve should be compatible with grammY adapter", () => { 31 | type BunServe = ( 32 | options: { 33 | fetch: (request: Request) => Response | Promise; 34 | }, 35 | ) => object; 36 | 37 | const handler = webhookCallback(bot, "bun"); 38 | const serve = (() => {}) as unknown as BunServe; 39 | serve({ 40 | fetch: (request) => { 41 | return handler(request); 42 | }, 43 | }); 44 | }); 45 | 46 | it("Cloudflare Workers should be compatible with grammY adapter", async () => { 47 | const req = { 48 | json: () => ({}), 49 | headers: { get: () => "" }, 50 | } as unknown as Request; 51 | const handler = webhookCallback(bot, "cloudflare-mod"); 52 | const _res: Response = await handler(req); 53 | }); 54 | 55 | it("Elysia should be compatible with grammY adapter", () => { 56 | const app = { post: () => {} } as unknown as Elysia; 57 | 58 | app.post("/", webhookCallback(bot, "elysia")); 59 | }); 60 | 61 | it("Express should be compatible with grammY adapter", () => { 62 | const app = { post: () => {} } as unknown as Express; 63 | const handler = webhookCallback(bot, "express"); 64 | app.post("/", (req, res) => { 65 | return handler(req, res); 66 | }); 67 | }); 68 | 69 | it("Fastify should be compatible with grammY adapter", () => { 70 | const app = { post: () => {} } as unknown as FastifyInstance; 71 | const handler = webhookCallback(bot, "fastify"); 72 | app.post("/", (request, reply) => { 73 | return handler(request, reply); 74 | }); 75 | }); 76 | 77 | it("Hono should be compatible with grammY adapter", () => { 78 | const app = { post: () => {} } as unknown as Hono; 79 | const handler = webhookCallback(bot, "hono"); 80 | app.post("/", (c) => { 81 | return handler(c); 82 | }); 83 | }); 84 | 85 | it("http/https should be compatible with grammY adapter", () => { 86 | const create = (() => {}) as unknown as typeof createServer; 87 | const handler = webhookCallback(bot, "http"); 88 | create((req, res) => { 89 | return handler(req, res); 90 | }); 91 | }); 92 | 93 | it("Koa should be compatible with grammY adapter", () => { 94 | const app = { use: () => {} } as unknown as Koa; 95 | const parser = (() => {}) as unknown as typeof bodyParser; 96 | const handler = webhookCallback(bot, "koa"); 97 | app.use(parser()); 98 | app.use((ctx) => { 99 | return handler(ctx); 100 | }); 101 | }); 102 | 103 | it("Next serverless functions should be compatible with grammY adapter", async () => { 104 | const req = { 105 | headers: {}, 106 | body: { update: {} }, 107 | } as unknown as NextApiRequest; 108 | const res = { end: () => {} } as NextApiResponse; 109 | const handler = webhookCallback(bot, "next-js"); 110 | await handler(req, res); 111 | }); 112 | 113 | it("NHttp should be compatible with grammY adapter", () => { 114 | const app = { post: () => {} } as unknown as NHttp; 115 | const handler = webhookCallback(bot, "nhttp"); 116 | app.post("/", (rev) => { 117 | return handler(rev); 118 | }); 119 | }); 120 | 121 | it("Oak should be compatible with grammY adapter", () => { 122 | const app = { use: () => {} } as unknown as Application; 123 | const handler = webhookCallback(bot, "oak"); 124 | app.use((ctx) => { 125 | return handler(ctx); 126 | }); 127 | }); 128 | 129 | it("serveHttp should be compatible with grammY adapter", async () => { 130 | const event = { 131 | request: new Request("https://grammy.dev", { 132 | method: "POST", 133 | body: JSON.stringify({ update_id: 0 }), 134 | }), 135 | respondWith: () => {}, 136 | }; 137 | const handler = webhookCallback(bot, "serveHttp"); 138 | await handler(event); 139 | }); 140 | 141 | it("std/http should be compatible with grammY adapter", () => { 142 | const serve = (() => {}) as unknown as typeof Deno.serve; 143 | const handler = webhookCallback(bot, "std/http"); 144 | serve((req) => { 145 | return handler(req); 146 | }); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /test/core/client.test.ts: -------------------------------------------------------------------------------- 1 | import { createRawApi, type TransformableApi } from "../../src/core/client.ts"; 2 | import { GrammyError } from "../../src/mod.ts"; 3 | import { type ApiResponse } from "../../src/types.ts"; 4 | import { 5 | afterEach, 6 | assertEquals, 7 | assertRejects, 8 | beforeEach, 9 | describe, 10 | it, 11 | spy, 12 | type Stub, 13 | stub, 14 | } from "../deps.test.ts"; 15 | 16 | const token = "secret-token"; 17 | 18 | describe("API client", () => { 19 | let api: TransformableApi; 20 | let canUseWebhookReply: boolean; 21 | let response: ApiResponse<{ testValue: number }>; 22 | let fetchStub: Stub; 23 | 24 | beforeEach(() => { 25 | fetchStub = stub( 26 | globalThis, 27 | "fetch", 28 | () => Promise.resolve(new Response(JSON.stringify(response))), 29 | ); 30 | canUseWebhookReply = false; 31 | api = createRawApi(token, { 32 | apiRoot: "my-api-root", 33 | buildUrl: spy((root, token, method) => `${root}${token}${method}`), 34 | canUseWebhookReply: () => canUseWebhookReply, 35 | timeoutSeconds: 1, 36 | }, { 37 | send: spy(() => {}), 38 | }); 39 | }); 40 | 41 | afterEach(() => { 42 | fetchStub.restore(); 43 | }); 44 | 45 | it("should return payloads", async () => { 46 | response = { ok: true, result: { testValue: 0 } }; 47 | const me = await api.raw.getMe(); 48 | assertEquals(me, response.result); 49 | }); 50 | 51 | it("should throw errors", async () => { 52 | response = { ok: false, error_code: 42, description: "evil" }; 53 | await assertRejects( 54 | () => api.raw.getMe(), 55 | GrammyError, 56 | "Call to 'getMe' failed! (42: evil)", 57 | ); 58 | }); 59 | }); 60 | 61 | // TODO: add tests: 62 | // - for all config options 63 | // - for webhook reply 64 | // - for api transformers 65 | // - for networking errors 66 | // - for networking timeouts 67 | -------------------------------------------------------------------------------- /test/core/error.test.ts: -------------------------------------------------------------------------------- 1 | import { HttpError, toHttpError } from "../../src/core/error.ts"; 2 | import { assertThrows, describe, it } from "../deps.test.ts"; 3 | 4 | describe("toHttpError", () => { 5 | it("should throw errors", () => { 6 | const sensitiveLogs = false; 7 | const handler = () => toHttpError("method", sensitiveLogs)(0); 8 | assertThrows( 9 | handler, 10 | HttpError, 11 | "Network request for 'method' failed!", 12 | ); 13 | }); 14 | 15 | it("should include Telegram info", () => { 16 | const sensitiveLogs = false; 17 | const handler = () => 18 | toHttpError("method", sensitiveLogs)({ 19 | status: "STAT", 20 | statusText: "status text", 21 | }); 22 | assertThrows( 23 | handler, 24 | HttpError, 25 | "Network request for 'method' failed! (STAT: status text)", 26 | ); 27 | }); 28 | 29 | it("should include sensitive info", () => { 30 | const sensitiveLogs = true; 31 | const handler = () => 32 | toHttpError("method", sensitiveLogs)(new Error("info")); 33 | assertThrows( 34 | handler, 35 | HttpError, 36 | "Network request for 'method' failed! info", 37 | ); 38 | }); 39 | 40 | it("should include Telegram info and sensitive info", () => { 41 | const sensitiveLogs = true; 42 | const handler = () => 43 | toHttpError("method", sensitiveLogs)( 44 | Object.assign(new Error("info"), { 45 | status: "STAT", 46 | statusText: "status text", 47 | }), 48 | ); 49 | assertThrows( 50 | handler, 51 | HttpError, 52 | "Network request for 'method' failed! (STAT: status text) info", 53 | ); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /test/core/payload.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createFormDataPayload, 3 | requiresFormDataUpload, 4 | } from "../../src/core/payload.ts"; 5 | import { InputFile } from "../../src/mod.ts"; 6 | import { 7 | assert, 8 | assertEquals, 9 | assertFalse, 10 | convertToUint8Array, 11 | describe, 12 | it, 13 | } from "../deps.test.ts"; 14 | 15 | describe("requiresFormDataUpload", () => { 16 | it("should ignore primitives", () => { 17 | assertFalse(requiresFormDataUpload(0)); 18 | assertFalse(requiresFormDataUpload("")); 19 | assertFalse(requiresFormDataUpload(true)); 20 | assertFalse(requiresFormDataUpload(false)); 21 | assertFalse(requiresFormDataUpload("asdfa")); 22 | assertFalse(requiresFormDataUpload(324234)); 23 | assertFalse(requiresFormDataUpload(Symbol())); 24 | }); 25 | 26 | it("should ignore objects", () => { 27 | assertFalse(requiresFormDataUpload({})); 28 | assertFalse(requiresFormDataUpload({ key: 0 })); 29 | assertFalse(requiresFormDataUpload({ a: 1, b: 2 })); 30 | assertFalse(requiresFormDataUpload({ foo: "asdf", bar: { baz: 3 } })); 31 | assertFalse(requiresFormDataUpload({ foo: "asdf", bar: [3, 3] })); 32 | assertFalse(requiresFormDataUpload([])); 33 | assertFalse(requiresFormDataUpload([1, 2, 3, "asdf", { a: -4 }])); 34 | assertFalse(requiresFormDataUpload(new Response(""))); 35 | }); 36 | 37 | it("should detect InputFiles inside objects", () => { 38 | assert(requiresFormDataUpload(new InputFile(""))); 39 | assert(requiresFormDataUpload({ data: new InputFile("") })); 40 | assert(requiresFormDataUpload([0, 1, new InputFile("")])); 41 | assert(requiresFormDataUpload({ x: [0, 1, new InputFile("")] })); 42 | assert(requiresFormDataUpload({ x: [0, 1, { y: new InputFile("") }] })); 43 | }); 44 | 45 | // TODO: json payloads, including nullish values 46 | 47 | it("builds multipart/form-data streams", async () => { 48 | const fileContent = "abc"; 49 | const buffer = new TextEncoder().encode(fileContent); 50 | const document = new InputFile(buffer, "my-file"); 51 | const payload = createFormDataPayload( 52 | { chat_id: 42, document }, 53 | (err) => { 54 | // cannot happen 55 | throw err; 56 | }, 57 | ); 58 | 59 | // based on testing seed which generates stable randomness 60 | const boundary = "----------a7tvrr8hjhi2q5kkuoh9kabvsgsu6ywp"; 61 | const attachId = "dam8u60sbhdqvv6m"; 62 | 63 | assertEquals(payload.method, "POST"); 64 | const headers = { 65 | "content-type": `multipart/form-data; boundary=${boundary}`, 66 | connection: "keep-alive", 67 | }; 68 | assertEquals(payload.headers, headers); 69 | const body = await convertToUint8Array(payload.body); 70 | const actual = new TextDecoder().decode(body); 71 | const expected = `--${boundary}\r 72 | content-disposition:form-data;name="chat_id"\r 73 | \r 74 | 42\r 75 | --${boundary}\r 76 | content-disposition:form-data;name="document"\r 77 | \r 78 | attach://${attachId}\r 79 | --${boundary}\r 80 | content-disposition:form-data;name="${attachId}";filename=${document.filename}\r 81 | content-type:application/octet-stream\r 82 | \r 83 | ${fileContent}\r 84 | --${boundary}--\r 85 | `; 86 | assertEquals(actual, expected); 87 | }); 88 | }); 89 | 90 | // TODO: adds tests for: 91 | // - other input file types 92 | // - errors in streams 93 | // - null values which should be removed 94 | // - complex values which should get JSON.stringify'ed (with nulls removed) 95 | // - input file types with several nested input files 96 | -------------------------------------------------------------------------------- /test/deps.test.ts: -------------------------------------------------------------------------------- 1 | export { 2 | assert, 3 | assertEquals, 4 | assertFalse, 5 | assertInstanceOf, 6 | assertNotStrictEquals, 7 | assertObjectMatch, 8 | assertRejects, 9 | assertStringIncludes, 10 | assertThrows, 11 | } from "jsr:@std/assert"; 12 | export { afterEach, beforeEach, describe, it } from "jsr:@std/testing/bdd"; 13 | export { type Spy, spy, type Stub, stub } from "jsr:@std/testing/mock"; 14 | export { assertType, type IsExact } from "jsr:@std/testing/types"; 15 | export { type IsMutuallyAssignable } from "jsr:@std/testing/unstable-types"; 16 | 17 | /** 18 | * Collects a potentially async iterator of `Uint8Array` objects into a single 19 | * array. 20 | * 21 | * @param data a stream of byte chunks 22 | */ 23 | export async function convertToUint8Array( 24 | data: Iterable | AsyncIterable, 25 | ) { 26 | const values: number[] = []; 27 | 28 | for await (const chunk of data) { 29 | values.push(...chunk); 30 | } 31 | 32 | return new Uint8Array(values); 33 | } 34 | -------------------------------------------------------------------------------- /test/filter.test.ts: -------------------------------------------------------------------------------- 1 | import { Context, type FilterQuery, matchFilter } from "../src/mod.ts"; 2 | import { 3 | assert, 4 | assertEquals, 5 | assertThrows, 6 | describe, 7 | it, 8 | } from "./deps.test.ts"; 9 | 10 | describe("matchFilter", () => { 11 | it("should reject empty filters", () => { 12 | assertThrows(() => matchFilter("" as FilterQuery)); 13 | assertThrows(() => matchFilter(":" as FilterQuery)); 14 | assertThrows(() => matchFilter("::" as FilterQuery)); 15 | assertThrows(() => matchFilter(" " as FilterQuery)); 16 | }); 17 | 18 | it("should reject invalid default omissions", () => { 19 | assertThrows(() => matchFilter("message:" as FilterQuery)); 20 | assertThrows(() => matchFilter("::me" as FilterQuery)); 21 | }); 22 | 23 | it("should perform L1 filtering", () => { 24 | const ctx = { update: { message: {} } } as Context; 25 | assert(matchFilter("message")(ctx)); 26 | assert(!matchFilter("edited_message")(ctx)); 27 | }); 28 | 29 | it("should perform L2 filtering", () => { 30 | const ctx = { update: { message: { text: "" } } } as Context; 31 | assert(matchFilter("message:text")(ctx)); 32 | assert(!matchFilter("edited_message")(ctx)); 33 | assert(!matchFilter("edited_message:text")(ctx)); 34 | }); 35 | 36 | it("should fill in L1 defaults", () => { 37 | const ctx = { update: { message: { text: "" } } } as Context; 38 | assert(matchFilter(":text")(ctx)); 39 | assert(!matchFilter(":entities")(ctx)); 40 | assert(!matchFilter(":caption")(ctx)); 41 | assert(!matchFilter("edited_message")(ctx)); 42 | }); 43 | 44 | it("should fill in L2 defaults", () => { 45 | const ctx = { 46 | update: { message: { text: "", entities: [{ type: "url" }] } }, 47 | } as Context; 48 | assert(matchFilter("message::url")(ctx)); 49 | assert(matchFilter("::url")(ctx)); 50 | assert(!matchFilter("edited_message")(ctx)); 51 | }); 52 | 53 | it("should expand L1 shortcuts", () => { 54 | const ctxNew = { update: { message: { text: "" } } } as Context; 55 | assert(matchFilter("msg")(ctxNew)); 56 | assert(matchFilter("msg:text")(ctxNew)); 57 | assert(!matchFilter("msg:entities")(ctxNew)); 58 | assert(matchFilter("message")(ctxNew)); 59 | assert(matchFilter(":text")(ctxNew)); 60 | assert(!matchFilter(":audio")(ctxNew)); 61 | 62 | const ctxEdited = { 63 | update: { edited_message: { text: "" } }, 64 | } as Context; 65 | assert(!matchFilter(":text")(ctxEdited)); 66 | assert(matchFilter("edit")(ctxEdited)); 67 | assert(matchFilter("edit:text")(ctxEdited)); 68 | assert(!matchFilter("edit:entities")(ctxEdited)); 69 | assert(matchFilter("edited_message")(ctxEdited)); 70 | }); 71 | 72 | it("should expand L2 shortcuts", () => { 73 | const ctx = { 74 | update: { edited_message: { photo: {}, caption: "" } }, 75 | } as Context; 76 | assert(matchFilter("edit")(ctx)); 77 | assert(matchFilter("edit:photo")(ctx)); 78 | assert(!matchFilter(":photo")(ctx)); 79 | assert(matchFilter("edited_message:media")(ctx)); 80 | assert(matchFilter("edit:caption")(ctx)); 81 | assert(matchFilter("edited_message:file")(ctx)); 82 | assert(matchFilter("edit:file")(ctx)); 83 | assert(!matchFilter(":media")(ctx)); 84 | assert(!matchFilter(":file")(ctx)); 85 | }); 86 | 87 | it("should perform L3 filtering", () => { 88 | let ctx = { 89 | update: { message: { text: "", entities: [{ type: "url" }] } }, 90 | } as Context; 91 | assert(matchFilter("message:entities:url")(ctx)); 92 | 93 | ctx = { 94 | me: { id: 42 }, 95 | update: { message: { left_chat_member: { id: 42, is_bot: true } } }, 96 | } as Context; 97 | assert(matchFilter(":left_chat_member:me")(ctx)); 98 | assert(matchFilter(":left_chat_member:is_bot")(ctx)); 99 | }); 100 | 101 | it("should match multiple filters", () => { 102 | const entity = { type: "" }; 103 | const ctx = { 104 | update: { 105 | message: { text: "", entities: [{ type: "italic" }, entity] }, 106 | }, 107 | } as Context; 108 | for (const t of ["url", "bold", "bot_command", "cashtag", "code"]) { 109 | entity.type = t; 110 | assert( 111 | matchFilter([ 112 | "::url", 113 | "::bold", 114 | "::bot_command", 115 | "::cashtag", 116 | "::code", 117 | ])(ctx), 118 | ); 119 | } 120 | }); 121 | 122 | it("should work with correct type-inference", () => { 123 | const text = "I <3 grammY"; 124 | const ctx = new Context( 125 | // deno-lint-ignore no-explicit-any 126 | { message: { text } } as any, 127 | // deno-lint-ignore no-explicit-any 128 | undefined as any, 129 | // deno-lint-ignore no-explicit-any 130 | undefined as any, 131 | ); 132 | const pred = matchFilter([":text", "callback_query:data"]); 133 | if (pred(ctx)) { 134 | if (ctx.callbackQuery) { 135 | const s: string = ctx.update.callback_query.data; 136 | assert(s); 137 | throw "never"; 138 | } else { 139 | const t: string = (ctx.channelPost ?? ctx.message).text; 140 | assertEquals(t, text); 141 | } 142 | } else { 143 | throw "never"; 144 | } 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /test/types.test.ts: -------------------------------------------------------------------------------- 1 | import { debug as d } from "../src/platform.deno.ts"; 2 | import { InputFile } from "../src/types.ts"; 3 | import { 4 | assertEquals, 5 | assertInstanceOf, 6 | assertRejects, 7 | assertStringIncludes, 8 | convertToUint8Array, 9 | stub, 10 | } from "./deps.test.ts"; 11 | 12 | Deno.test({ 13 | name: "file name inference", 14 | fn() { 15 | assertEquals(new InputFile("/tmp/file.txt").filename, "file.txt"); 16 | assertEquals( 17 | new InputFile((function* (): Iterable {})()).filename, 18 | undefined, 19 | ); 20 | assertEquals( 21 | new InputFile({ url: "https://grammy.dev/file.txt" }).filename, 22 | "file.txt", 23 | ); 24 | assertEquals( 25 | new InputFile({ url: "https://grammy.dev" }).filename, 26 | "grammy.dev", 27 | ); 28 | assertEquals( 29 | new InputFile(new URL("https://grammy.dev/file.txt")).filename, 30 | "file.txt", 31 | ); 32 | assertEquals( 33 | new InputFile(new URL("https://grammy.dev")).filename, 34 | "grammy.dev", 35 | ); 36 | }, 37 | }); 38 | 39 | Deno.test({ 40 | name: "invalid usage warning", 41 | fn() { 42 | const debug = stub(d as Console, "log"); 43 | d.enable("*"); 44 | new InputFile("http://grammy.dev"); 45 | new InputFile("https://grammy.dev"); 46 | d.disable("*"); 47 | debug.restore(); 48 | assertEquals(debug.calls.length, 2); 49 | assertStringIncludes(debug.calls[0].args[0], "local file path"); 50 | assertStringIncludes(debug.calls[1].args[0], "local file path"); 51 | }, 52 | }); 53 | 54 | Deno.test({ 55 | name: "throw upon using a consumed InputFile", 56 | fn() { 57 | const file = new InputFile((function* (): Iterable {})()); 58 | const raw = () => file.toRaw(); 59 | raw(); 60 | assertRejects(raw, "consumed InputFile"); 61 | }, 62 | }); 63 | 64 | Deno.test({ 65 | name: "convert Uint8Array to raw", 66 | async fn() { 67 | const bytes = new Uint8Array([65, 66, 67]); 68 | const file = new InputFile(bytes); 69 | const data = await file.toRaw(); 70 | assertInstanceOf(data, Uint8Array); 71 | assertEquals(data, bytes); 72 | }, 73 | }); 74 | 75 | Deno.test({ 76 | name: "convert file to raw", 77 | async fn() { 78 | const bytes = new Uint8Array([65, 66, 67]); 79 | const open = stub(Deno, "open", (path) => { 80 | assertEquals(path, "/tmp/file.txt"); 81 | function* data() { 82 | yield bytes; 83 | } 84 | 85 | const stream = ReadableStream.from(data()); 86 | return Promise.resolve({ readable: stream } as Deno.FsFile); 87 | }); 88 | const file = new InputFile("/tmp/file.txt"); 89 | assertEquals(file.filename, "file.txt"); 90 | const data = await file.toRaw(); 91 | if (data instanceof Uint8Array) throw new Error("no itr"); 92 | const values = await convertToUint8Array(data); 93 | assertEquals(values, bytes); 94 | open.restore(); 95 | }, 96 | }); 97 | 98 | Deno.test({ 99 | name: "convert blob to raw", 100 | async fn() { 101 | const blob = new Blob(["AB", "CD"]); 102 | const file = new InputFile(blob); 103 | const data = await file.toRaw(); 104 | if (data instanceof Uint8Array) throw new Error("no itr"); 105 | const values = await convertToUint8Array(data); 106 | assertEquals(values, new Uint8Array([65, 66, 67, 68])); // ABCD 107 | }, 108 | }); 109 | 110 | Deno.test({ 111 | name: "convert URL to raw", 112 | async fn() { 113 | const bytes = new Uint8Array([65, 66, 67]); 114 | const source = stub( 115 | globalThis, 116 | "fetch", 117 | () => Promise.resolve(new Response(bytes)), 118 | ); 119 | const file0 = new InputFile({ url: "https://grammy.dev" }); 120 | const file1 = new InputFile(new URL("https://grammy.dev")); 121 | const data0 = await file0.toRaw(); 122 | const data1 = await file1.toRaw(); 123 | if (data0 instanceof Uint8Array) throw new Error("no itr"); 124 | if (data1 instanceof Uint8Array) throw new Error("no itr"); 125 | const values0 = await convertToUint8Array(data0); 126 | const values1 = await convertToUint8Array(data1); 127 | assertEquals(values0, bytes); 128 | assertEquals(values1, bytes); 129 | source.restore(); 130 | }, 131 | }); 132 | 133 | Deno.test({ 134 | name: "convert Response to raw", 135 | async fn() { 136 | const bytes = new Uint8Array([65, 66, 67]); 137 | const file0 = new InputFile(new Response(bytes)); 138 | const data0 = await file0.toRaw(); 139 | if (data0 instanceof Uint8Array) throw new Error("no itr"); 140 | const values0 = await convertToUint8Array(data0); 141 | assertEquals(values0, bytes); 142 | }, 143 | }); 144 | 145 | Deno.test({ 146 | name: "convert supplier function to raw", 147 | async fn() { 148 | const blob = new Blob(["AB", "CD"]); 149 | const file = new InputFile(() => blob); 150 | const data = await file.toRaw(); 151 | if (data instanceof Uint8Array) throw new Error("no itr"); 152 | const values = await convertToUint8Array(data); 153 | assertEquals(values, new Uint8Array([65, 66, 67, 68])); // ABCD 154 | }, 155 | }); 156 | 157 | Deno.test({ 158 | name: "handle invalid URLs", 159 | fn() { 160 | const source = stub( 161 | globalThis, 162 | "fetch", 163 | () => Promise.resolve(new Response(null)), 164 | ); 165 | const file = new InputFile({ url: "https://grammy.dev" }); 166 | 167 | assertRejects( 168 | () => file.toRaw(), 169 | "no response body from 'https://grammy.dev'", 170 | ); 171 | source.restore(); 172 | }, 173 | }); 174 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "forceConsistentCasingInFileNames": true, 4 | "newLine": "lf", 5 | "noFallthroughCasesInSwitch": true, 6 | "noImplicitReturns": true, 7 | "noUnusedParameters": true, 8 | "rootDir": "./src/", 9 | "strict": true, 10 | "declaration": true, 11 | "moduleResolution": "node", 12 | "module": "commonjs", 13 | "outDir": "./out/", 14 | "skipLibCheck": true, 15 | "target": "es2019" 16 | }, 17 | "include": [ 18 | "src/" 19 | ], 20 | "exclude": [ 21 | "src/*.web.ts" 22 | ], 23 | "deno2node": { 24 | "shim": "./src/shim.node.ts" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | export * from "./out/types"; 2 | --------------------------------------------------------------------------------