diff --git a/.babelrc b/.babelrc deleted file mode 100644 index d03285da..00000000 --- a/.babelrc +++ /dev/null @@ -1,10 +0,0 @@ -{ - "plugins": [ - "transform-strict-mode", - "transform-object-rest-spread", - "transform-class-properties" - ], - "presets": [ - "babel-preset-es2015" - ] -} diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 417342d6..00000000 --- a/.eslintignore +++ /dev/null @@ -1,2 +0,0 @@ -bin -*.md diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 89f049b6..00000000 --- a/.eslintrc +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": "airbnb/base", - "parser": "babel-eslint", - "rules": { - "new-cap": 0, - "prefer-arrow-callback": 0, - "no-param-reassign": [2,{"props":false}], - "max-len": [2, 200], - "arrow-body-style": 0, - "comma-dangle": 0, - "indent": ["error", 2] - }, - "plugins": [ - "mocha" - ], - "env": { - "mocha": true - } -} \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index adc2bcc8..bd872a43 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,8 +4,9 @@ For example, if your PR passes all tests, you would mark the option as so: - [x] All tests pass Note the 'x' in between the square brackets '[]' --> -- [ ] All tests pass -- [ ] I have run `npm run doc` +- [ ] All unit tests pass (`npm run test:node:unit`) +- [ ] All integration tests pass (`npm run test:node:integration`) +- [ ] `npm run typecheck` is clean ### Description @@ -21,3 +22,65 @@ For example, * Issue #1: https://github.com/yagop/node-telegram-bot-api/issues/1 * Telegram Bot API - Getting updates: https://core.telegram.org/bots/api#getting-updates --> + +--- + +### Running the test suite + +The project ships two test layers and supports both the Node.js native test +runner and Bun. All scripts assume `npm install` (or `bun install`) has been +run first. + +#### Unit tests + +Pure unit tests with no network. Safe to run anywhere — no token required. + +```bash +npm run test:node:unit # Node — node:test runner via tsx +npm run test:bun:unit # Bun — bun:test runner +``` + +#### Integration tests + +Hit `api.telegram.org` directly. Tests that would mutate irreversible bot +configuration (e.g. `logOut`, `close`, `deleteWebHook`, `setMyName`, +`setMyProfilePhoto`, `deleteStickerSet`, …) are deliberately skipped. + +**Required environment variables** + +| Var | Purpose | +| --- | --- | +| `NODE_TELEGRAM_TOKEN` | Bot token (or `TEST_TELEGRAM_TOKEN` as fallback). Create one via [@BotFather](https://t.me/BotFather). | +| `TEST_GROUP_ID` | Chat id where the bot can send messages (group or private). | +| `TEST_USER_ID` | A user id the bot can resolve in `TEST_GROUP_ID`. | + +**Optional** + +| Var | Default | Purpose | +| --- | --- | --- | +| `TEST_STICKER_SET_NAME` | `pusheen` | Name of a public sticker set used in sticker tests. | +| `TEST_CUSTOM_EMOJI_ID` | _(unset)_ | A custom emoji id; the related test is skipped if unset. | + +```bash +NODE_TELEGRAM_TOKEN="" \ +TEST_GROUP_ID="-1001234567890" \ +TEST_USER_ID="123456789" \ + npm run test:node:integration + +# Bun equivalent +NODE_TELEGRAM_TOKEN="" \ +TEST_GROUP_ID="-1001234567890" \ +TEST_USER_ID="123456789" \ + npm run test:bun:integration +``` + +> **Tip:** add the bot to a private test group, grab its id with +> [`@RawDataBot`](https://t.me/RawDataBot) (or any update logger), and +> use that id for `TEST_GROUP_ID`. `TEST_GROUP_ID` accepts the canonical +> negative form (`-100…`) or a positive number — the suite normalizes it. + +#### Typecheck + +```bash +npm run typecheck # tsc --noEmit +``` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..746a891c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,106 @@ +name: CI + +on: + push: + branches: [master, main, feat/typescript] + pull_request: + workflow_dispatch: + inputs: + run_integration: + description: "Run integration tests against api.telegram.org (requires TEST_TELEGRAM_TOKEN secret)" + type: boolean + default: false + +permissions: + contents: read + +# Cancel in-progress runs for the same PR/branch when a new commit is pushed. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + typecheck: + name: Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - name: Install dependencies + run: npm ci || npm install --no-audit --no-fund + - name: tsc --noEmit + run: npx tsc --noEmit + + unit-node: + name: Unit tests (Node ${{ matrix.node }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ["18", "20", "22"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - name: Install dependencies + run: npm ci || npm install --no-audit --no-fund + - name: Run unit tests + run: npm run test:node:unit + + unit-bun: + name: Unit tests (Bun) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + # Bun 1.2+ ships with node:test compatibility, which our suite uses. + bun-version: latest + - name: Install dependencies + run: bun install --frozen-lockfile || bun install + - name: Run unit tests with Bun + run: npm run test:bun:unit + + build: + name: Build (dist) + runs-on: ubuntu-latest + needs: typecheck + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - name: Install dependencies + run: npm ci || npm install --no-audit --no-fund + - name: Build + run: npm run build + - name: Verify dist artefacts + run: | + test -f dist/index.js + test -f dist/index.d.ts + test -f dist/telegram.js + test -f dist/telegram.d.ts + + integration: + name: Integration tests (api.telegram.org) + runs-on: ubuntu-latest + needs: typecheck + if: github.event_name == 'workflow_dispatch' && inputs.run_integration + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - name: Install dependencies + run: npm ci || npm install --no-audit --no-fund + - name: Run integration tests + env: + TEST_TELEGRAM_TOKEN: ${{ secrets.TEST_TELEGRAM_TOKEN }} + run: npm run test:node:integration diff --git a/.gitignore b/.gitignore index bd74acab..17219cda 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,12 @@ node_modules coverage/ npm-debug.log .package.json -package-lock.json output.md output/ lib/ lib-doc/ -.DS_Store \ No newline at end of file +.DS_Store +dist +.claude +.env +.vscode \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 097239ee..00000000 --- a/.travis.yml +++ /dev/null @@ -1,17 +0,0 @@ -language: node_js -node_js: - - "12" - - "10" - - "8" - - "6" -# -# create required bash scripts to run builds on pull requests in the future -# -#script: -# - 'if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then bash ./travis/run_on_pull_requests; fi' -# - 'if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then bash ./travis/run_on_non_pull_requests; fi' -after_success: - - bash <(curl -s https://codecov.io/bash) -cache: - directories: - - node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md index 8051989b..bdf3f5c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,44 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased][Unreleased] +## [1.0.0] — 2026-05-10 + +### Rewritten in TypeScript + +The library has been rewritten from JavaScript to TypeScript with Zod runtime type validation. + +### Added + +- **TypeScript** — full type coverage for all API methods, options, and responses +- **Zod schemas** — runtime validation of Telegram Bot API payloads, exported for reuse (`src/types/schemas.ts`) +- **ESM** — the package is now ESM-only (`"type": "module"`); `require()` is no longer supported +- `TelegramBotOptions` type exported from the main entrypoint +- Type exports: `ChatId`, `ParseMode`, `MessageEntity`, `ReplyMarkup`, `ReplyParameters`, `LinkPreviewOptions`, `SuggestedPostPrice`, `SuggestedPostInfo`, `SuggestedPostParameters`, and all Zod-inferred API types +- Node.js native test runner replaces Mocha +- `sendLivePhoto` method + +### Changed + +- `src/telegram.js` → `src/telegram.ts` (full rewrite) +- `src/telegramPolling.js` → `src/polling.ts` +- `src/telegramWebHook.js` → `src/webhook.ts` +- `src/errors.js` → `src/errors.ts` +- `src/utils.js` → `src/utils.ts` +- `test/` rewritten in TypeScript with `node:test` assertions +- Build output: `lib/` → `dist/` + +### Removed + +- CJS support — `require('node-telegram-bot-api')` no longer works; use `import` +- Mocha test infrastructure (`test/mocha.opts`, legacy `test/telegram.js`) +- Legacy `lib/` output directory +- `_deprecatedMessageTypes` (`new_chat_participant`, `left_chat_participant`) +- Legacy param `thumb` replace with `thumbnail` + +### Fixed + +- String errors now include timestamps in console output + ## [0.68.0][0.68.0] - 2026-04-05 Added: diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 00000000..a805c876 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,21 @@ +version: "{build}" + +environment: + matrix: + - nodejs_version: "22" + - nodejs_version: "20" + - nodejs_version: "18" + +platform: x64 + +install: + - ps: Install-Product node $env:nodejs_version x64 + - node --version + - npm --version + - npm ci + +test_script: + - npm run typecheck + - npm run test:node:unit + +build: off diff --git a/doc/api.md b/doc/api.md index fc424c33..14480c7c 100644 --- a/doc/api.md +++ b/doc/api.md @@ -218,7 +218,7 @@ Emits `message` when a message arrives. | [options.polling] | Boolean \| Object | false | Set true to enable polling or set options. If a WebHook has been set, it will be deleted automatically. | | [options.polling.timeout] | String \| Number | 10 | *Deprecated. Use `options.polling.params` instead*. Timeout in seconds for long polling. | | [options.testEnvironment] | Boolean | false | Set true to work with test enviroment. When working with the test environment, you may use HTTP links without TLS to test your Web App. | -| [options.polling.interval] | String \| Number | 300 | Interval between requests in miliseconds | +| [options.polling.interval] | String \| Number | 300 | Interval between requests in milliseconds | | [options.polling.autoStart] | Boolean | true | Start polling immediately | | [options.polling.params] | Object | | Parameters to be used in polling API requests. See https://core.telegram.org/bots/api#getupdates for more information. | | [options.polling.params.timeout] | Number | 10 | Timeout in seconds for long polling. | diff --git a/index.js b/index.js deleted file mode 100644 index 86e04808..00000000 --- a/index.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * If running on Nodejs 5.x and below, we load the transpiled code. - * Otherwise, we use the ES6 code. - * We are deprecating support for Node.js v5.x and below. - */ -const majorVersion = parseInt(process.versions.node.split('.')[0], 10); -if (majorVersion <= 5) { - const deprecate = require('./src/utils').deprecate; - deprecate('Node.js v5.x and below will no longer be supported in the future'); - module.exports = require('./lib/telegram'); -} else { - module.exports = require('./src/telegram'); -} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..0ae129d6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,606 @@ +{ + "name": "node-telegram-bot-api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "node-telegram-bot-api", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/node": "^25.6.2", + "tsx": "^4.21.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "25.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz", + "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json index b78aa518..c5c7a66e 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,21 @@ { "name": "node-telegram-bot-api", - "version": "0.68.0", - "description": "Telegram Bot API", - "main": "./index.js", + "version": "1.0.0", + "description": "Telegram Bot API — modern TypeScript rewrite with Zod typing", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE.md" + ], "directories": { "example": "examples", "test": "test" @@ -11,57 +24,30 @@ "telegram", "telegram bot", "telegram bot api", - "bot" + "bot", + "typescript", + "zod" ], "scripts": { - "gen-doc": "echo 'WARNING: `npm run gen-doc` is deprecated. Use `npm run doc` instead.' && npm run doc", - "doc": "jsdoc2md --files src/telegram.js --template doc/api.hbs > doc/api.md", - "build": "babel -d ./lib src", - "prepublishOnly": "npm run build && npm run gen-doc", - "eslint": "eslint ./src ./test ./examples", - "mocha": "mocha", - "pretest": "npm run build", - "test": "npm run eslint && istanbul cover ./node_modules/mocha/bin/_mocha" + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "test:node:unit": "node test/run-unit.mjs", + "test:node:integration": "node --test --test-reporter=spec --import tsx test/integration/telegram.test.ts", + "test:bun:unit": "bun test test/unit", + "test:bun:integration": "bun test --timeout 120000 test/integration" }, "author": "Yago Pérez ", "license": "MIT", "engines": { - "node": ">=0.12" + "node": ">=18" }, "dependencies": { - "@cypress/request": "^3.0.10", - "@cypress/request-promise": "^5.0.0", - "array.prototype.findindex": "^2.2.4", - "bl": "^1.2.3", - "debug": "^3.2.7", - "eventemitter3": "^3.0.0", - "file-type": "^3.9.0", - "mime": "^1.6.0", - "pump": "^2.0.0" + "zod": "^3.25.0" }, "devDependencies": { - "babel-cli": "^6.26.0", - "babel-eslint": "^8.0.3", - "babel-plugin-transform-class-properties": "^6.24.1", - "babel-plugin-transform-es2015-destructuring": "^6.23.0", - "babel-plugin-transform-es2015-parameters": "^6.24.1", - "babel-plugin-transform-es2015-shorthand-properties": "^6.24.1", - "babel-plugin-transform-es2015-spread": "^6.22.0", - "babel-plugin-transform-object-rest-spread": "^6.26.0", - "babel-plugin-transform-strict-mode": "^6.24.1", - "babel-preset-es2015": "^6.24.1", - "babel-register": "^6.26.0", - "concat-stream": "^1.6.0", - "eslint": "^2.13.1", - "eslint-config-airbnb": "^6.2.0", - "eslint-plugin-mocha": "^4.11.0", - "is": "^3.2.1", - "is-ci": "^1.0.10", - "istanbul": "^1.1.0-alpha.1", - "jsdoc-to-markdown": "^9.1.3", - "mocha": "^3.5.3", - "mocha-lcov-reporter": "^1.3.0", - "node-static": "^0.7.10" + "@types/node": "^25.6.2", + "tsx": "^4.21.0", + "typescript": "^5.7.0" }, "repository": { "type": "git", @@ -71,4 +57,4 @@ "url": "https://github.com/yagop/node-telegram-bot-api/issues" }, "homepage": "https://github.com/yagop/node-telegram-bot-api" -} +} \ No newline at end of file diff --git a/src/errors.js b/src/errors.js deleted file mode 100644 index 8f1c597b..00000000 --- a/src/errors.js +++ /dev/null @@ -1,68 +0,0 @@ -exports.BaseError = class BaseError extends Error { - /** - * @class BaseError - * @constructor - * @private - * @param {String} code Error code - * @param {String} message Error message - */ - constructor(code, message) { - super(`${code}: ${message}`); - this.code = code; - } - toJSON() { - return { - code: this.code, - message: this.message, - }; - } -}; - - -exports.FatalError = class FatalError extends exports.BaseError { - /** - * Fatal Error. Error code is `"EFATAL"`. - * @class FatalError - * @constructor - * @param {String|Error} data Error object or message - */ - constructor(data) { - const error = (typeof data === 'string') ? null : data; - const message = error ? error.message : data; - super('EFATAL', message); - if (error) { - this.stack = error.stack; - this.cause = error; - } - } -}; - - -exports.ParseError = class ParseError extends exports.BaseError { - /** - * Error during parsing. Error code is `"EPARSE"`. - * @class ParseError - * @constructor - * @param {String} message Error message - * @param {http.IncomingMessage} response Server response - */ - constructor(message, response) { - super('EPARSE', message); - this.response = response; - } -}; - - -exports.TelegramError = class TelegramError extends exports.BaseError { - /** - * Error returned from Telegram. Error code is `"ETELEGRAM"`. - * @class TelegramError - * @constructor - * @param {String} message Error message - * @param {http.IncomingMessage} response Server response - */ - constructor(message, response) { - super('ETELEGRAM', message); - this.response = response; - } -}; diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 00000000..633324a2 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,66 @@ +/** + * Error hierarchy used throughout the library. + * + * - {@link BaseError} — root class; all custom errors extend it. + * - {@link FatalError} — code `EFATAL`; non-recoverable problem (network, programmer mistake, etc.). + * - {@link ParseError} — code `EPARSE`; the response from Telegram could not be parsed. + * - {@link TelegramError} — code `ETELEGRAM`; the Bot API returned `ok: false`. + */ + +export interface TelegramErrorResponse { + status?: number; + body?: unknown; + headers?: Record; + /** Underlying response object (Response or http.IncomingMessage) when available. */ + raw?: unknown; +} + +export class BaseError extends Error { + public readonly code: string; + + constructor(code: string, message: string) { + super(`${code}: ${message}`); + this.code = code; + this.name = new.target.name; + // Restore prototype chain when targeting older TS module emit + Object.setPrototypeOf(this, new.target.prototype); + } + + toJSON(): { code: string; message: string } { + return { code: this.code, message: this.message }; + } +} + +export class FatalError extends BaseError { + public override readonly cause?: Error; + + constructor(data: string | Error) { + const message = typeof data === "string" ? data : data.message; + super("EFATAL", message); + if (typeof data !== "string") { + this.stack = data.stack; + this.cause = data; + } + } +} + +export class ParseError extends BaseError { + public readonly response?: TelegramErrorResponse; + + constructor(message: string, response?: TelegramErrorResponse) { + super("EPARSE", message); + this.response = response; + } +} + +export class TelegramError extends BaseError { + public readonly response?: TelegramErrorResponse; + + constructor(message: string, response?: TelegramErrorResponse) { + super("ETELEGRAM", message); + this.response = response; + } +} + +export const errors = { BaseError, FatalError, ParseError, TelegramError } as const; +export type Errors = typeof errors; diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 00000000..ed53ed8e --- /dev/null +++ b/src/http.ts @@ -0,0 +1,207 @@ +/** + * Thin HTTP transport wrapper around Node 18+'s built-in `fetch`. Centralises: + * - URL construction for the Bot API + * - x-www-form-urlencoded vs multipart/form-data body building + * - response parsing into Telegram's `{ ok, result, description, error_code }` envelope + * - error mapping into our error hierarchy + */ + +import createDebug from "./internal/debug.js"; +import { FatalError, ParseError, TelegramError, type TelegramErrorResponse } from "./errors.js"; +import { streamToBuffer, type PreparedFile } from "./utils.js"; + +const debug = createDebug("node-telegram-bot-api:http"); + +export interface RequestOptions { + /** Form fields (x-www-form-urlencoded) */ + form?: Record; + /** Query string fields (used in mixed form/multipart calls) */ + qs?: Record; + /** Multipart form data (file uploads) */ + formData?: Record; + /** Per-call abort signal */ + signal?: AbortSignal; + /** Per-call timeout in ms */ + timeoutMs?: number; +} + +export interface TelegramApiOk { + ok: true; + result: T; +} + +export interface TelegramApiErr { + ok: false; + description?: string; + error_code?: number; + parameters?: { migrate_to_chat_id?: number; retry_after?: number }; +} + +export type TelegramApiResponse = TelegramApiOk | TelegramApiErr; + +export interface HttpClientOptions { + baseApiUrl?: string; + testEnvironment?: boolean; + request?: { + timeoutMs?: number; + headers?: Record; + /** + * On `429 Too Many Requests`, sleep for `retry_after` seconds (as + * advertised by Telegram) and retry up to this many times. Set to `0` + * to opt out. Defaults to `2`. + */ + maxRetriesOn429?: number; + }; +} + +/** + * Coerce a value into the string form Telegram expects on `application/x-www-form-urlencoded` + * bodies. Booleans become `"true"`/`"false"`, arrays/objects become JSON, undefined/null are skipped. + */ +function toStringValue(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return JSON.stringify(value); +} + +function appendForm(target: URLSearchParams | FormData, fields: Record | undefined): void { + if (!fields) return; + for (const [key, value] of Object.entries(fields)) { + const stringified = toStringValue(value); + if (stringified !== undefined) target.append(key, stringified); + } +} + +async function buildBody(opts: RequestOptions): Promise<{ + body: FormData | string | undefined; + contentType?: string; +}> { + if (opts.formData && Object.keys(opts.formData).length > 0) { + // Materialise streams ahead of time so undici/fetch can hand them to FormData. + for (const file of Object.values(opts.formData)) { + if ( + !Buffer.isBuffer(file.value) && + typeof (file.value as NodeJS.ReadableStream).pipe === "function" + ) { + file.value = await streamToBuffer(file.value as NodeJS.ReadableStream); + } + } + + const fd = new FormData(); + appendForm(fd, opts.qs); + appendForm(fd, opts.form); + for (const [name, file] of Object.entries(opts.formData)) { + const buffer = file.value as Buffer; + // Copy into a fresh ArrayBuffer to satisfy the BlobPart type. + const ab = new ArrayBuffer(buffer.byteLength); + new Uint8Array(ab).set(buffer); + const blob = new Blob([ab], { type: file.contentType }); + fd.append(name, blob, file.filename); + } + // Content-type is set by fetch automatically for FormData. + return { body: fd }; + } + + const params = new URLSearchParams(); + appendForm(params, opts.qs); + appendForm(params, opts.form); + return { body: params.toString(), contentType: "application/x-www-form-urlencoded" }; +} + +export class HttpClient { + private readonly token: string; + private readonly options: HttpClientOptions; + + constructor(token: string, options: HttpClientOptions = {}) { + this.token = token; + this.options = options; + } + + buildURL(method: string): string { + const base = this.options.baseApiUrl ?? "https://api.telegram.org"; + const tail = this.options.testEnvironment ? "/test" : ""; + return `${base}/bot${this.token}${tail}/${method}`; + } + + async request(method: string, opts: RequestOptions = {}): Promise { + if (!this.token) throw new FatalError("Telegram Bot Token not provided!"); + + const url = this.buildURL(method); + const { body, contentType } = await buildBody(opts); + + const headers: Record = { + ...(this.options.request?.headers ?? {}), + }; + if (contentType) headers["content-type"] = contentType; + + debug("HTTP POST %s qs=%j form=%j", url, opts.qs, opts.form); + + const timeoutMs = opts.timeoutMs ?? this.options.request?.timeoutMs; + const maxRetries = this.options.request?.maxRetriesOn429 ?? 2; + + let attempt = 0; + while (true) { + const controller = new AbortController(); + const userSignal = opts.signal; + if (userSignal) { + if (userSignal.aborted) controller.abort(userSignal.reason); + else userSignal.addEventListener("abort", () => controller.abort(userSignal.reason), { once: true }); + } + const timer = timeoutMs ? setTimeout(() => controller.abort(new Error("HTTP timeout")), timeoutMs) : null; + + let response: Response; + try { + response = await fetch(url, { + method: "POST", + body: body as BodyInit | undefined, + headers, + signal: controller.signal, + }); + } catch (err) { + throw new FatalError(err as Error); + } finally { + if (timer) clearTimeout(timer); + } + + const status = response.status; + const text = await response.text(); + debug("response %s %s", status, text.length > 1000 ? `${text.slice(0, 1000)}…` : text); + + let parsed: TelegramApiResponse; + try { + parsed = JSON.parse(text) as TelegramApiResponse; + } catch { + throw new ParseError(`Error parsing response: ${text}`, makeResponseInfo(status, text)); + } + + if (parsed.ok) return parsed.result; + + const retryAfter = parsed.parameters?.retry_after; + if ( + parsed.error_code === 429 && + typeof retryAfter === "number" && + attempt < maxRetries && + !controller.signal.aborted + ) { + debug("429 Too Many Requests, sleeping %ds then retrying (attempt %d/%d)", retryAfter, attempt + 1, maxRetries); + await sleep((retryAfter + 1) * 1000); + attempt++; + continue; + } + + throw new TelegramError( + `${parsed.error_code ?? status} ${parsed.description ?? "Unknown error"}`, + makeResponseInfo(status, parsed), + ); + } + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function makeResponseInfo(status: number, body: unknown): TelegramErrorResponse { + return { status, body }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..d57220df --- /dev/null +++ b/src/index.ts @@ -0,0 +1,20 @@ +/** + * Public entrypoint for `node-telegram-bot-api`. + * + * Provides: + * - The {@link TelegramBot} class (default export, for legacy compatibility). + * - Polling and Webhook helper classes (advanced use). + * - The full set of Zod schemas and inferred types. + * - The error hierarchy. + */ + +export { TelegramBot, type TelegramBotOptions } from "./telegram.js"; +export { TelegramBotPolling, type PollingOptions, type PollingStartOptions, type PollingStopOptions } from "./polling.js"; +export { TelegramBotWebHook, type WebHookOptions } from "./webhook.js"; +export { HttpClient, type HttpClientOptions, type RequestOptions } from "./http.js"; + +export * from "./errors.js"; +export * from "./types/index.js"; + +import { TelegramBot } from "./telegram.js"; +export default TelegramBot; diff --git a/src/internal/debug.ts b/src/internal/debug.ts new file mode 100644 index 00000000..07785c66 --- /dev/null +++ b/src/internal/debug.ts @@ -0,0 +1,40 @@ +/** + * Tiny `debug`-compatible shim. Activated by setting the DEBUG environment + * variable to a comma-separated list of namespaces (or "*" / "node-telegram-bot-api*"). + */ + +function namespaceEnabled(namespace: string): boolean { + const env = process.env.DEBUG ?? ""; + if (!env) return false; + return env + .split(/[\s,]+/) + .filter(Boolean) + .some((pattern) => { + if (pattern === "*") return true; + if (pattern.endsWith("*")) return namespace.startsWith(pattern.slice(0, -1)); + return pattern === namespace; + }); +} + +function format(arg: unknown, formatter?: string): string { + if (formatter === "%j") return JSON.stringify(arg); + if (typeof arg === "object") return JSON.stringify(arg); + return String(arg); +} + +export type Debugger = (template: string, ...rest: unknown[]) => void; + +export default function createDebug(namespace: string): Debugger { + const enabled = namespaceEnabled(namespace); + return (template: string, ...rest: unknown[]) => { + if (!enabled) return; + let i = 0; + const expanded = template.replace(/%[sdjOo%]/g, (token) => { + if (token === "%%") return "%"; + const value = rest[i++]; + return format(value, token); + }); + // eslint-disable-next-line no-console + console.error(` ${namespace} ${expanded}`); + }; +} diff --git a/src/internal/file-type.ts b/src/internal/file-type.ts new file mode 100644 index 00000000..a5f53c85 --- /dev/null +++ b/src/internal/file-type.ts @@ -0,0 +1,59 @@ +/** + * Magic-byte sniffer for the file types Telegram bots most commonly upload. + * Replaces the legacy `file-type` dependency. Best-effort; returns `null` if + * the format isn't recognised. + */ + +export interface DetectedType { + ext: string; + mime: string; +} + +function startsWith(buf: Buffer, bytes: number[], offset = 0): boolean { + if (buf.length < offset + bytes.length) return false; + for (let i = 0; i < bytes.length; i++) { + if (buf[offset + i] !== bytes[i]) return false; + } + return true; +} + +export function detectFileType(buf: Buffer): DetectedType | null { + if (!buf || buf.length < 4) return null; + + // PNG: 89 50 4E 47 0D 0A 1A 0A + if (startsWith(buf, [0x89, 0x50, 0x4e, 0x47])) return { ext: "png", mime: "image/png" }; + // JPEG: FF D8 FF + if (startsWith(buf, [0xff, 0xd8, 0xff])) return { ext: "jpg", mime: "image/jpeg" }; + // GIF: 47 49 46 38 + if (startsWith(buf, [0x47, 0x49, 0x46, 0x38])) return { ext: "gif", mime: "image/gif" }; + // WEBP: RIFF....WEBP + if (startsWith(buf, [0x52, 0x49, 0x46, 0x46]) && buf.length >= 12 && startsWith(buf, [0x57, 0x45, 0x42, 0x50], 8)) + return { ext: "webp", mime: "image/webp" }; + // BMP: 42 4D + if (startsWith(buf, [0x42, 0x4d])) return { ext: "bmp", mime: "image/bmp" }; + // PDF: 25 50 44 46 2D + if (startsWith(buf, [0x25, 0x50, 0x44, 0x46, 0x2d])) return { ext: "pdf", mime: "application/pdf" }; + // ZIP: 50 4B 03 04 (also docx/xlsx/pptx/odt etc.) + if (startsWith(buf, [0x50, 0x4b, 0x03, 0x04])) return { ext: "zip", mime: "application/zip" }; + // ID3 / MP3: 49 44 33 or FF FB + if (startsWith(buf, [0x49, 0x44, 0x33]) || startsWith(buf, [0xff, 0xfb])) + return { ext: "mp3", mime: "audio/mpeg" }; + // OGG: 4F 67 67 53 + if (startsWith(buf, [0x4f, 0x67, 0x67, 0x53])) return { ext: "ogg", mime: "audio/ogg" }; + // FLAC: 66 4C 61 43 + if (startsWith(buf, [0x66, 0x4c, 0x61, 0x43])) return { ext: "flac", mime: "audio/flac" }; + // WAV: RIFF....WAVE + if (startsWith(buf, [0x52, 0x49, 0x46, 0x46]) && buf.length >= 12 && startsWith(buf, [0x57, 0x41, 0x56, 0x45], 8)) + return { ext: "wav", mime: "audio/wav" }; + // MP4 / M4A / MOV: ....ftyp + if (buf.length >= 12 && startsWith(buf, [0x66, 0x74, 0x79, 0x70], 4)) { + const major = buf.slice(8, 12).toString("ascii"); + if (major.startsWith("M4A")) return { ext: "m4a", mime: "audio/mp4" }; + if (major.startsWith("qt")) return { ext: "mov", mime: "video/quicktime" }; + return { ext: "mp4", mime: "video/mp4" }; + } + // WEBM / Matroska: 1A 45 DF A3 + if (startsWith(buf, [0x1a, 0x45, 0xdf, 0xa3])) return { ext: "webm", mime: "video/webm" }; + + return null; +} diff --git a/src/internal/mime.ts b/src/internal/mime.ts new file mode 100644 index 00000000..10abf286 --- /dev/null +++ b/src/internal/mime.ts @@ -0,0 +1,54 @@ +/** + * Minimal MIME lookup. Replaces the legacy `mime` dependency for the small + * subset of types the Telegram Bot API actually cares about. + * + * For unknown extensions, callers should fall back to + * `application/octet-stream`. + */ + +const MIME_BY_EXT: Record = { + // Audio + mp3: "audio/mpeg", + m4a: "audio/mp4", + ogg: "audio/ogg", + oga: "audio/ogg", + wav: "audio/wav", + flac: "audio/flac", + // Video + mp4: "video/mp4", + m4v: "video/mp4", + mov: "video/quicktime", + webm: "video/webm", + mkv: "video/x-matroska", + // Image + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + bmp: "image/bmp", + tiff: "image/tiff", + // Documents + pdf: "application/pdf", + zip: "application/zip", + json: "application/json", + txt: "text/plain", + csv: "text/csv", + html: "text/html", + htm: "text/html", + xml: "application/xml", + // Telegram-specific + tgs: "application/x-tgsticker", + // Stream / fallback + bin: "application/octet-stream", + pem: "application/x-pem-file", + crt: "application/x-x509-ca-cert", +}; + +export function lookupMime(filename: string): string | null { + const dot = filename.lastIndexOf("."); + if (dot === -1) return null; + const ext = filename.slice(dot + 1).toLowerCase(); + return MIME_BY_EXT[ext] ?? null; +} diff --git a/src/polling.ts b/src/polling.ts new file mode 100644 index 00000000..d8b78cc7 --- /dev/null +++ b/src/polling.ts @@ -0,0 +1,189 @@ +import createDebug from "./internal/debug.js"; + +import { FatalError } from "./errors.js"; +import type { TelegramBot } from "./telegram.js"; +import type { GetUpdatesOptions } from "./types/options.js"; +import type { Update } from "./types/schemas.js"; + +const debug = createDebug("node-telegram-bot-api:polling"); + +const ANOTHER_WEB_HOOK_USED = 409; + +export interface PollingOptions { + /** Polling interval in milliseconds between successive `getUpdates` calls. */ + interval?: number; + /** Whether to start polling automatically when the bot is constructed. */ + autoStart?: boolean; + /** Parameters forwarded to `getUpdates`. */ + params?: GetUpdatesOptions; + /** @deprecated Use `params.timeout` instead. */ + timeout?: number; +} + +export interface PollingStartOptions { + restart?: boolean; +} + +export interface PollingStopOptions { + cancel?: boolean; + reason?: string; +} + +interface InternalParams extends GetUpdatesOptions { + offset: number; + timeout: number; +} + +export class TelegramBotPolling { + private readonly bot: TelegramBot; + public readonly interval: number; + public readonly params: InternalParams; + private _abort = false; + private _abortController?: AbortController; + private _activeRequest: Promise | null = null; + private _pollingTimeout: NodeJS.Timeout | null = null; + private _running = false; + + constructor(bot: TelegramBot, options: PollingOptions = {}) { + this.bot = bot; + this.interval = typeof options.interval === "number" ? options.interval : 300; + const params: GetUpdatesOptions = options.params ?? {}; + this.params = { + ...params, + offset: typeof params.offset === "number" ? params.offset : 0, + timeout: typeof params.timeout === "number" ? params.timeout : 10, + }; + if (typeof options.timeout === "number") { + this.params.timeout = options.timeout; + } + } + + /** + * Start polling. If polling is already running, the call resolves immediately + * unless `restart: true` is passed (in which case the previous loop is cancelled + * before a fresh one is started). + */ + async start(options: PollingStartOptions = {}): Promise { + if (this._running) { + if (!options.restart) return; + await this.stop({ cancel: true, reason: "Polling restart" }); + } + this._running = true; + this._abort = false; + void this._loop(); + } + + /** + * Stop polling. Resolves once the current `getUpdates` call has completed. + */ + async stop(options: PollingStopOptions = {}): Promise { + if (!this._running) return; + this._running = false; + if (this._pollingTimeout) { + clearTimeout(this._pollingTimeout); + this._pollingTimeout = null; + } + if (options.cancel && this._abortController) { + this._abortController.abort(options.reason ?? "Polling stop"); + } + this._abort = true; + if (this._activeRequest) { + try { + await this._activeRequest; + } catch { + /* swallow — caller has already been notified */ + } + } + } + + isPolling(): boolean { + return this._running; + } + + private _emitError(err: unknown): void { + if (!this.bot.listeners("polling_error").length) { + // Fallback: log to stderr. + // eslint-disable-next-line no-console + console.error(`${new Date().toISOString()} error: [polling_error] %j`, err); + return; + } + this.bot.emit("polling_error", err); + } + + private _scheduleNext(): void { + if (!this._running || this._abort) { + debug("Polling aborted"); + return; + } + debug("setTimeout for %s milliseconds", this.interval); + this._pollingTimeout = setTimeout(() => void this._loop(), this.interval); + } + + private async _loop(): Promise { + const request = this._poll(); + this._activeRequest = request; + try { + await request; + } finally { + this._activeRequest = null; + this._scheduleNext(); + } + } + + private async _poll(): Promise { + try { + const updates = await this._getUpdates(); + debug("polling data %j", updates); + for (const update of updates) { + this.params.offset = update.update_id + 1; + debug("updated offset: %s", this.params.offset); + try { + this.bot.processUpdate(update); + } catch (err) { + (err as { _processing?: boolean })._processing = true; + throw err; + } + } + } catch (err) { + const flagged = err as { _processing?: boolean; response?: { status?: number } }; + debug("polling error: %s", (err as Error).message); + if (!flagged._processing) { + this._emitError(err); + return; + } + delete flagged._processing; + // Update-processing failure handling — see the original library's discussion at + // https://github.com/yagop/node-telegram-bot-api/issues/36#issuecomment-268532067 + try { + await this.bot.getUpdates({ + offset: this.params.offset, + limit: 1, + timeout: 0, + }); + this._emitError(err); + } catch (requestErr) { + // eslint-disable-next-line no-console + console.error( + `${new Date().toISOString()} error: Internal handling of The Offset Infinite Loop failed`, + requestErr, + ); + this.bot.emit("error", new FatalError(err as Error)); + } + } + } + + private async _getUpdates(): Promise { + debug("polling with options: %j", this.params); + try { + return await this.bot.getUpdates(this.params); + } catch (err) { + const status = (err as { response?: { status?: number } }).response?.status; + if (status === ANOTHER_WEB_HOOK_USED) { + debug("unsetting webhook because polling is in use"); + await this.bot.deleteWebHook(); + return this.bot.getUpdates(this.params); + } + throw err; + } + } +} diff --git a/src/telegram.js b/src/telegram.js deleted file mode 100644 index 4457ca76..00000000 --- a/src/telegram.js +++ /dev/null @@ -1,4072 +0,0 @@ -// shims -require('array.prototype.findindex').shim(); // for Node.js v0.x - -const errors = require('./errors'); -const TelegramBotWebHook = require('./telegramWebHook'); -const TelegramBotPolling = require('./telegramPolling'); -const debug = require('debug')('node-telegram-bot-api'); -const EventEmitter = require('eventemitter3'); -const fileType = require('file-type'); -const request = require('@cypress/request-promise'); -const streamedRequest = require('@cypress/request'); -const qs = require('querystring'); -const stream = require('stream'); -const mime = require('mime'); -const path = require('path'); -const URL = require('url'); -const fs = require('fs'); -const pump = require('pump'); -const deprecate = require('./utils').deprecate; - -const _messageTypes = [ - 'text', - 'animation', - 'audio', - 'channel_chat_created', - 'contact', - 'delete_chat_photo', - 'dice', - 'document', - 'game', - 'group_chat_created', - 'invoice', - 'left_chat_member', - 'location', - 'migrate_from_chat_id', - 'migrate_to_chat_id', - 'new_chat_members', - 'new_chat_photo', - 'new_chat_title', - 'passport_data', - 'photo', - 'pinned_message', - 'poll', - 'sticker', - 'successful_payment', - 'supergroup_chat_created', - 'video', - 'video_note', - 'voice', - 'video_chat_started', - 'video_chat_ended', - 'video_chat_participants_invited', - 'video_chat_scheduled', - 'message_auto_delete_timer_changed', - 'chat_invite_link', - 'chat_member_updated', - 'web_app_data', - 'message_reaction' -]; - -const _deprecatedMessageTypes = [ - 'new_chat_participant', 'left_chat_participant' -]; - -/** - * JSON-serialize data. If the provided data is already a String, - * return it as is. - * @private - * @param {*} data - * @return {String} - */ -function stringify(data) { - if (typeof data === 'string') { - return data; - } - return JSON.stringify(data); -} - - -class TelegramBot extends EventEmitter { - /** - * The different errors the library uses. - * @type {Object} - */ - static get errors() { - return errors; - } - - /** - * The types of message updates the library handles. - * @type {String[]} - */ - static get messageTypes() { - return _messageTypes; - } - - /** - * Add listener for the specified [event](https://github.com/yagop/node-telegram-bot-api/blob/master/doc/usage.md#events). - * This is the usual `emitter.on()` method. - * @param {String} event - * @param {Function} listener - * @see {@link https://github.com/yagop/node-telegram-bot-api/blob/master/doc/usage.md#events|Available events} - * @see https://nodejs.org/api/events.html#events_emitter_on_eventname_listener - */ - on(event, listener) { - if (_deprecatedMessageTypes.indexOf(event) !== -1) { - const url = 'https://github.com/yagop/node-telegram-bot-api/blob/master/doc/usage.md#events'; - deprecate(`Events ${_deprecatedMessageTypes.join(',')} are deprecated. See the updated list of events: ${url}`); - } - super.on(event, listener); - } - - /** - * Both request method to obtain messages are implemented. To use standard polling, set `polling: true` - * on `options`. Notice that [webHook](https://core.telegram.org/bots/api#setwebhook) will need a SSL certificate. - * Emits `message` when a message arrives. - * - * @class TelegramBot - * @constructor - * @param {String} token Bot Token - * @param {Object} [options] - * @param {Boolean|Object} [options.polling=false] Set true to enable polling or set options. - * If a WebHook has been set, it will be deleted automatically. - * @param {String|Number} [options.polling.timeout=10] *Deprecated. Use `options.polling.params` instead*. - * Timeout in seconds for long polling. - * @param {Boolean} [options.testEnvironment=false] Set true to work with test enviroment. - * When working with the test environment, you may use HTTP links without TLS to test your Web App. - * @param {String|Number} [options.polling.interval=300] Interval between requests in miliseconds - * @param {Boolean} [options.polling.autoStart=true] Start polling immediately - * @param {Object} [options.polling.params] Parameters to be used in polling API requests. - * See https://core.telegram.org/bots/api#getupdates for more information. - * @param {Number} [options.polling.params.timeout=10] Timeout in seconds for long polling. - * @param {Array|String} [options.polling.params.allowed_updates] A JSON-serialized list of the update types you want your bot to receive. - * For example, specify ["message", "edited_channel_post", "callback_query"] to only receive updates of these types. - * @param {Boolean|Object} [options.webHook=false] Set true to enable WebHook or set options - * @param {String} [options.webHook.host="0.0.0.0"] Host to bind to - * @param {Number} [options.webHook.port=8443] Port to bind to - * @param {String} [options.webHook.key] Path to file with PEM private key for webHook server. - * The file is read **synchronously**! - * @param {String} [options.webHook.cert] Path to file with PEM certificate (public) for webHook server. - * The file is read **synchronously**! - * @param {String} [options.webHook.pfx] Path to file with PFX private key and certificate chain for webHook server. - * The file is read **synchronously**! - * @param {Boolean} [options.webHook.autoOpen=true] Open webHook immediately - * @param {Object} [options.webHook.https] Options to be passed to `https.createServer()`. - * Note that `options.webHook.key`, `options.webHook.cert` and `options.webHook.pfx`, if provided, will be - * used to override `key`, `cert` and `pfx` in this object, respectively. - * See https://nodejs.org/api/https.html#https_https_createserver_options_requestlistener for more information. - * @param {String} [options.webHook.healthEndpoint="/healthz"] An endpoint for health checks that always responds with 200 OK - * @param {Boolean} [options.onlyFirstMatch=false] Set to true to stop after first match. Otherwise, all regexps are executed - * @param {Object} [options.request] Options which will be added for all requests to telegram api. - * See https://github.com/request/request#requestoptions-callback for more information. - * @param {String} [options.baseApiUrl="https://api.telegram.org"] API Base URl; useful for proxying and testing - * @param {Boolean} [options.filepath=true] Allow passing file-paths as arguments when sending files, - * such as photos using `TelegramBot#sendPhoto()`. See [usage information][usage-sending-files-performance] - * for more information on this option and its consequences. - * @param {Boolean} [options.badRejection=false] Set to `true` - * **if and only if** the Node.js version you're using terminates the - * process on unhandled rejections. This option is only for - * *forward-compatibility purposes*. - * @see https://core.telegram.org/bots/api - */ - constructor(token, options = {}) { - super(); - this.token = token; - this.options = options; - this.options.polling = (typeof options.polling === 'undefined') ? false : options.polling; - this.options.webHook = (typeof options.webHook === 'undefined') ? false : options.webHook; - this.options.baseApiUrl = options.baseApiUrl || 'https://api.telegram.org'; - this.options.filepath = (typeof options.filepath === 'undefined') ? true : options.filepath; - this.options.badRejection = (typeof options.badRejection === 'undefined') ? false : options.badRejection; - this._textRegexpCallbacks = []; - this._replyListenerId = 0; - this._replyListeners = []; - this._polling = null; - this._webHook = null; - - if (options.polling) { - const autoStart = options.polling.autoStart; - if (typeof autoStart === 'undefined' || autoStart === true) { - this.startPolling(); - } - } - - if (options.webHook) { - const autoOpen = options.webHook.autoOpen; - if (typeof autoOpen === 'undefined' || autoOpen === true) { - this.openWebHook(); - } - } - } - - /** - * Generates url with bot token and provided path/method you want to be got/executed by bot - * @param {String} path - * @return {String} url - * @private - * @see https://core.telegram.org/bots/api#making-requests - */ - _buildURL(_path) { - return `${this.options.baseApiUrl}/bot${this.token}${this.options.testEnvironment ? '/test' : ''}/${_path}`; - } - - /** - * Fix 'reply_markup' parameter by making it JSON-serialized, as - * required by the Telegram Bot API - * @param {Object} obj Object; either 'form' or 'qs' - * @private - * @see https://core.telegram.org/bots/api#sendmessage - */ - _fixReplyMarkup(obj) { - const replyMarkup = obj.reply_markup; - if (replyMarkup && typeof replyMarkup !== 'string') { - obj.reply_markup = stringify(replyMarkup); - } - } - - /** - * Fix 'entities' or 'caption_entities' or 'explanation_entities' parameter by making it JSON-serialized, as - * required by the Telegram Bot API - * @param {Object} obj Object; - * @private - * @see https://core.telegram.org/bots/api#sendmessage - * @see https://core.telegram.org/bots/api#copymessage - * @see https://core.telegram.org/bots/api#sendpoll - */ - _fixEntitiesField(obj) { - const entities = obj.entities; - const captionEntities = obj.caption_entities; - const explanationEntities = obj.explanation_entities; - if (entities && typeof entities !== 'string') { - obj.entities = stringify(entities); - } - - if (captionEntities && typeof captionEntities !== 'string') { - obj.caption_entities = stringify(captionEntities); - } - - if (explanationEntities && typeof explanationEntities !== 'string') { - obj.explanation_entities = stringify(explanationEntities); - } - } - - _fixAddFileThumbnail(options, opts) { - if (options.thumb) { - deprecate('The "thumb" parameter was renamed to "thumbnail" in Telegram Bot API v6.6. Please use the renamed parameter instead.'); - options.thumbnail = options.thumb; - } - if (options.thumbnail) { - if (opts.formData === null) { - opts.formData = {}; - } - - const attachName = 'photo'; - const [formData] = this._formatSendData(attachName, options.thumbnail.replace('attach://', '')); - - if (formData) { - opts.formData[attachName] = formData[attachName]; - opts.qs.thumbnail = `attach://${attachName}`; - } - } - } - - _fixMessageIds(obj) { - const messageIds = obj.message_ids; - if (messageIds && typeof messageIds !== 'string') { - obj.message_ids = stringify(messageIds); - } - } - - /** - * Fix 'reply_parameters' parameter by making it JSON-serialized, as - * required by the Telegram Bot API - * @param {Object} obj Object; either 'form' or 'qs' - * @private - * @see https://core.telegram.org/bots/api#sendmessage - */ - _fixReplyParameters(obj) { - if (obj.hasOwnProperty('reply_parameters') && typeof obj.reply_parameters !== 'string') { - obj.reply_parameters = stringify(obj.reply_parameters); - } - } - - /** - * Make request against the API - * @param {String} _path API endpoint - * @param {Object} [options] - * @private - * @return {Promise} - */ - _request(_path, options = {}) { - if (!this.token) { - return Promise.reject(new errors.FatalError('Telegram Bot Token not provided!')); - } - - if (this.options.request) { - Object.assign(options, this.options.request); - } - - if (options.form) { - this._fixReplyMarkup(options.form); - this._fixEntitiesField(options.form); - this._fixReplyParameters(options.form); - this._fixMessageIds(options.form); - } - if (options.qs) { - this._fixReplyMarkup(options.qs); - this._fixReplyParameters(options.qs); - } - - options.method = 'POST'; - options.url = this._buildURL(_path); - options.simple = false; - options.resolveWithFullResponse = true; - options.forever = true; - debug('HTTP request: %j', options); - return request(options) - .then(resp => { - let data; - try { - data = resp.body = JSON.parse(resp.body); - } catch (err) { - throw new errors.ParseError(`Error parsing response: ${resp.body}`, resp); - } - - if (data.ok) { - return data.result; - } - - throw new errors.TelegramError(`${data.error_code} ${data.description}`, resp); - }).catch(error => { - // TODO: why can't we do `error instanceof errors.BaseError`? - if (error.response) throw error; - throw new errors.FatalError(error); - }); - } - - /** - * Format data to be uploaded; handles file paths, streams and buffers - * @param {String} type - * @param {String|stream.Stream|Buffer} data - * @param {Object} fileOptions File options - * @param {String} [fileOptions.filename] File name - * @param {String} [fileOptions.contentType] Content type (i.e. MIME) - * @return {Array} formatted - * @return {Object} formatted[0] formData - * @return {String} formatted[1] fileId - * @throws Error if Buffer file type is not supported. - * @see https://npmjs.com/package/file-type - * @private - */ - _formatSendData(type, data, fileOptions = {}) { - const deprecationMessage = - 'See https://github.com/yagop/node-telegram-bot-api/blob/master/doc/usage.md#sending-files' + - ' for more information on how sending files has been improved and' + - ' on how to disable this deprecation message altogether.'; - let filedata = data; - let filename = fileOptions.filename; - let contentType = fileOptions.contentType; - - if (data instanceof stream.Stream) { - if (!filename && data.path) { - // Will be 'null' if could not be parsed. - // For example, 'data.path' === '/?id=123' from 'request("https://example.com/?id=123")' - const url = URL.parse(path.basename(data.path.toString())); - if (url.pathname) { - filename = qs.unescape(url.pathname); - } - } - } else if (Buffer.isBuffer(data)) { - if (!filename && !process.env.NTBA_FIX_350) { - deprecate(`Buffers will have their filenames default to "filename" instead of "data". ${deprecationMessage}`); - filename = 'data'; - } - if (!contentType) { - const filetype = fileType(data); - if (filetype) { - contentType = filetype.mime; - const ext = filetype.ext; - if (ext && !process.env.NTBA_FIX_350) { - filename = `${filename}.${ext}`; - } - } else if (!process.env.NTBA_FIX_350) { - deprecate(`An error will no longer be thrown if file-type of buffer could not be detected. ${deprecationMessage}`); - throw new errors.FatalError('Unsupported Buffer file-type'); - } - } - } else if (data) { - if (this.options.filepath && fs.existsSync(data)) { - filedata = fs.createReadStream(data); - if (!filename) { - filename = path.basename(data); - } - } else { - return [null, data]; - } - } else { - return [null, data]; - } - - filename = filename || 'filename'; - contentType = contentType || mime.lookup(filename); - if (process.env.NTBA_FIX_350) { - contentType = contentType || 'application/octet-stream'; - } else { - deprecate(`In the future, content-type of files you send will default to "application/octet-stream". ${deprecationMessage}`); - } - - // TODO: Add missing file extension. - - return [{ - [type]: { - value: filedata, - options: { - filename, - contentType, - }, - }, - }, null]; - } - - - /** - * Format multiple files to be uploaded; handles file paths, streams, and buffers - * @param {String} type - * @param {Array} files Array of file data objects - * @param {Object} fileOptions File options - * @param {String} [fileOptions.filename] File name - * @param {String} [fileOptions.contentType] Content type (i.e. MIME) - * @return {Object} formatted - * @return {Object} formatted.formData Form data object with all files - * @return {Array} formatted.fileIds Array of fileIds for non-file data - * @throws Error if Buffer file type is not supported. - * @see https://npmjs.com/package/file-type - * @private - */ - _formatSendMultipleData(type, files, fileOptions = {}) { - const formData = {}; - const fileIds = {}; - - files.forEach((file, index) => { - let filedata = file.media || file.data || file[type]; - let filename = file.filename || fileOptions.filename; - let contentType = file.contentType || fileOptions.contentType; - - if (filedata instanceof stream.Stream) { - if (!filename && filedata.path) { - const url = URL.parse(path.basename(filedata.path.toString()), true); - if (url.pathname) { - filename = qs.unescape(url.pathname); - } - } - } else if (Buffer.isBuffer(filedata)) { - filename = `filename_${index}`; - - if (!contentType) { - const filetype = fileType(filedata); - - if (filetype) { - contentType = filetype.mime; - const ext = filetype.ext; - - if (ext) { - filename = `${filename}.${ext}`; - } - } else { - throw new errors.FatalError('Unsupported Buffer file-type'); - } - } - } else if (fs.existsSync(filedata)) { - filedata = fs.createReadStream(filedata); - - if (!filename) { - filename = path.basename(filedata.path); - } - } else { - fileIds[index] = filedata; - return; - } - - filename = filename || `filename_${index}`; - contentType = contentType || 'application/octet-stream'; - - formData[`${type}_${index}`] = { - value: filedata, - options: { - filename, - contentType, - }, - }; - }); - - return { formData, fileIds }; - } - /** - * Start polling. - * Rejects returned promise if a WebHook is being used by this instance. - * @param {Object} [options] - * @param {Boolean} [options.restart=true] Consecutive calls to this method causes polling to be restarted - * @return {Promise} - */ - startPolling(options = {}) { - if (this.hasOpenWebHook()) { - return Promise.reject(new errors.FatalError('Polling and WebHook are mutually exclusive')); - } - options.restart = typeof options.restart === 'undefined' ? true : options.restart; - if (!this._polling) { - this._polling = new TelegramBotPolling(this); - } - return this._polling.start(options); - } - - /** - * Alias of `TelegramBot#startPolling()`. This is **deprecated**. - * @param {Object} [options] - * @return {Promise} - * @deprecated - */ - initPolling() { - deprecate('TelegramBot#initPolling() is deprecated. Use TelegramBot#startPolling() instead.'); - return this.startPolling(); - } - - /** - * Stops polling after the last polling request resolves. - * Multiple invocations do nothing if polling is already stopped. - * Returning the promise of the last polling request is **deprecated**. - * @param {Object} [options] Options - * @param {Boolean} [options.cancel] Cancel current request - * @param {String} [options.reason] Reason for stopping polling - * @return {Promise} - */ - stopPolling(options) { - if (!this._polling) { - return Promise.resolve(); - } - return this._polling.stop(options); - } - - /** - * Get link for file. - * Use this method to get link for file for subsequent use. - * Attention: link will be valid for 1 hour. - * - * This method is a sugar extension of the (getFile)[#getfilefileid] method, - * which returns just path to file on remote server (you will have to manually build full uri after that). - * - * @param {String} fileId File identifier to get info about - * @param {Object} [options] Additional Telegram query options - * @return {Promise} Promise which will have *fileURI* in resolve callback - * @see https://core.telegram.org/bots/api#getfile - */ - getFileLink(fileId, form = {}) { - return this.getFile(fileId, form) - .then(resp => `${this.options.baseApiUrl}/file/bot${this.token}/${resp.file_path}`); - } - - /** - * Return a readable stream for file. - * - * `fileStream.path` is the specified file ID i.e. `fileId`. - * `fileStream` emits event `info` passing a single argument i.e. - * `info` with the interface `{ uri }` where `uri` is the URI of the - * file on Telegram servers. - * - * This method is a sugar extension of the [getFileLink](#TelegramBot+getFileLink) method, - * which returns the full URI to the file on remote server. - * - * @param {String} fileId File identifier to get info about - * @param {Object} [options] Additional Telegram query options - * @return {stream.Readable} fileStream - */ - getFileStream(fileId, form = {}) { - const fileStream = new stream.PassThrough(); - fileStream.path = fileId; - this.getFileLink(fileId, form) - .then((fileURI) => { - fileStream.emit('info', { - uri: fileURI, - }); - pump(streamedRequest(Object.assign({ uri: fileURI }, this.options.request)), fileStream); - }) - .catch((error) => { - fileStream.emit('error', error); - }); - return fileStream; - } - - /** - * Downloads file in the specified folder. - * - * This method is a sugar extension of the [getFileStream](#TelegramBot+getFileStream) method, - * which returns a readable file stream. - * - * @param {String} fileId File identifier to get info about - * @param {String} downloadDir Absolute path to the folder in which file will be saved - * @param {Object} [options] Additional Telegram query options - * @return {Promise} Promise, which will have *filePath* of downloaded file in resolve callback - */ - downloadFile(fileId, downloadDir, form = {}) { - let resolve; - let reject; - const promise = new Promise((a, b) => { - resolve = a; - reject = b; - }); - const fileStream = this.getFileStream(fileId, form); - fileStream.on('info', (info) => { - const fileName = info.uri.slice(info.uri.lastIndexOf('/') + 1); - // TODO: Ensure fileName doesn't contains slashes - const filePath = path.join(downloadDir, fileName); - pump(fileStream, fs.createWriteStream(filePath), (error) => { - if (error) { return reject(error); } - return resolve(filePath); - }); - }); - fileStream.on('error', (err) => { - reject(err); - }); - return promise; - } - - /** - * Register a RegExp to test against an incomming text message. - * @param {RegExp} regexpRexecuted with `exec`. - * @param {Function} callback Callback will be called with 2 parameters, - * the `msg` and the result of executing `regexp.exec` on message text. - */ - onText(regexp, callback) { - this._textRegexpCallbacks.push({ regexp, callback }); - } - - /** - * Remove a listener registered with `onText()`. - * @param {RegExp} regexp RegExp used previously in `onText()` - * @return {Object} deletedListener The removed reply listener if - * found. This object has `regexp` and `callback` - * properties. If not found, returns `null`. - */ - removeTextListener(regexp) { - const index = this._textRegexpCallbacks.findIndex((textListener) => { - return String(textListener.regexp) === String(regexp); - }); - if (index === -1) { - return null; - } - return this._textRegexpCallbacks.splice(index, 1)[0]; - } - - /** - * Remove all listeners registered with `onText()`. - */ - clearTextListeners() { - this._textRegexpCallbacks = []; - } - - /** - * Register a reply to wait for a message response. - * - * @param {Number|String} chatId The chat id where the message cames from. - * @param {Number|String} messageId The message id to be replied. - * @param {Function} callback Callback will be called with the reply - * message. - * @return {Number} id The ID of the inserted reply listener. - */ - onReplyToMessage(chatId, messageId, callback) { - const id = ++this._replyListenerId; - this._replyListeners.push({ - id, - chatId, - messageId, - callback - }); - return id; - } - - /** - * Removes a reply that has been prev. registered for a message response. - * @param {Number} replyListenerId The ID of the reply listener. - * @return {Object} deletedListener The removed reply listener if - * found. This object has `id`, `chatId`, `messageId` and `callback` - * properties. If not found, returns `null`. - */ - removeReplyListener(replyListenerId) { - const index = this._replyListeners.findIndex((replyListener) => { - return replyListener.id === replyListenerId; - }); - if (index === -1) { - return null; - } - return this._replyListeners.splice(index, 1)[0]; - } - - /** - * Removes all replies that have been prev. registered for a message response. - * - * @return {Array} deletedListeners An array of removed listeners. - */ - clearReplyListeners() { - this._replyListeners = []; - } - - /** - * Return true if polling. Otherwise, false. - * - * @return {Boolean} - */ - isPolling() { - return this._polling ? this._polling.isPolling() : false; - } - - /** - * Open webhook. - * Multiple invocations do nothing if webhook is already open. - * Rejects returned promise if Polling is being used by this instance. - * - * @return {Promise} - */ - openWebHook() { - if (this.isPolling()) { - return Promise.reject(new errors.FatalError('WebHook and Polling are mutually exclusive')); - } - if (!this._webHook) { - this._webHook = new TelegramBotWebHook(this); - } - return this._webHook.open(); - } - - /** - * Close webhook after closing all current connections. - * Multiple invocations do nothing if webhook is already closed. - * - * @return {Promise} Promise - */ - closeWebHook() { - if (!this._webHook) { - return Promise.resolve(); - } - return this._webHook.close(); - } - - /** - * Return true if using webhook and it is open i.e. accepts connections. - * Otherwise, false. - * - * @return {Boolean} - */ - hasOpenWebHook() { - return this._webHook ? this._webHook.isOpen() : false; - } - - - /** - * Process an update; emitting the proper events and executing regexp - * callbacks. This method is useful should you be using a different - * way to fetch updates, other than those provided by TelegramBot. - * - * @param {Object} update - * @see https://core.telegram.org/bots/api#update - */ - processUpdate(update) { - debug('Process Update %j', update); - const message = update.message; - const editedMessage = update.edited_message; - const channelPost = update.channel_post; - const editedChannelPost = update.edited_channel_post; - const businessConnection = update.business_connection; - const businessMessage = update.business_message; - const editedBusinessMessage = update.edited_business_message; - const deletedBusinessMessage = update.deleted_business_messages; - const messageReaction = update.message_reaction; - const messageReactionCount = update.message_reaction_count; - const inlineQuery = update.inline_query; - const chosenInlineResult = update.chosen_inline_result; - const callbackQuery = update.callback_query; - const shippingQuery = update.shipping_query; - const preCheckoutQuery = update.pre_checkout_query; - const purchasedPaidMedia = update.purchased_paid_media; - const poll = update.poll; - const pollAnswer = update.poll_answer; - const myChatMember = update.my_chat_member; - const chatMember = update.chat_member; - const chatJoinRequest = update.chat_join_request; - const chatBoost = update.chat_boost; - const removedChatBoost = update.removed_chat_boost; - const managedBot = update.managed_bot; - - - if (message) { - debug('Process Update message %j', message); - const metadata = {}; - metadata.type = TelegramBot.messageTypes.find((messageType) => { - return message[messageType]; - }); - this.emit('message', message, metadata); - if (metadata.type) { - debug('Emitting %s: %j', metadata.type, message); - this.emit(metadata.type, message, metadata); - } - if (message.text) { - debug('Text message'); - this._textRegexpCallbacks.some(reg => { - debug('Matching %s with %s', message.text, reg.regexp); - - if (!(reg.regexp instanceof RegExp)) { - reg.regexp = new RegExp(reg.regexp); - } - - const result = reg.regexp.exec(message.text); - if (!result) { - return false; - } - // reset index so we start at the beginning of the regex each time - reg.regexp.lastIndex = 0; - debug('Matches %s', reg.regexp); - reg.callback(message, result); - // returning truthy value exits .some - return this.options.onlyFirstMatch; - }); - } - if (message.reply_to_message) { - // Only callbacks waiting for this message - this._replyListeners.forEach(reply => { - // Message from the same chat - if (reply.chatId === message.chat.id) { - // Responding to that message - if (reply.messageId === message.reply_to_message.message_id) { - // Resolve the promise - reply.callback(message); - } - } - }); - } - } else if (editedMessage) { - debug('Process Update edited_message %j', editedMessage); - this.emit('edited_message', editedMessage); - if (editedMessage.text) { - this.emit('edited_message_text', editedMessage); - } - if (editedMessage.caption) { - this.emit('edited_message_caption', editedMessage); - } - } else if (channelPost) { - debug('Process Update channel_post %j', channelPost); - this.emit('channel_post', channelPost); - } else if (editedChannelPost) { - debug('Process Update edited_channel_post %j', editedChannelPost); - this.emit('edited_channel_post', editedChannelPost); - if (editedChannelPost.text) { - this.emit('edited_channel_post_text', editedChannelPost); - } - if (editedChannelPost.caption) { - this.emit('edited_channel_post_caption', editedChannelPost); - } - } else if (businessConnection) { - debug('Process Update business_connection %j', businessConnection); - this.emit('business_connection', businessConnection); - } else if (businessMessage) { - debug('Process Update business_message %j', businessMessage); - this.emit('business_message', businessMessage); - } else if (editedBusinessMessage) { - debug('Process Update edited_business_message %j', editedBusinessMessage); - this.emit('edited_business_message', editedBusinessMessage); - } else if (deletedBusinessMessage) { - debug('Process Update deleted_business_messages %j', deletedBusinessMessage); - this.emit('deleted_business_messages', deletedBusinessMessage); - } else if (messageReaction) { - debug('Process Update message_reaction %j', messageReaction); - this.emit('message_reaction', messageReaction); - } else if (messageReactionCount) { - debug('Process Update message_reaction_count %j', messageReactionCount); - this.emit('message_reaction_count', messageReactionCount); - } else if (inlineQuery) { - debug('Process Update inline_query %j', inlineQuery); - this.emit('inline_query', inlineQuery); - } else if (chosenInlineResult) { - debug('Process Update chosen_inline_result %j', chosenInlineResult); - this.emit('chosen_inline_result', chosenInlineResult); - } else if (callbackQuery) { - debug('Process Update callback_query %j', callbackQuery); - this.emit('callback_query', callbackQuery); - } else if (shippingQuery) { - debug('Process Update shipping_query %j', shippingQuery); - this.emit('shipping_query', shippingQuery); - } else if (preCheckoutQuery) { - debug('Process Update pre_checkout_query %j', preCheckoutQuery); - this.emit('pre_checkout_query', preCheckoutQuery); - } else if (purchasedPaidMedia) { - debug('Process Update purchased_paid_media %j', purchasedPaidMedia); - this.emit('purchased_paid_media', purchasedPaidMedia); - } else if (poll) { - debug('Process Update poll %j', poll); - this.emit('poll', poll); - } else if (pollAnswer) { - debug('Process Update poll_answer %j', pollAnswer); - this.emit('poll_answer', pollAnswer); - } else if (chatMember) { - debug('Process Update chat_member %j', chatMember); - this.emit('chat_member', chatMember); - } else if (myChatMember) { - debug('Process Update my_chat_member %j', myChatMember); - this.emit('my_chat_member', myChatMember); - } else if (chatJoinRequest) { - debug('Process Update my_chat_member %j', chatJoinRequest); - this.emit('chat_join_request', chatJoinRequest); - } else if (chatBoost) { - debug('Process Update chat_boost %j', chatBoost); - this.emit('chat_boost', chatBoost); - } else if (removedChatBoost) { - debug('Process Update removed_chat_boost %j', removedChatBoost); - this.emit('removed_chat_boost', removedChatBoost); - } else if (managedBot) { - debug('Process Update managed_bot %j', managedBot); - this.emit('managed_bot', managedBot); - } - } - - /** Start Telegram Bot API methods */ - - /** - * Use this method to receive incoming updates using long polling. - * This method has an [older, compatible signature][getUpdates-v0.25.0] - * that is being deprecated. - * - * @param {Object} [options] Additional Telegram query options - * @return {Promise} - * @see https://core.telegram.org/bots/api#getupdates - */ - getUpdates(form = {}) { - /* The older method signature was getUpdates(timeout, limit, offset). - * We need to ensure backwards-compatibility while maintaining - * consistency of the method signatures throughout the library */ - if (typeof form !== 'object') { - /* eslint-disable no-param-reassign, prefer-rest-params */ - deprecate('The method signature getUpdates(timeout, limit, offset) has been deprecated since v0.25.0'); - form = { - timeout: arguments[0], - limit: arguments[1], - offset: arguments[2], - }; - /* eslint-enable no-param-reassign, prefer-rest-params */ - } - - // If allowed_updates is present and is an array, stringify it. - // If it's already a string (e.g., user did JSON.stringify), leave as is. - if (form.allowed_updates) { - form.allowed_updates = stringify(form.allowed_updates); - } - - return this._request('getUpdates', { form }); - } - - /** - * Specify an url to receive incoming updates via an outgoing webHook. - * This method has an [older, compatible signature][setWebHook-v0.25.0] - * that is being deprecated. - * - * @param {String} url URL where Telegram will make HTTP Post. Leave empty to - * delete webHook. - * @param {Object} [options] Additional Telegram query options - * @param {String|stream.Stream} [options.certificate] PEM certificate key (public). - * @param {String} [options.secret_token] Optional secret token to be sent in a header `X-Telegram-Bot-Api-Secret-Token` in every webhook request. - * @param {Object} [fileOptions] Optional file related meta-data - * @return {Promise} - * @see https://core.telegram.org/bots/api#setwebhook - * @see https://github.com/yagop/node-telegram-bot-api/blob/master/doc/usage.md#sending-files - */ - setWebHook(url, options = {}, fileOptions = {}) { - /* The older method signature was setWebHook(url, cert). - * We need to ensure backwards-compatibility while maintaining - * consistency of the method signatures throughout the library */ - let cert; - // Note: 'options' could be an object, if a stream was provided (in place of 'cert') - if (typeof options !== 'object' || options instanceof stream.Stream) { - deprecate('The method signature setWebHook(url, cert) has been deprecated since v0.25.0'); - cert = options; - options = {}; // eslint-disable-line no-param-reassign - } else { - cert = options.certificate; - } - - const opts = { - qs: options, - }; - opts.qs.url = url; - - if (cert) { - try { - const sendData = this._formatSendData('certificate', cert, fileOptions); - opts.formData = sendData[0]; - opts.qs.certificate = sendData[1]; - } catch (ex) { - return Promise.reject(ex); - } - } - - return this._request('setWebHook', opts); - } - - /** - * Use this method to remove webhook integration if you decide to - * switch back to getUpdates. Returns True on success. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} - * @see https://core.telegram.org/bots/api#deletewebhook - */ - deleteWebHook(form = {}) { - return this._request('deleteWebhook', { form }); - } - - /** - * Use this method to get current webhook status. - * On success, returns a [WebhookInfo](https://core.telegram.org/bots/api#webhookinfo) object. - * If the bot is using getUpdates, will return an object with the - * url field empty. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} - * @see https://core.telegram.org/bots/api#getwebhookinfo - */ - getWebHookInfo(form = {}) { - return this._request('getWebhookInfo', { form }); - } - - /** - * A simple method for testing your bot's authentication token. Requires no parameters. - * - * @param {Object} [options] Additional Telegram query options - * @return {Promise} basic information about the bot in form of a [User](https://core.telegram.org/bots/api#user) object. - * @see https://core.telegram.org/bots/api#getme - */ - getMe(form = {}) { - return this._request('getMe', { form }); - } - - /** - * This method log out your bot from the cloud Bot API server before launching the bot locally. - * You must log out the bot before running it locally, otherwise there is no guarantee that the bot will receive updates. - * After a successful call, you will not be able to log in again using the same token for 10 minutes. - * - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#logout - */ - logOut(form = {}) { - return this._request('logOut', { form }); - } - - /** - * This method close the bot instance before moving it from one local server to another. - * This method will return error 429 in the first 10 minutes after the bot is launched. - * - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#close - */ - close(form = {}) { - return this._request('close', { form }); - } - - /** - * Send text message. - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {String} text Text of the message to be sent - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, the sent [Message](https://core.telegram.org/bots/api#message) object is returned - * @see https://core.telegram.org/bots/api#sendmessage - */ - sendMessage(chatId, text, form = {}) { - form.chat_id = chatId; - form.text = text; - return this._request('sendMessage', { form }); - } - - /** - * Forward messages of any kind. - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * or username of the target channel (in the format `@channelusername`) - * @param {Number|String} fromChatId Unique identifier for the chat where the - * original message was sent (or channel username in the format `@channelusername`) - * @param {Number|String} messageId Unique message identifier in the chat specified in fromChatId - * @param {Object} [options] Additional Telegram query options - * @return {Promise} - * @see https://core.telegram.org/bots/api#forwardmessage - */ - forwardMessage(chatId, fromChatId, messageId, form = {}) { - form.chat_id = chatId; - form.from_chat_id = fromChatId; - form.message_id = messageId; - return this._request('forwardMessage', { form }); - } - - /** - * Use this method to forward multiple messages of any kind. - * If some of the specified messages can't be found or forwarded, they are skipped. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * or username of the target channel (in the format `@channelusername`) - * @param {Number|String} fromChatId Unique identifier for the chat where the - * original message was sent (or channel username in the format `@channelusername`) - * @param {Array} messageIds Identifiers of 1-100 messages in the chat from_chat_id to forward. - * The identifiers must be specified in a strictly increasing order. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} An array of MessageId of the sent messages on success - * @see https://core.telegram.org/bots/api#forwardmessages - */ - forwardMessages(chatId, fromChatId, messageIds, form = {}) { - form.chat_id = chatId; - form.from_chat_id = fromChatId; - form.message_ids = messageIds; - return this._request('forwardMessages', { form }); - } - - /** - * Copy messages of any kind. **Service messages and invoice messages can't be copied.** - * The method is analogous to the method forwardMessages, but the copied message doesn't - * have a link to the original message. - * Returns the MessageId of the sent message on success. - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Number|String} fromChatId Unique identifier for the chat where the - * original message was sent - * @param {Number|String} messageId Unique message identifier - * @param {Object} [options] Additional Telegram query options - * @return {Promise} The [MessageId](https://core.telegram.org/bots/api#messageid) of the sent message on success - * @see https://core.telegram.org/bots/api#copymessage - */ - copyMessage(chatId, fromChatId, messageId, form = {}) { - form.chat_id = chatId; - form.from_chat_id = fromChatId; - form.message_id = messageId; - return this._request('copyMessage', { form }); - } - - /** - * Use this method to copy messages of any kind. If some of the specified messages can't be found or copied, they are skipped. - * Service messages, giveaway messages, giveaway winners messages, and invoice messages can't be copied. - * Returns the MessageId of the sent message on success. - * @param {Number|String} chatId Unique identifier for the target chat - * @param {Number|String} fromChatId Unique identifier for the chat where the - * original message was sent - * @param {Array} messageIds Identifiers of 1-100 messages in the chat from_chat_id to copy. - * The identifiers must be specified in a strictly increasing order. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} An array of MessageId of the sent messages - * @see https://core.telegram.org/bots/api#copymessages - */ - copyMessages(chatId, fromChatId, messageIds, form = {}) { - form.chat_id = chatId; - form.from_chat_id = fromChatId; - form.message_ids = stringify(messageIds); - return this._request('copyMessages', { form }); - } - - /** - * Send photo - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {String|stream.Stream|Buffer} photo A file path or a Stream. Can - * also be a `file_id` previously uploaded - * @param {Object} [options] Additional Telegram query options - * @param {Object} [fileOptions] Optional file related meta-data - * @return {Promise} On success, the sent [Message](https://core.telegram.org/bots/api#message) object is returned - * @see https://core.telegram.org/bots/api#sendphoto - * @see https://github.com/yagop/node-telegram-bot-api/blob/master/doc/usage.md#sending-files - */ - sendPhoto(chatId, photo, options = {}, fileOptions = {}) { - const opts = { - qs: options, - }; - opts.qs.chat_id = chatId; - try { - const sendData = this._formatSendData('photo', photo, fileOptions); - opts.formData = sendData[0]; - opts.qs.photo = sendData[1]; - } catch (ex) { - return Promise.reject(ex); - } - return this._request('sendPhoto', opts); - } - - /** - * Send audio - * - * **Your audio must be in the .MP3 or .M4A format.** - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {String|stream.Stream|Buffer} audio A file path, Stream or Buffer. - * Can also be a `file_id` previously uploaded. - * @param {Object} [options] Additional Telegram query options - * @param {Object} [fileOptions] Optional file related meta-data - * @return {Promise} On success, the sent [Message](https://core.telegram.org/bots/api#message) object is returned - * @see https://core.telegram.org/bots/api#sendaudio - * @see https://github.com/yagop/node-telegram-bot-api/blob/master/doc/usage.md#sending-files - */ - sendAudio(chatId, audio, options = {}, fileOptions = {}) { - const opts = { - qs: options - }; - - opts.qs.chat_id = chatId; - - try { - const sendData = this._formatSendData('audio', audio, fileOptions); - opts.formData = sendData[0]; - opts.qs.audio = sendData[1]; - this._fixAddFileThumbnail(options, opts); - } catch (ex) { - return Promise.reject(ex); - } - - return this._request('sendAudio', opts); - } - - /** - * Send Document - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {String|stream.Stream|Buffer} doc A file path, Stream or Buffer. - * Can also be a `file_id` previously uploaded. - * @param {Object} [options] Additional Telegram query options - * @param {Object} [fileOptions] Optional file related meta-data - * @return {Promise} On success, the sent [Message](https://core.telegram.org/bots/api#message) object is returned - * @see https://core.telegram.org/bots/api#sendDocument - * @see https://github.com/yagop/node-telegram-bot-api/blob/master/doc/usage.md#sending-files - */ - sendDocument(chatId, doc, options = {}, fileOptions = {}) { - const opts = { - qs: options - }; - opts.qs.chat_id = chatId; - try { - const sendData = this._formatSendData('document', doc, fileOptions); - opts.formData = sendData[0]; - opts.qs.document = sendData[1]; - this._fixAddFileThumbnail(options, opts); - } catch (ex) { - return Promise.reject(ex); - } - - return this._request('sendDocument', opts); - } - - /** - * Use this method to send video files, **Telegram clients support mp4 videos** (other formats may be sent as Document). - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {String|stream.Stream|Buffer} video A file path or Stream. - * Can also be a `file_id` previously uploaded. - * @param {Object} [options] Additional Telegram query options - * @param {Object} [fileOptions] Optional file related meta-data - * @return {Promise} On success, the sent [Message](https://core.telegram.org/bots/api#message) object is returned - * @see https://core.telegram.org/bots/api#sendvideo - * @see https://github.com/yagop/node-telegram-bot-api/blob/master/doc/usage.md#sending-files - */ - sendVideo(chatId, video, options = {}, fileOptions = {}) { - const opts = { - qs: options - }; - opts.qs.chat_id = chatId; - try { - const sendData = this._formatSendData('video', video, fileOptions); - opts.formData = sendData[0]; - opts.qs.video = sendData[1]; - this._fixAddFileThumbnail(options, opts); - } catch (ex) { - return Promise.reject(ex); - } - return this._request('sendVideo', opts); - } - - /** - * Use this method to send animation files (GIF or H.264/MPEG-4 AVC video without sound). - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {String|stream.Stream|Buffer} animation A file path, Stream or Buffer. - * Can also be a `file_id` previously uploaded. - * @param {Object} [options] Additional Telegram query options - * @param {Object} [fileOptions] Optional file related meta-data - * @return {Promise} On success, the sent [Message](https://core.telegram.org/bots/api#message) object is returned - * @see https://core.telegram.org/bots/api#sendanimation - * @see https://github.com/yagop/node-telegram-bot-api/blob/master/doc/usage.md#sending-files - */ - sendAnimation(chatId, animation, options = {}, fileOptions = {}) { - const opts = { - qs: options - }; - opts.qs.chat_id = chatId; - try { - const sendData = this._formatSendData('animation', animation, fileOptions); - opts.formData = sendData[0]; - opts.qs.animation = sendData[1]; - } catch (ex) { - return Promise.reject(ex); - } - return this._request('sendAnimation', opts); - } - - /** - * Send voice - * - * **Your audio must be in an .OGG file encoded with OPUS**, or in .MP3 format, or in .M4A format (other formats may be sent as Audio or Document) - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {String|stream.Stream|Buffer} voice A file path, Stream or Buffer. - * Can also be a `file_id` previously uploaded. - * @param {Object} [options] Additional Telegram query options - * @param {Object} [fileOptions] Optional file related meta-data - * @return {Promise} On success, the sent [Message](https://core.telegram.org/bots/api#message) object is returned - * @see https://core.telegram.org/bots/api#sendvoice - * @see https://github.com/yagop/node-telegram-bot-api/blob/master/doc/usage.md#sending-files - */ - sendVoice(chatId, voice, options = {}, fileOptions = {}) { - const opts = { - qs: options - }; - opts.qs.chat_id = chatId; - try { - const sendData = this._formatSendData('voice', voice, fileOptions); - opts.formData = sendData[0]; - opts.qs.voice = sendData[1]; - } catch (ex) { - return Promise.reject(ex); - } - return this._request('sendVoice', opts); - } - - /** - * Use this method to send video messages - * Telegram clients support **rounded square MPEG4 videos** of up to 1 minute long. - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {String|stream.Stream|Buffer} videoNote A file path or Stream. - * Can also be a `file_id` previously uploaded. - * @param {Object} [options] Additional Telegram query options - * @param {Object} [fileOptions] Optional file related meta-data - * @return {Promise} On success, the sent [Message](https://core.telegram.org/bots/api#message) object is returned - * @info The length parameter is actually optional. However, the API (at time of writing) requires you to always provide it until it is fixed. - * @see https://core.telegram.org/bots/api#sendvideonote - * @see https://github.com/yagop/node-telegram-bot-api/blob/master/doc/usage.md#sending-files - */ - sendVideoNote(chatId, videoNote, options = {}, fileOptions = {}) { - const opts = { - qs: options - }; - opts.qs.chat_id = chatId; - try { - const sendData = this._formatSendData('video_note', videoNote, fileOptions); - opts.formData = sendData[0]; - opts.qs.video_note = sendData[1]; - this._fixAddFileThumbnail(options, opts); - } catch (ex) { - return Promise.reject(ex); - } - return this._request('sendVideoNote', opts); - } - - /** - * Use this method to send paid media. - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Number} starCount The number of Telegram Stars that must be paid to buy access to the media; 1-10000 - * @param {Array} media Array of [InputPaidMedia](https://core.telegram.org/bots/api#inputpaidmedia). The media property can bea String, Stream or Buffer. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, the sent [Message](https://core.telegram.org/bots/api#message) object is returned - * @see https://core.telegram.org/bots/api#sendpaidmedia - */ - sendPaidMedia(chatId, starCount, media, options = {}) { - const opts = { - qs: options - }; - - opts.qs.chat_id = chatId; - opts.qs.star_count = starCount; - - try { - const inputPaidMedia = []; - opts.formData = {}; - - const { formData, fileIds } = this._formatSendMultipleData('media', media); - - opts.formData = formData; - - inputPaidMedia.push(...media.map((item, index) => { - if (fileIds[index]) { - item.media = fileIds[index]; - } else { - item.media = `attach://media_${index}`; - } - return item; - })); - - opts.qs.media = stringify(inputPaidMedia); - } catch (ex) { - return Promise.reject(ex); - } - - return this._request('sendPaidMedia', opts); - } - - /** - * Use this method to send a group of photos or videos as an album. - * - * **Documents and audio files can be only grouped in an album with messages of the same type** - * - * If you wish to [specify file options](https://github.com/yagop/node-telegram-bot-api/blob/master/doc/usage.md#sending-files), - * add a `fileOptions` property to the target input in `media`. - * - * @param {String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Array} media A JSON-serialized array describing photos and videos to be sent, must include 2–10 items - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, an array of the sent [Messages](https://core.telegram.org/bots/api#message) - * is returned. - * @see https://core.telegram.org/bots/api#sendmediagroup - * @see https://github.com/yagop/node-telegram-bot-api/blob/master/doc/usage.md#sending-files - */ - sendMediaGroup(chatId, media, options = {}) { - const opts = { - qs: options, - }; - opts.qs.chat_id = chatId; - - opts.formData = {}; - const inputMedia = []; - let index = 0; - for (const input of media) { - const payload = Object.assign({}, input); - delete payload.media; - delete payload.fileOptions; - try { - const attachName = String(index); - const [formData, fileId] = this._formatSendData(attachName, input.media, input.fileOptions); - if (formData) { - opts.formData[attachName] = formData[attachName]; - payload.media = `attach://${attachName}`; - } else { - payload.media = fileId; - } - } catch (ex) { - return Promise.reject(ex); - } - inputMedia.push(payload); - index++; - } - opts.qs.media = stringify(inputMedia); - - return this._request('sendMediaGroup', opts); - } - - - /** - * Send location. - * Use this method to send point on the map. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Float} latitude Latitude of location - * @param {Float} longitude Longitude of location - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, the sent [Message](https://core.telegram.org/bots/api#message) object is returned - * @see https://core.telegram.org/bots/api#sendlocation - */ - sendLocation(chatId, latitude, longitude, form = {}) { - form.chat_id = chatId; - form.latitude = latitude; - form.longitude = longitude; - return this._request('sendLocation', { form }); - } - - /** - * Use this method to edit live location messages sent by - * the bot or via the bot (for inline bots). - * - * A location **can be edited until its live_period expires or editing is explicitly disabled by a call to [stopMessageLiveLocation](https://core.telegram.org/bots/api#stopmessagelivelocation)** - * - * Note that you must provide one of chat_id, message_id, or - * inline_message_id in your request. - * - * @param {Float} latitude Latitude of location - * @param {Float} longitude Longitude of location - * @param {Object} [options] Additional Telegram query options (provide either one of chat_id, message_id, or inline_message_id here) - * @return {Promise} On success, if the edited message is not an inline message, the edited [Message](https://core.telegram.org/bots/api#message) is returned, otherwise True is returned. - * @see https://core.telegram.org/bots/api#editmessagelivelocation - */ - editMessageLiveLocation(latitude, longitude, form = {}) { - form.latitude = latitude; - form.longitude = longitude; - return this._request('editMessageLiveLocation', { form }); - } - - /** - * Use this method to stop updating a live location message sent by - * the bot or via the bot (for inline bots) before live_period expires. - * - * Note that you must provide one of chat_id, message_id, or - * inline_message_id in your request. - * - * @param {Object} [options] Additional Telegram query options (provide either one of chat_id, message_id, or inline_message_id here) - * @return {Promise} On success, if the edited message is not an inline message, the edited [Message](https://core.telegram.org/bots/api#message) is returned, otherwise True is returned. - * @see https://core.telegram.org/bots/api#stopmessagelivelocation - */ - stopMessageLiveLocation(form = {}) { - return this._request('stopMessageLiveLocation', { form }); - } - - /** - * Send venue. - * Use this method to send information about a venue. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Float} latitude Latitude of location - * @param {Float} longitude Longitude of location - * @param {String} title Name of the venue - * @param {String} address Address of the venue - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, the sent [Message](https://core.telegram.org/bots/api#message) object is returned. - * @see https://core.telegram.org/bots/api#sendvenue - */ - sendVenue(chatId, latitude, longitude, title, address, form = {}) { - form.chat_id = chatId; - form.latitude = latitude; - form.longitude = longitude; - form.title = title; - form.address = address; - return this._request('sendVenue', { form }); - } - - /** - * Send contact. - * Use this method to send phone contacts. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {String} phoneNumber Contact's phone number - * @param {String} firstName Contact's first name - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, the sent [Message](https://core.telegram.org/bots/api#message) object is returned - * @see https://core.telegram.org/bots/api#sendcontact - */ - sendContact(chatId, phoneNumber, firstName, form = {}) { - form.chat_id = chatId; - form.phone_number = phoneNumber; - form.first_name = firstName; - return this._request('sendContact', { form }); - } - - /** - * Send poll. - * Use this method to send a native poll. - * - * @param {Number|String} chatId Unique identifier for the group/channel - * @param {String} question Poll question, 1-300 characters - * @param {Array} pollOptions Poll options, between 2-10 options (only 1-100 characters each) - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, the sent [Message](https://core.telegram.org/bots/api#message) object is returned - * @see https://core.telegram.org/bots/api#sendpoll - */ - sendPoll(chatId, question, pollOptions, form = {}) { - form.chat_id = chatId; - form.question = question; - form.options = stringify(pollOptions); - return this._request('sendPoll', { form }); - } - - /** - * Send sendChecklist. - * Use this method to send a checklist on behalf of a connected business account. - * - * @param {Number|String} businessConnectionId Unique identifier for the business connection - * @param {Number|String} chatId Unique identifier for the group/channel - * @param {Object} checklist A JSON-serialized object for the checklist to send - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, the sent [Message](https://core.telegram.org/bots/api#message) object is returned - * @see https://core.telegram.org/bots/api#sendchecklist - */ - sendChecklist(businessConnectionId, chatId, checklist, form = {}) { - form.business_connection_id = businessConnectionId; - form.chat_id = chatId; - form.checklist = stringify(checklist); - return this._request('sendChecklist', { form }); - } - - /** - * Send Dice - * Use this method to send an animated emoji that will display a random value. - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, the sent [Message](https://core.telegram.org/bots/api#message) object is returned - * @see https://core.telegram.org/bots/api#senddice - */ - sendDice(chatId, options = {}) { - const opts = { - qs: options, - }; - opts.qs.chat_id = chatId; - try { - const sendData = this._formatSendData('dice'); - opts.formData = sendData[0]; - } catch (ex) { - return Promise.reject(ex); - } - return this._request('sendDice', opts); - } - - /** - * Send Message Draft - * Use this method to stream a partial message to a user while the message is being generated; supported only for bots with forum topic mode enabled. Returns True on success. - * @param {Number|String} chatId Unique identifier for the target private chat - * @param {Number} draftId Unique identifier of the message draft; must be non-zero. Changes of drafts with the same identifier are animated - * @param {String} text Text of the message to be sent, 1-4096 characters after entities parsing - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, return true - * @see https://core.telegram.org/bots/api#sendmessagedraft - */ - sendMessageDraft(chatId, draftId, text, form = {}) { - form.chat_id = chatId; - form.draft_id = draftId; - form.text = text; - return this._request('sendMessageDraft', { form }); - } - - /** - * Send chat action. - * - * Use this method when you need to tell the user that something is happening on the bot's side. - * **The status is set for 5 seconds or less** (when a message arrives from your bot, Telegram clients clear its typing status). - * - * Action `typing` for [text messages](https://core.telegram.org/bots/api#sendmessage), - * `upload_photo` for [photos](https://core.telegram.org/bots/api#sendphoto), `record_video` or `upload_video` for [videos](https://core.telegram.org/bots/api#sendvideo), - * `record_voice` or `upload_voice` for [voice notes](https://core.telegram.org/bots/api#sendvoice), `upload_document` for [general files](https://core.telegram.org/bots/api#senddocument), - * `choose_sticker` for [stickers](https://core.telegram.org/bots/api#sendsticker), `find_location` for [location data](https://core.telegram.org/bots/api#sendlocation), - * `record_video_note` or `upload_video_note` for [video notes](https://core.telegram.org/bots/api#sendvideonote). - * - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {String} action Type of action to broadcast. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#sendchataction - */ - sendChatAction(chatId, action, form = {}) { - form.chat_id = chatId; - form.action = action; - return this._request('sendChatAction', { form }); - } - - /** - * Use this method to change the chosen reactions on a message. - * - Service messages can't be reacted to. - * - Automatically forwarded messages from a channel to its discussion group have the same available reactions as messages in the channel. - * - In albums, bots must react to the first message. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format @channelusername) - * @param {Number} messageId Unique identifier of the target message - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#setmessagereaction - */ - setMessageReaction(chatId, messageId, form = {}) { - form.chat_id = chatId; - form.message_id = messageId; - if (form.reaction) { - form.reaction = stringify(form.reaction); - } - return this._request('setMessageReaction', { form }); - } - - /** - * Use this method to get a list of profile pictures for a user. - * Returns a [UserProfilePhotos](https://core.telegram.org/bots/api#userprofilephotos) object. - * - * @param {Number} userId Unique identifier of the target user - * @param {Object} [options] Additional Telegram query options - * @return {Promise} Returns a [UserProfilePhotos](https://core.telegram.org/bots/api#userprofilephotos) object - * @see https://core.telegram.org/bots/api#getuserprofilephotos - */ - getUserProfilePhotos(userId, form = {}) { - form.user_id = userId; - return this._request('getUserProfilePhotos', { form }); - } - - /** - * Use this method to get a list of profile audios for a user. - * Returns a [UserProfileAudios](https://core.telegram.org/bots/api#userprofileaudios) object. - * - * @param {Number} userId Unique identifier of the target user - * @param {Object} [options] Additional Telegram query options - * @return {Promise} Returns a [UserProfileAudios](https://core.telegram.org/bots/api#userprofileaudios) object - * @see https://core.telegram.org/bots/api#getuserprofileaudios - */ - getUserProfileAudios(userId, form = {}) { - form.user_id = userId; - return this._request('getUserProfileAudios', { form }); - } - - - /** - * Changes the emoji status for a given user that previously allowed the bot to manage their emoji status - * via the Mini App method [requestEmojiStatusAccess](https://core.telegram.org/bots/webapps#initializing-mini-apps). - * - * @param {Number} userId Unique identifier of the target user - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#setuseremojistatus - */ - setUserEmojiStatus(userId, form = {}) { - form.user_id = userId; - return this._request('setUserEmojiStatus', { form }); - } - - /** - * Get file. - * Use this method to get basic info about a file and prepare it for downloading. - * - * Attention: **link will be valid for 1 hour.** - * - * @param {String} fileId File identifier to get info about - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, a [File](https://core.telegram.org/bots/api#file) object is returned - * @see https://core.telegram.org/bots/api#getfile - */ - getFile(fileId, form = {}) { - form.file_id = fileId; - return this._request('getFile', { form }); - } - - /** - * Use this method to ban a user in a group, a supergroup or a channel. - * In the case of supergroups and channels, the user will not be able to - * return to the chat on their own using invite links, etc., unless unbanned first.. - * - * The **bot must be an administrator in the group, supergroup or a channel** for this to work. - * - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Number} userId Unique identifier of the target user - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success. - * @see https://core.telegram.org/bots/api#banchatmember - */ - banChatMember(chatId, userId, form = {}) { - form.chat_id = chatId; - form.user_id = userId; - return this._request('banChatMember', { form }); - } - - /** - * Use this method to unban a previously kicked user in a supergroup. - * The user will not return to the group automatically, but will be - * able to join via link, etc. - * - * The **bot must be an administrator** in the supergroup or channel for this to work. - * - * **By default**, this method guarantees that after the call the user is not a member of the chat, but will be able to join it. - * So **if the user is a member of the chat they will also be removed from the chat**. If you don't want this, use the parameter *only_if_banned* - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Number} userId Unique identifier of the target user - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#unbanchatmember - */ - unbanChatMember(chatId, userId, form = {}) { - form.chat_id = chatId; - form.user_id = userId; - return this._request('unbanChatMember', { form }); - } - - /** - * Use this method to restrict a user in a supergroup. - * The bot **must be an administrator in the supergroup** for this to work - * and must have the appropriate admin rights. Pass True for all boolean parameters - * to lift restrictions from a user. Returns True on success. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Number} userId Unique identifier of the target user - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#restrictchatmember - */ - restrictChatMember(chatId, userId, form = {}) { - form.chat_id = chatId; - form.user_id = userId; - return this._request('restrictChatMember', { form }); - } - - /** - * Use this method to promote or demote a user in a supergroup or a channel. - * The bot **must be an administrator** in the chat for this to work - * and must have the appropriate admin rights. Pass False for all boolean parameters to demote a user. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Number} userId - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success. - * @see https://core.telegram.org/bots/api#promotechatmember - */ - promoteChatMember(chatId, userId, form = {}) { - form.chat_id = chatId; - form.user_id = userId; - return this._request('promoteChatMember', { form }); - } - - /** - * Use this method to set a custom title for an administrator in a supergroup promoted by the bot. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Number} userId Unique identifier of the target user - * @param {String} customTitle New custom title for the administrator; 0-16 characters, emoji are not allowed - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#setchatadministratorcustomtitle - */ - setChatAdministratorCustomTitle(chatId, userId, customTitle, form = {}) { - form.chat_id = chatId; - form.user_id = userId; - form.custom_title = customTitle; - return this._request('setChatAdministratorCustomTitle', { form }); - } - - /** - * Use this method to set a tag for a regular member in a group or a supergroup. - * - * The bot must be an administrator in the chat for this to work and must have the can_manage_tags administrator right. - * - * Note: If the user is a admin in the chat, then this method will fail with a 400 Bad Request error with the message "Bad Request: CHAT_ADMIN_REQUIRED". - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Number} userId Unique identifier of the target user - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#setchatmembertag - */ - setChatMemberTag(chatId, userId, form = {}) { - form.chat_id = chatId; - form.user_id = userId; - return this._request('setChatMemberTag', { form }); - } - - /** - * Use this method to ban a channel chat in a supergroup or a channel. - * - * Until the chat is [unbanned](https://core.telegram.org/bots/api#unbanchatsenderchat), the owner of the banned chat won't be able to send messages on behalf of any of their channels. - * The bot **must be an administrator in the supergroup or channel** for this to work and must have the appropriate administrator rights - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Number} senderChatId Unique identifier of the target user - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success. - * @see https://core.telegram.org/bots/api#banchatsenderchat - */ - banChatSenderChat(chatId, senderChatId, form = {}) { - form.chat_id = chatId; - form.sender_chat_id = senderChatId; - return this._request('banChatSenderChat', { form }); - } - - /** - * Use this method to unban a previously banned channel chat in a supergroup or channel. - * - * The bot **must be an administrator** for this to work and must have the appropriate administrator rights. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Number} senderChatId Unique identifier of the target user - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#unbanchatsenderchat - */ - unbanChatSenderChat(chatId, senderChatId, form = {}) { - form.chat_id = chatId; - form.sender_chat_id = senderChatId; - return this._request('unbanChatSenderChat', { form }); - } - - /** - * Use this method to set default chat permissions for all members. - * - * The bot **must be an administrator in the group or a supergroup** for this to - * work and **must have the `can_restrict_members` admin rights.** - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Array} chatPermissions New default chat permissions - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#setchatpermissions - */ - setChatPermissions(chatId, chatPermissions, form = {}) { - form.chat_id = chatId; - form.permissions = stringify(chatPermissions); - return this._request('setChatPermissions', { form }); - } - - /** - * Use this method to generate a new primary invite link for a chat. **Any previously generated primary link is revoked**. - * - * The bot **must be an administrator in the chat** for this to work and must have the appropriate administrator rights. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Object} [options] Additional Telegram query options - * @return {Promise} Exported invite link as String on success. - * @see https://core.telegram.org/bots/api#exportchatinvitelink - */ - exportChatInviteLink(chatId, form = {}) { - form.chat_id = chatId; - return this._request('exportChatInviteLink', { form }); - } - - /** - * Use this method to create an additional invite link for a chat. - * - * The bot **must be an administrator in the chat** for this to work and must have the appropriate admin rights. - * - * The link generated with this method can be revoked using the method [revokeChatInviteLink](https://core.telegram.org/bots/api#revokechatinvitelink) - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Object} [options] Additional Telegram query options - * @return {Object} The new invite link as [ChatInviteLink](https://core.telegram.org/bots/api#chatinvitelink) object - * @see https://core.telegram.org/bots/api#createchatinvitelink - */ - createChatInviteLink(chatId, form = {}) { - form.chat_id = chatId; - return this._request('createChatInviteLink', { form }); - } - - /** - * Use this method to edit a non-primary invite link created by the bot. - * - * The bot **must be an administrator in the chat** for this to work and must have the appropriate admin rights. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {String} inviteLink Text with the invite link to edit - * @param {Object} [options] Additional Telegram query options - * @return {Promise} The edited invite link as a [ChatInviteLink](https://core.telegram.org/bots/api#chatinvitelink) object - * @see https://core.telegram.org/bots/api#editchatinvitelink - */ - editChatInviteLink(chatId, inviteLink, form = {}) { - form.chat_id = chatId; - form.invite_link = inviteLink; - return this._request('editChatInviteLink', { form }); - } - - /** - * Use this method to create a subscription invite link for a channel chat. - * - * The bot must have the can_invite_users administrator rights - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Number} subscriptionPeriod The number of seconds the subscription will be active for before the next payment. Currently, it must always be 2592000 (30 days) - * @param {Number} subscriptionPrice The amount of Telegram Stars a user must pay initially and after each subsequent subscription period to be a member of the chat (1-2500) - * @param {Object} [options] Additional Telegram query options - * @return {Promise} The new invite link as a [ChatInviteLink](https://core.telegram.org/bots/api#chatinvitelink) object - * @see https://core.telegram.org/bots/api#createchatsubscriptioninvitelink - */ - createChatSubscriptionInviteLink(chatId, subscriptionPeriod, subscriptionPrice, form = {}) { - form.chat_id = chatId; - form.subscription_period = subscriptionPeriod; - form.subscription_price = subscriptionPrice; - return this._request('createChatSubscriptionInviteLink', { form }); - } - - /** - * Use this method to edit a subscription invite link created by the bot. - * - * The bot must have the can_invite_users administrator rights - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {String} inviteLink The invite link to edit - * @param {Object} [options] Additional Telegram query options - * @return {Promise} The new invite link as a [ChatInviteLink](https://core.telegram.org/bots/api#chatinvitelink) object - * @see https://core.telegram.org/bots/api#editchatsubscriptioninvitelink - */ - editChatSubscriptionInviteLink(chatId, inviteLink, form = {}) { - form.chat_id = chatId; - form.invite_link = inviteLink; - return this._request('editChatSubscriptionInviteLink', { form }); - } - - /** - * Use this method to revoke an invite link created by the bot. - * Note: If the primary link is revoked, a new link is automatically generated - * - * The bot **must be an administrator in the chat** for this to work and must have the appropriate admin rights. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {String} inviteLink The invite link to revoke - * @param {Object} [options] Additional Telegram query options - * @return {Promise} The revoked invite link as [ChatInviteLink](https://core.telegram.org/bots/api#chatinvitelink) object - * @see https://core.telegram.org/bots/api#revokechatinvitelink - */ - revokeChatInviteLink(chatId, inviteLink, form = {}) { - form.chat_id = chatId; - form.invite_link = inviteLink; - return this._request('revokeChatInviteLink', { form }); - } - - /** - * Use this method to approve a chat join request. - * - * The bot **must be an administrator in the chat** for this to work and **must have the `can_invite_users` administrator right.** - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Number} userId Unique identifier of the target user - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#approvechatjoinrequest - */ - approveChatJoinRequest(chatId, userId, form = {}) { - form.chat_id = chatId; - form.user_id = userId; - return this._request('approveChatJoinRequest', { form }); - } - - /** - * Use this method to decline a chat join request. - * - * The bot **must be an administrator in the chat** for this to work and **must have the `can_invite_users` administrator right**. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Number} userId Unique identifier of the target user - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#declinechatjoinrequest - */ - declineChatJoinRequest(chatId, userId, form = {}) { - form.chat_id = chatId; - form.user_id = userId; - return this._request('declineChatJoinRequest', { form }); - } - - /** - * Use this method to set a new profile photo for the chat. **Photos can't be changed for private chats**. - * - * The bot **must be an administrator in the chat** for this to work and must have the appropriate admin rights. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {stream.Stream|Buffer} photo A file path or a Stream. - * @param {Object} [options] Additional Telegram query options - * @param {Object} [fileOptions] Optional file related meta-data - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#setchatphoto - */ - setChatPhoto(chatId, photo, options = {}, fileOptions = {}) { - const opts = { - qs: options, - }; - opts.qs.chat_id = chatId; - try { - const sendData = this._formatSendData('photo', photo, fileOptions); - opts.formData = sendData[0]; - opts.qs.photo = sendData[1]; - } catch (ex) { - return Promise.reject(ex); - } - return this._request('setChatPhoto', opts); - } - - /** - * Use this method to delete a chat photo. **Photos can't be changed for private chats**. - * - * The bot **must be an administrator in the chat** for this to work and must have the appropriate admin rights. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#deletechatphoto - */ - deleteChatPhoto(chatId, form = {}) { - form.chat_id = chatId; - return this._request('deleteChatPhoto', { form }); - } - - /** - * Use this method to change the title of a chat. **Titles can't be changed for private chats**. - * - * The bot **must be an administrator in the chat** for this to work and must have the appropriate admin rights. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {String} title New chat title, 1-255 characters - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#setchattitle - */ - setChatTitle(chatId, title, form = {}) { - form.chat_id = chatId; - form.title = title; - return this._request('setChatTitle', { form }); - } - - /** - * Use this method to change the description of a group, a supergroup or a channel. - * - * The bot **must be an administrator in the chat** for this to work and must have the appropriate admin rights. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {String} description New chat title, 0-255 characters - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#setchatdescription - */ - setChatDescription(chatId, description, form = {}) { - form.chat_id = chatId; - form.description = description; - return this._request('setChatDescription', { form }); - } - - /** - * Use this method to pin a message in a supergroup. - * - * If the chat is not a private chat, the **bot must be an administrator in the chat** for this to work and must have the `can_pin_messages` administrator - * right in a supergroup or `can_edit_messages` administrator right in a channel. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Number} messageId Identifier of a message to pin - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#pinchatmessage - */ - pinChatMessage(chatId, messageId, form = {}) { - form.chat_id = chatId; - form.message_id = messageId; - return this._request('pinChatMessage', { form }); - } - - /** - * Use this method to remove a message from the list of pinned messages in a chat - * - * If the chat is not a private chat, the **bot must be an administrator in the chat** for this to work and must have the `can_pin_messages` administrator - * right in a supergroup or `can_edit_messages` administrator right in a channel. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#unpinchatmessage - */ - unpinChatMessage(chatId, form = {}) { - form.chat_id = chatId; - return this._request('unpinChatMessage', { form }); - } - - /** - * Use this method to clear the list of pinned messages in a chat. - * - * If the chat is not a private chat, the **bot must be an administrator in the chat** for this to work and must have the `can_pin_messages` administrator - * right in a supergroup or `can_edit_messages` administrator right in a channel. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#unpinallchatmessages - */ - unpinAllChatMessages(chatId, form = {}) { - form.chat_id = chatId; - return this._request('unpinAllChatMessages', { form }); - } - - /** - * Use this method for your bot to leave a group, supergroup or channel - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#leavechat - */ - leaveChat(chatId, form = {}) { - form.chat_id = chatId; - return this._request('leaveChat', { form }); - } - - /** - * Use this method to get up to date information about the chat - * (current name of the user for one-on-one conversations, current - * username of a user, group or channel, etc.). - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) or channel - * @param {Object} [options] Additional Telegram query options - * @return {Promise} [ChatFullInfo](https://core.telegram.org/bots/api#chatfullinfo) object on success - * @see https://core.telegram.org/bots/api#getchat - */ - getChat(chatId, form = {}) { - form.chat_id = chatId; - return this._request('getChat', { form }); - } - - /** - * Use this method to get a list of administrators in a chat - * - * @param {Number|String} chatId Unique identifier for the target group or username of the target supergroup - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns an Array of [ChatMember](https://core.telegram.org/bots/api#chatmember) objects that contains information about all chat administrators except other bots. - * If the chat is a group or a supergroup and no administrators were appointed, only the creator will be returned - * @see https://core.telegram.org/bots/api#getchatadministrators - */ - getChatAdministrators(chatId, form = {}) { - form.chat_id = chatId; - return this._request('getChatAdministrators', { form }); - } - - /** - * Use this method to get the number of members in a chat. - * - * @param {Number|String} chatId Unique identifier for the target group or username of the target supergroup - * @param {Object} [options] Additional Telegram query options - * @return {Promise} Int on success - * @see https://core.telegram.org/bots/api#getchatmembercount - */ - getChatMemberCount(chatId, form = {}) { - form.chat_id = chatId; - return this._request('getChatMemberCount', { form }); - } - - /** - * Use this method to get information about a member of a chat. - * - * @param {Number|String} chatId Unique identifier for the target group or username of the target supergroup - * @param {Number} userId Unique identifier of the target user - * @param {Object} [options] Additional Telegram query options - * @return {Promise} [ChatMember](https://core.telegram.org/bots/api#chatmember) object on success - * @see https://core.telegram.org/bots/api#getchatmember - */ - getChatMember(chatId, userId, form = {}) { - form.chat_id = chatId; - form.user_id = userId; - return this._request('getChatMember', { form }); - } - - /** - * Use this method to set a new group sticker set for a supergroup. - * - * The bot **must be an administrator in the chat** for this to work and must have the appropriate administrator rights. - * - * **Note:** Use the field `can_set_sticker_set` optionally returned in [getChat](https://core.telegram.org/bots/api#getchat) requests to check if the bot can use this method. - * - * @param {Number|String} chatId Unique identifier for the target group or username of the target supergroup (in the format @supergroupusername) - * @param {String} stickerSetName Name of the sticker set to be set as the group sticker set - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#setchatstickerset - */ - setChatStickerSet(chatId, stickerSetName, form = {}) { - form.chat_id = chatId; - form.sticker_set_name = stickerSetName; - return this._request('setChatStickerSet', { form }); - } - - - /** - * Use this method to delete a group sticker set from a supergroup. - * - * Use the field `can_set_sticker_set` optionally returned in [getChat](https://core.telegram.org/bots/api#getchat) requests to check if the bot can use this method. - * - * @param {Number|String} chatId Unique identifier for the target group or username of the target supergroup (in the format @supergroupusername) - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#deletechatstickerset - */ - deleteChatStickerSet(chatId, form = {}) { - form.chat_id = chatId; - return this._request('deleteChatStickerSet', { form }); - } - - /** - * Use this method to get custom emoji stickers, which can be used as a forum topic icon by any user. - * - * @param {Number|String} chatId Unique identifier for the target group or username of the target supergroup (in the format @supergroupusername) - * @param {Object} [options] Additional Telegram query options - * @return {Promise} Array of [Sticker](https://core.telegram.org/bots/api#sticker) objects - * @see https://core.telegram.org/bots/api#getforumtopiciconstickers - */ - getForumTopicIconStickers(chatId, form = {}) { - form.chat_id = chatId; - return this._request('getForumTopicIconStickers', { form }); - } - - /** - * Use this method to create a topic in a forum supergroup chat. - * The bot must be an administrator in the chat for this to work and must have the can_manage_topics administrator rights. - * - * Returns information about the created topic as a [ForumTopic](https://core.telegram.org/bots/api#forumtopic) object. - * - * @param {Number|String} chatId Unique identifier for the target group or username of the target supergroup (in the format @supergroupusername) - * @param {String} name Topic name, 1-128 characters - * @param {Object} [options] Additional Telegram query options - * @see https://core.telegram.org/bots/api#createforumtopic - */ - createForumTopic(chatId, name, form = {}) { - form.chat_id = chatId; - form.name = name; - return this._request('createForumTopic', { form }); - } - - /** - * Use this method to edit name and icon of a topic in a forum supergroup chat. - * The bot must be an administrator in the chat for this to work and must have can_manage_topics administrator rights, unless it is the creator of the topic. - * - * @param {Number|String} chatId Unique identifier for the target group or username of the target supergroup (in the format @supergroupusername) - * @param {Number} messageThreadId Unique identifier for the target message thread of the forum topic - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#editforumtopic - */ - editForumTopic(chatId, messageThreadId, form = {}) { - form.chat_id = chatId; - form.message_thread_id = messageThreadId; - return this._request('editForumTopic', { form }); - } - - /** - * Use this method to close an open topic in a forum supergroup chat. - * The bot must be an administrator in the chat for this to work and must have the can_manage_topics administrator rights, unless it is the creator of the topic. - * - * @param {Number|String} chatId Unique identifier for the target group or username of the target supergroup (in the format @supergroupusername) - * @param {Number} messageThreadId Unique identifier for the target message thread of the forum topic - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#closeforumtopic - */ - closeForumTopic(chatId, messageThreadId, form = {}) { - form.chat_id = chatId; - form.message_thread_id = messageThreadId; - return this._request('closeForumTopic', { form }); - } - - /** - * Use this method to reopen a closed topic in a forum supergroup chat. - * The bot must be an administrator in the chat for this to work and must have the can_manage_topics administrator rights, unless it is the creator of the topic. - * - * @param {Number|String} chatId Unique identifier for the target group or username of the target supergroup (in the format @supergroupusername) - * @param {Number} messageThreadId Unique identifier for the target message thread of the forum topic - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#reopenforumtopic - */ - reopenForumTopic(chatId, messageThreadId, form = {}) { - form.chat_id = chatId; - form.message_thread_id = messageThreadId; - return this._request('reopenForumTopic', { form }); - } - - /** - * Use this method to delete a forum topic along with all its messages in a forum supergroup chat. - * The bot must be an administrator in the chat for this to work and must have the can_delete_messages administrator rights. - * - * @param {Number|String} chatId Unique identifier for the target group or username of the target supergroup (in the format @supergroupusername) - * @param {Number} messageThreadId Unique identifier for the target message thread of the forum topic - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#deleteforumtopic - */ - deleteForumTopic(chatId, messageThreadId, form = {}) { - form.chat_id = chatId; - form.message_thread_id = messageThreadId; - return this._request('deleteForumTopic', { form }); - } - - /** - * Use this method to clear the list of pinned messages in a forum topic. - * The bot must be an administrator in the chat for this to work and must have the can_pin_messages administrator right in the supergroup. - * - * @param {Number|String} chatId Unique identifier for the target group or username of the target supergroup (in the format @supergroupusername) - * @param {Number} messageThreadId Unique identifier for the target message thread of the forum topic - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#unpinallforumtopicmessages - */ - unpinAllForumTopicMessages(chatId, messageThreadId, form = {}) { - form.chat_id = chatId; - form.message_thread_id = messageThreadId; - return this._request('unpinAllForumTopicMessages', { form }); - } - - /** - * Use this method to edit the name of the 'General' topic in a forum supergroup chat. - * The bot must be an administrator in the chat for this to work and must have the can_manage_topics administrator rights. - * The topic will be automatically unhidden if it was hidden. - * - * @param {Number|String} chatId Unique identifier for the target group or username of the target supergroup (in the format @supergroupusername) - * @param {String} name New topic name, 1-128 characters - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#editgeneralforumtopic - */ - editGeneralForumTopic(chatId, name, form = {}) { - form.chat_id = chatId; - form.name = name; - return this._request('editGeneralForumTopic', { form }); - } - - /** - * Use this method to close an open 'General' topic in a forum supergroup chat. - * The bot must be an administrator in the chat for this to work and must have the can_manage_topics administrator rights. - * The topic will be automatically unhidden if it was hidden. - * - * @param {Number|String} chatId Unique identifier for the target group or username of the target supergroup (in the format @supergroupusername) - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#closegeneralforumtopic - */ - closeGeneralForumTopic(chatId, form = {}) { - form.chat_id = chatId; - return this._request('closeGeneralForumTopic', { form }); - } - - /** - * Use this method to reopen a closed 'General' topic in a forum supergroup chat. - * The bot must be an administrator in the chat for this to work and must have the can_manage_topics administrator rights. - * The topic will be automatically unhidden if it was hidden. - * - * @param {Number|String} chatId Unique identifier for the target group or username of the target supergroup (in the format @supergroupusername) - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#reopengeneralforumtopic - */ - reopenGeneralForumTopic(chatId, form = {}) { - form.chat_id = chatId; - return this._request('reopenGeneralForumTopic', { form }); - } - - /** - * Use this method to hide the 'General' topic in a forum supergroup chat. - * The bot must be an administrator in the chat for this to work and must have the can_manage_topics administrator rights. - * The topic will be automatically closed if it was open. - * - * @param {Number|String} chatId Unique identifier for the target group or username of the target supergroup (in the format @supergroupusername) - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#hidegeneralforumtopic - */ - hideGeneralForumTopic(chatId, form = {}) { - form.chat_id = chatId; - return this._request('hideGeneralForumTopic', { form }); - } - - /** - * Use this method to unhide the 'General' topic in a forum supergroup chat. - * The bot must be an administrator in the chat for this to work and must have the can_manage_topics administrator rights - * - * @param {Number|String} chatId Unique identifier for the target group or username of the target supergroup (in the format @supergroupusername) - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#unhidegeneralforumtopic - */ - unhideGeneralForumTopic(chatId, form = {}) { - form.chat_id = chatId; - return this._request('unhideGeneralForumTopic', { form }); - } - - /** - * Use this method to clear the list of pinned messages in a General forum topic. - * The bot must be an administrator in the chat for this to work and must have the can_pin_messages administrator right in the supergroup. - * - * @param {Number|String} chatId Unique identifier for the target group or username of the target supergroup (in the format @supergroupusername) - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#unpinallgeneralforumtopicmessages - */ - unpinAllGeneralForumTopicMessages(chatId, form = {}) { - form.chat_id = chatId; - return this._request('unpinAllGeneralForumTopicMessages', { form }); - } - - /** - * Use this method to send answers to callback queries sent from - * [inline keyboards](https://core.telegram.org/bots#inline-keyboards-and-on-the-fly-updating). - * - * The answer will be displayed to the user as a notification at the top of the chat screen or as an alert. - * - * This method has **older, compatible signatures ([1][answerCallbackQuery-v0.27.1])([2][answerCallbackQuery-v0.29.0])** - * that are being deprecated. - * - * @param {String} callbackQueryId Unique identifier for the query to be answered - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#answercallbackquery - */ - answerCallbackQuery(callbackQueryId, form = {}) { - /* The older method signature (in/before v0.27.1) was answerCallbackQuery(callbackQueryId, text, showAlert). - * We need to ensure backwards-compatibility while maintaining - * consistency of the method signatures throughout the library */ - if (typeof form !== 'object') { - /* eslint-disable no-param-reassign, prefer-rest-params */ - deprecate('The method signature answerCallbackQuery(callbackQueryId, text, showAlert) has been deprecated since v0.27.1'); - form = { - callback_query_id: arguments[0], - text: arguments[1], - show_alert: arguments[2], - }; - /* eslint-enable no-param-reassign, prefer-rest-params */ - } - /* The older method signature (in/before v0.29.0) was answerCallbackQuery([options]). - * We need to ensure backwards-compatibility while maintaining - * consistency of the method signatures throughout the library. */ - if (typeof callbackQueryId === 'object') { - /* eslint-disable no-param-reassign, prefer-rest-params */ - deprecate('The method signature answerCallbackQuery([options]) has been deprecated since v0.29.0'); - form = callbackQueryId; - /* eslint-enable no-param-reassign, prefer-rest-params */ - } else { - form.callback_query_id = callbackQueryId; - } - return this._request('answerCallbackQuery', { form }); - } - - /** - * Use this method to stores a message that can be sent by a user of a Mini App. - * - * @param {Number} userId Unique identifier of the target user - * @param {InlineQueryResult} result object that represents one result of an inline query - * @param {Object} [options] Optional form data to include in the request - * @return {Promise} On success, returns a [PreparedInlineMessage](https://core.telegram.org/bots/api#preparedinlinemessage) object. - * @see https://core.telegram.org/bots/api#savepreparedinlinemessage - */ - savePreparedInlineMessage(userId, result, form = {}) { - form.user_id = userId; - form.result = stringify(result); - return this._request('savePreparedInlineMessage', { form }); - } - - /** - * Use this method to stores a message that can be sent by a user of a Mini App. - * - * @param {Number} userId Unique identifier of the target user - * @param {KeyboardButton} button A JSON-serialized object describing the button to be saved. The button must be of the type request_users, request_chat, or request_managed_bot. - * @param {Object} [options] Optional form data to include in the request - * @return {Promise} On success, returns a [PreparedKeyboardButton](https://core.telegram.org/bots/api#preparedkeyboardbutton) object. - * @see https://core.telegram.org/bots/api#savepreparedkeyboardbutton - */ - savePreparedKeyboardButton(userId, button, form = {}) { - form.user_id = userId; - form.button = stringify(button); - return this._request('savePreparedKeyboardButton', { form }); - } - - /** - * Use this method to get the list of boosts added to a chat by a use. - * Requires administrator rights in the chat - * - * @param {Number|String} chatId Unique identifier for the group/channel - * @param {Number} userId Unique identifier of the target user - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns a [UserChatBoosts](https://core.telegram.org/bots/api#userchatboosts) object - * @see https://core.telegram.org/bots/api#getuserchatboosts - */ - getUserChatBoosts(chatId, userId, form = {}) { - form.chat_id = chatId; - form.user_id = userId; - return this._request('getUserChatBoosts', { form }); - } - - /** - * Use this method to get information about the connection of the bot with a business account - * - * @param {Number|String} businessConnectionId Unique identifier for the group/channel - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns [BusinessConnection](https://core.telegram.org/bots/api#businessconnection) object - * @see https://core.telegram.org/bots/api#getbusinessconnection - */ - getBusinessConnection(businessConnectionId, form = {}) { - form.business_connection_id = businessConnectionId; - return this._request('getBusinessConnection', { form }); - } - - /** - * Use this method to get the token of a managed bot. - * - * @param {Number|String} userId User identifier of the managed bot whose token will be returned - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns the token of the managed bot as String - * @see https://core.telegram.org/bots/api#getmanagedbottoken - */ - getManagedBotToken(userId, form = {}) { - form.user_id = userId; - return this._request('getManagedBotToken', { form }); - } - - /** - * Use this method to revoke the current token of a managed bot and generate a new one. - * - * @param {Number|String} userId User identifier of the managed bot whose token will be replaced - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns the token of the managed bot as String - * @see https://core.telegram.org/bots/api#replacemanagedbottoken - */ - replaceManagedBotToken(userId, form = {}) { - form.user_id = userId; - return this._request('replaceManagedBotToken', { form }); - } - - /** - * Use this method to change the list of the bot's commands. - * - * See https://core.telegram.org/bots#commands for more details about bot commands - * - * @param {Array} commands List of bot commands to be set as the list of the [bot's commands](https://core.telegram.org/bots/api#botcommand). At most 100 commands can be specified. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#setmycommands - */ - setMyCommands(commands, form = {}) { - form.commands = stringify(commands); - - if (form.scope) { - form.scope = stringify(form.scope); - } - - return this._request('setMyCommands', { form }); - } - - /** - * Use this method to delete the list of the bot's commands for the given scope and user language. - * - * After deletion, [higher level commands](https://core.telegram.org/bots/api#determining-list-of-commands) will be shown to affected users. - * - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#deletemycommands - */ - deleteMyCommands(form = {}) { - if (form.scope) { - form.scope = stringify(form.scope); - } - return this._request('deleteMyCommands', { form }); - } - - - /** - * Use this method to get the current list of the bot's commands for the given scope and user language. - * - * @param {Object} [options] Additional Telegram query options - * @return {Promise} Array of [BotCommand](https://core.telegram.org/bots/api#botcommand) on success. If commands aren't set, an empty list is returned. - * @see https://core.telegram.org/bots/api#getmycommands - */ - getMyCommands(form = {}) { - if (form.scope) { - form.scope = stringify(form.scope); - } - return this._request('getMyCommands', { form }); - } - - /** - * Use this method to change the bot's name. - * - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#setmyname - */ - setMyName(form = {}) { - return this._request('setMyName', { form }); - } - - /** - * Use this method to get the current bot name for the given user language. - * - * @param {Object} [options] Additional Telegram query options - * @return {Promise} [BotName](https://core.telegram.org/bots/api#botname) on success - * @see https://core.telegram.org/bots/api#getmyname - */ - getMyName(form = {}) { - return this._request('getMyName', { form }); - } - - /** - * Use this method to change the bot's description, which is shown in the chat with the bot if the chat is empty. - * - * Returns True on success. - * - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#setmydescription - */ - setMyDescription(form = {}) { - return this._request('setMyDescription', { form }); - } - - /** - * Use this method to get the current bot description for the given user language. - * - * @param {Object} [options] Additional Telegram query options - * @return {Promise} Returns [BotDescription](https://core.telegram.org/bots/api#botdescription) on success. - * @see https://core.telegram.org/bots/api#getmydescription - */ - getMyDescription(form = {}) { - return this._request('getMyDescription', { form }); - } - - /** - * Use this method to change the bot's short description, which is shown on the bot's profile page - * and is sent together with the link when users share the bot. - * - * @param {Object} [options] Additional Telegram query options - * @return {Promise} Returns True on success. - * @see https://core.telegram.org/bots/api#setmyshortdescription - */ - setMyShortDescription(form = {}) { - return this._request('setMyShortDescription', { form }); - } - - /** - * Use this method to get the current bot short description for the given user language. - * - * @param {Object} [options] Additional Telegram query options - * @return {Promise} Returns [BotShortDescription](https://core.telegram.org/bots/api#botshortdescription) on success. - * @see https://core.telegram.org/bots/api#getmyshortdescription - */ - getMyShortDescription(form = {}) { - return this._request('getMyShortDescription', { form }); - } - - /** - * Changes the profile photo of the bot. - * - * @param {InputProfilePhoto} photo The new profile photo to set - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#setmyprofilephoto - */ - setMyProfilePhoto(photo, options = {}) { - const opts = { - qs: options, - formData: {}, - }; - - if (!photo.type) { - throw new Error('InputProfilePhoto must have a type'); - } - - const media = photo.photo || photo.animation; - - if (!media) { - throw new Error('InputProfilePhoto must have a photo or animation field'); - } - - try { - const [formData] = this._formatSendData(photo.type, media.replace(/^attach:\/\/?/, '')); - - opts.formData[photo.type] = formData[photo.type]; - - opts.qs.thumbnail = `attach://${photo.type}`; - } catch (ex) { - return Promise.reject(ex); - } - - return this._request('setMyProfilePhoto', opts); - } - - /** - * Removes the profile photo of the bot. - * - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#removemyprofilephoto - */ - removeMyProfilePhoto(form = {}) { - return this._request('removeMyProfilePhoto', { form }); - } - - /** - * Use this method to change the bot's menu button in a private chat, or the default menu button. - * - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#setchatmenubutton - */ - setChatMenuButton(form = {}) { - return this._request('setChatMenuButton', { form }); - } - - /** - * Use this method to get the current value of the bot's menu button in a private chat, or the default menu button. - * - * @param {Object} [options] Additional Telegram query options - * @return {Promise} [MenuButton](https://core.telegram.org/bots/api#menubutton) on success - * @see https://core.telegram.org/bots/api#getchatmenubutton - */ - getChatMenuButton(form = {}) { - return this._request('getChatMenuButton', { form }); - } - - /** - * Use this method to change the default administrator rights requested by the bot when it's added as an administrator to groups or channels. - * - * These rights will be suggested to users, but they are are free to modify the list before adding the bot. - * - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#getchatmenubutton - */ - setMyDefaultAdministratorRights(form = {}) { - return this._request('setMyDefaultAdministratorRights', { form }); - } - - /** - * Use this method to get the current default administrator rights of the bot. - * - * @param {Object} [options] Additional Telegram query options - * @return {Promise} [ChatAdministratorRights](https://core.telegram.org/bots/api#chatadministratorrights) on success - * @see https://core.telegram.org/bots/api#getmydefaultadministratorrights - */ - getMyDefaultAdministratorRights(form = {}) { - return this._request('getMyDefaultAdministratorRights', { form }); - } - - /** - * Use this method to edit text or [game](https://core.telegram.org/bots/api#games) messages sent by the bot or via the bot (for inline bots). - * - * Note: that **you must provide one of chat_id, message_id, or inline_message_id** in your request. - * - * @param {String} text New text of the message - * @param {Object} [options] Additional Telegram query options (provide either one of chat_id, message_id, or inline_message_id here) - * @return {Promise} On success, if the edited message is not an inline message, the edited [Message](https://core.telegram.org/bots/api#message) is returned, otherwise True is returned - * @see https://core.telegram.org/bots/api#editmessagetext - */ - editMessageText(text, form = {}) { - form.text = text; - return this._request('editMessageText', { form }); - } - - /** - * Use this method to edit captions of messages sent by the bot or via the bot (for inline bots). - * - * Note: You **must provide one of chat_id, message_id, or inline_message_id** in your request. - * - * @param {String} caption New caption of the message - * @param {Object} [options] Additional Telegram query options (provide either one of chat_id, message_id, or inline_message_id here) - * @return {Promise} On success, if the edited message is not an inline message, the edited [Message](https://core.telegram.org/bots/api#message) is returned, otherwise True is returned - * @see https://core.telegram.org/bots/api#editmessagecaption - */ - editMessageCaption(caption, form = {}) { - form.caption = caption; - return this._request('editMessageCaption', { form }); - } - - /** - * Use this method to edit animation, audio, document, photo, or video messages. - * - * If a message is a part of a message album, then it can be edited only to a photo or a video. - * - * Otherwise, message type can be changed arbitrarily. When inline message is edited, new file can't be uploaded. - * Use previously uploaded file via its file_id or specify a URL. - * - * Note: You **must provide one of chat_id, message_id, or inline_message_id** in your request. - * - * @param {Object} media A JSON-serialized object for a new media content of the message - * @param {Object} [options] Additional Telegram query options (provide either one of chat_id, message_id, or inline_message_id here) - * @return {Promise} On success, if the edited message is not an inline message, the edited [Message](https://core.telegram.org/bots/api#message) is returned, otherwise True is returned - * @see https://core.telegram.org/bots/api#editmessagemedia - */ - editMessageMedia(media, form = {}) { - const regexAttach = /attach:\/\/.+/; - - if (typeof media.media === 'string' && regexAttach.test(media.media)) { - const opts = { - qs: form, - }; - - opts.formData = {}; - - const payload = Object.assign({}, media); - delete payload.media; - - try { - const attachName = String(0); - const [formData] = this._formatSendData( - attachName, - media.media.replace('attach://', ''), - media.fileOptions - ); - - if (formData) { - opts.formData[attachName] = formData[attachName]; - payload.media = `attach://${attachName}`; - } else { - throw new errors.FatalError(`Failed to process the replacement action for your ${media.type}`); - } - } catch (ex) { - return Promise.reject(ex); - } - - opts.qs.media = stringify(payload); - - return this._request('editMessageMedia', opts); - } - - form.media = stringify(media); - - return this._request('editMessageMedia', { form }); - } - - /** - * Use this method to edit a checklist on behalf of a business connection. - * @param {Number|String} businessConnectionId Unique identifier for the target business connection - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {Number} messageId Unique identifier for the target message - * @param {Object} checklist A JSON-serialized object for the new checklist - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, the sent [Message](https://core.telegram.org/bots/api#message) object is returned. - * @see https://core.telegram.org/bots/api#editmessagechecklist - */ - editMessageChecklist(businessConnectionId, chatId, messageId, checklist, form = {}) { - form.business_connection_id = businessConnectionId; - form.chat_id = chatId; - form.message_id = messageId; - form.checklist = stringify(checklist); - return this._request('editMessageChecklist', { form }); - } - - /** - * Use this method to edit only the reply markup of messages sent by the bot or via the bot (for inline bots). - * - * Note: You **must provide one of chat_id, message_id, or inline_message_id** in your request. - * - * @param {Object} replyMarkup A JSON-serialized object for an inline keyboard. - * @param {Object} [options] Additional Telegram query options (provide either one of chat_id, message_id, or inline_message_id here) - * @return {Promise} On success, if the edited message is not an inline message, the edited [Message](https://core.telegram.org/bots/api#message) is returned, otherwise True is returned - * @see https://core.telegram.org/bots/api#editmessagetext - */ - editMessageReplyMarkup(replyMarkup, form = {}) { - form.reply_markup = replyMarkup; - return this._request('editMessageReplyMarkup', { form }); - } - - /** - * Use this method to stop a poll which was sent by the bot. - * - * @param {Number|String} chatId Unique identifier for the group/channel - * @param {Number} pollId Identifier of the original message with the poll - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, the stopped [Poll](https://core.telegram.org/bots/api#poll) is returned - * @see https://core.telegram.org/bots/api#stoppoll - */ - stopPoll(chatId, pollId, form = {}) { - form.chat_id = chatId; - form.message_id = pollId; - return this._request('stopPoll', { form }); - } - - /** - * Use this method to approve a suggested post in a direct messages chat. - * - * The bot must have the 'can_post_messages' administrator right in the corresponding channel chat. - * - * @param {Number|String} chatId Unique identifier for the group/channel - * @param {Number} messageId Identifier of the original message with the suggested post - * @param {Object} [options] Additional Telegram query options - * @return {Promise} on success, returns True - * @see https://core.telegram.org/bots/api#approvesuggestedpost - */ - approveSuggestedPost(chatId, messageId, form = {}) { - form.chat_id = chatId; - form.message_id = messageId; - return this._request('approveSuggestedPost', { form }); - } - - /** - * Use this method to decline a suggested post in a direct messages chat. - * - * The bot must have the 'can_manage_direct_messages' administrator right in the corresponding channel chat. - * - * @param {Number|String} chatId Unique identifier for the group/channel - * @param {Number} messageId Identifier of the original message with the suggested post - * @param {Object} [options] Additional Telegram query options - * @return {Promise} on success, returns True - * @see https://core.telegram.org/bots/api#declinesuggestedpost - */ - declineSuggestedPost(chatId, messageId, form = {}) { - form.chat_id = chatId; - form.message_id = messageId; - return this._request('declineSuggestedPost', { form }); - } - - /** - * Use this method to send static .WEBP, [animated](https://telegram.org/blog/animated-stickers) .TGS, - * or [video](https://telegram.org/blog/video-stickers-better-reactions) .WEBM stickers. - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {String|stream.Stream|Buffer} sticker A file path, Stream or Buffer. - * Can also be a `file_id` previously uploaded. Stickers are WebP format files. - * @param {Object} [options] Additional Telegram query options - * @param {Object} [fileOptions] Optional file related meta-data - * @return {Promise} On success, the sent [Message](https://core.telegram.org/bots/api#message) is returned - * @see https://core.telegram.org/bots/api#sendsticker - */ - sendSticker(chatId, sticker, options = {}, fileOptions = {}) { - const opts = { - qs: options - }; - opts.qs.chat_id = chatId; - try { - const sendData = this._formatSendData('sticker', sticker, fileOptions); - opts.formData = sendData[0]; - opts.qs.sticker = sendData[1]; - } catch (ex) { - return Promise.reject(ex); - } - return this._request('sendSticker', opts); - } - - /** - * Use this method to get a sticker set. - * - * @param {String} name Name of the sticker set - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, a [StickerSet](https://core.telegram.org/bots/api#stickerset) object is returned - * @see https://core.telegram.org/bots/api#getstickerset - */ - getStickerSet(name, form = {}) { - form.name = name; - return this._request('getStickerSet', { form }); - } - - /** - * Use this method to get information about custom emoji stickers by their identifiers. - * - * @param {Array} custom_emoji_ids List of custom emoji identifiers. At most 200 custom emoji identifiers can be specified. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} Array of [Sticker](https://core.telegram.org/bots/api#sticker) objects. - * @see https://core.telegram.org/bots/api#getcustomemojistickers - */ - getCustomEmojiStickers(customEmojiIds, form = {}) { - form.custom_emoji_ids = stringify(customEmojiIds); - return this._request('getCustomEmojiStickers', { form }); - } - - /** - * Use this method to upload a file with a sticker for later use in *createNewStickerSet* and *addStickerToSet* methods (can be used multiple - * times). - * - * @param {Number} userId User identifier of sticker file owner - * @param {String|stream.Stream|Buffer} sticker A file path or a Stream with the sticker in .WEBP, .PNG, .TGS, or .WEBM format. Can also be a `file_id` previously uploaded. - * @param {String} stickerFormat Allow values: `static`, `animated` or `video` - * @param {Object} [options] Additional Telegram query options - * @param {Object} [fileOptions] Optional file related meta-data - * @return {Promise} On success, a [File](https://core.telegram.org/bots/api#file) object is returned - * @see https://core.telegram.org/bots/api#uploadstickerfile - */ - uploadStickerFile(userId, sticker, stickerFormat = 'static', options = {}, fileOptions = {}) { - const opts = { - qs: options, - }; - opts.qs.user_id = userId; - opts.qs.sticker_format = stickerFormat; - - try { - const sendData = this._formatSendData('sticker', sticker, fileOptions); - opts.formData = sendData[0]; - opts.qs.sticker = sendData[1]; - } catch (ex) { - return Promise.reject(ex); - } - return this._request('uploadStickerFile', opts); - } - - /** - * Use this method to create new sticker set owned by a user. - * - * The bot will be able to edit the created sticker set. - * - * You must use exactly one of the fields *png_sticker*, *tgs_sticker*, or *webm_sticker* - * - * @param {Number} userId User identifier of created sticker set owner - * @param {String} name Short name of sticker set, to be used in `t.me/addstickers/` URLs (e.g., *"animals"*). Can contain only english letters, digits and underscores. - * Must begin with a letter, can't contain consecutive underscores and must end in `"_by_"`. `` is case insensitive. 1-64 characters. - * @param {String} title Sticker set title, 1-64 characters - * @param {String|stream.Stream|Buffer} pngSticker Png image with the sticker, must be up to 512 kilobytes in size, - * dimensions must not exceed 512px, and either width or height must be exactly 512px. - * @param {String} emojis One or more emoji corresponding to the sticker - * @param {Object} [options] Additional Telegram query options - * @param {Object} [fileOptions] Optional file related meta-data - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#createnewstickerset - */ - createNewStickerSet(userId, name, title, pngSticker, emojis, options = {}, fileOptions = {}) { - const opts = { - qs: options, - }; - opts.qs.user_id = userId; - opts.qs.name = name; - opts.qs.title = title; - opts.qs.emojis = emojis; - opts.qs.mask_position = stringify(options.mask_position); - try { - const sendData = this._formatSendData('png_sticker', pngSticker, fileOptions); - opts.formData = sendData[0]; - opts.qs.png_sticker = sendData[1]; - } catch (ex) { - return Promise.reject(ex); - } - return this._request('createNewStickerSet', opts); - } - - /** - * Use this method to add a new sticker to a set created by the bot. - * - * You must use exactly one of the fields *png_sticker*, *tgs_sticker*, or *webm_sticker* - * - * Animated stickers can be added to animated sticker sets and only to them - * - * Note: - * - Emoji sticker sets can have up to 200 sticker - * - Static or Animated sticker sets can have up to 120 stickers - * - * @param {Number} userId User identifier of sticker set owner - * @param {String} name Sticker set name - * @param {String|stream.Stream|Buffer} sticker Png image with the sticker (must be up to 512 kilobytes in size, - * dimensions must not exceed 512px, and either width or height must be exactly 512px, [TGS animation](https://core.telegram.org/stickers#animated-sticker-requirements) - * with the sticker or [WEBM video](https://core.telegram.org/stickers#video-sticker-requirements) with the sticker. - * @param {String} emojis One or more emoji corresponding to the sticker - * @param {String} stickerType Allow values: `png_sticker`, `tgs_sticker`, or `webm_sticker`. - * @param {Object} [options] Additional Telegram query options - * @param {Object} [fileOptions] Optional file related meta-data - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#addstickertoset - */ - addStickerToSet(userId, name, sticker, emojis, stickerType = 'png_sticker', options = {}, fileOptions = {}) { - const opts = { - qs: options, - }; - opts.qs.user_id = userId; - opts.qs.name = name; - opts.qs.emojis = emojis; - opts.qs.mask_position = stringify(options.mask_position); - - if (typeof stickerType !== 'string' || ['png_sticker', 'tgs_sticker', 'webm_sticker'].indexOf(stickerType) === -1) { - return Promise.reject(new Error('stickerType must be a string and the allow types is: png_sticker, tgs_sticker, webm_sticker')); - } - - try { - const sendData = this._formatSendData(stickerType, sticker, fileOptions); - opts.formData = sendData[0]; - opts.qs[stickerType] = sendData[1]; - } catch (ex) { - return Promise.reject(ex); - } - return this._request('addStickerToSet', opts); - } - - /** - * Use this method to move a sticker in a set created by the bot to a specific position. - * - * @param {String} sticker File identifier of the sticker - * @param {Number} position New sticker position in the set, zero-based - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#setstickerpositioninset - */ - setStickerPositionInSet(sticker, position, form = {}) { - form.sticker = sticker; - form.position = position; - return this._request('setStickerPositionInSet', { form }); - } - - /** - * Use this method to delete a sticker from a set created by the bot. - * - * @param {String} sticker File identifier of the sticker - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#deletestickerfromset - * @todo Add tests for this method! - */ - deleteStickerFromSet(sticker, form = {}) { - form.sticker = sticker; - return this._request('deleteStickerFromSet', { form }); - } - - /** - * Use this method to replace an existing sticker in a sticker set with a new one - * - * @param {Number} userId User identifier of the sticker set owner - * @param {String} name Sticker set name - * @param {String} sticker File identifier of the sticker - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#replacestickerinset - * @todo Add tests for this method! - */ - replaceStickerInSet(userId, name, oldSticker, form = {}) { - form.user_id = userId; - form.name = name; - form.old_sticker = oldSticker; - return this._request('replaceStickerInSet', { form }); - } - - - /** - * Use this method to change the list of emoji assigned to a regular or custom emoji sticker. - * - * The sticker must belong to a sticker set created by the bot. - * - * @param {String} sticker File identifier of the sticker - * @param { Array } emojiList A JSON-serialized list of 1-20 emoji associated with the sticker - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#setstickeremojilist - */ - setStickerEmojiList(sticker, emojiList, form = {}) { - form.sticker = sticker; - form.emoji_list = stringify(emojiList); - return this._request('setStickerEmojiList', { form }); - } - - /** - * Use this method to change the list of emoji assigned to a `regular` or `custom emoji` sticker. - * - * The sticker must belong to a sticker set created by the bot. - * - * @param {String} sticker File identifier of the sticker - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#setstickerkeywords - */ - setStickerKeywords(sticker, form = {}) { - form.sticker = sticker; - if (form.keywords) { - form.keywords = stringify(form.keywords); - } - return this._request('setStickerKeywords', { form }); - } - - /** - * Use this method to change the [mask position](https://core.telegram.org/bots/api#maskposition) of a mask sticker. - * - * The sticker must belong to a sticker set created by the bot. - * - * @param {String} sticker File identifier of the sticker - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#setstickermaskposition - */ - setStickerMaskPosition(sticker, form = {}) { - form.sticker = sticker; - if (form.mask_position) { - form.mask_position = stringify(form.mask_position); - } - return this._request('setStickerMaskPosition', { form }); - } - - /** - * Use this method to set the title of a created sticker set. - * - * The sticker must belong to a sticker set created by the bot. - * - * @param {String} name Sticker set name - * @param {String} title Sticker set title, 1-64 characters - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#setstickersettitle - */ - setStickerSetTitle(name, title, form = {}) { - form.name = name; - form.title = title; - return this._request('setStickerSetTitle', { form }); - } - - /** - * Use this method to add a thumb to a set created by the bot. - * - * Animated thumbnails can be set for animated sticker sets only. Video thumbnails can be set only for video sticker sets only - * - * @param {Number} userId User identifier of sticker set owner - * @param {String} name Sticker set name - * @param {String|stream.Stream|Buffer} thumbnail A .WEBP or .PNG image with the thumbnail, - * must be up to 128 kilobytes in size and have width and height exactly 100px, - * a TGS animation with the thumbnail up to 32 kilobytes in size or a WEBM video with the thumbnail up to 32 kilobytes in size. - * - * Pass a file_id as a String to send a file that already exists on the Telegram servers, pass an HTTP URL as a String for Telegram - * to get a file from the Internet, or upload a new one. Animated sticker set thumbnails can't be uploaded via HTTP URL. - * @param {Object} [options] Additional Telegram query options - * @param {Object} [fileOptions] Optional file related meta-data - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#setstickersetthumbnail - */ - setStickerSetThumbnail(userId, name, thumbnail, options = {}, fileOptions = {}) { - const opts = { - qs: options, - }; - opts.qs.user_id = userId; - opts.qs.name = name; - opts.qs.mask_position = stringify(options.mask_position); - try { - const sendData = this._formatSendData('thumbnail', thumbnail, fileOptions); - opts.formData = sendData[0]; - opts.qs.thumbnail = sendData[1]; - } catch (ex) { - return Promise.reject(ex); - } - return this._request('setStickerSetThumbnail', opts); - } - - - /** - * Use this method to set the thumbnail of a custom emoji sticker set. - * - * The sticker must belong to a sticker set created by the bot. - * - * @param {String} name Sticker set name - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail - */ - setCustomEmojiStickerSetThumbnail(name, form = {}) { - form.name = name; - return this._request('setCustomEmojiStickerSetThumbnail', { form }); - } - - /** - * Use this method to delete a sticker set that was created by the bot. - * - * The sticker must belong to a sticker set created by the bot. - * - * @param {String} name Sticker set name - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#deletestickerset - */ - deleteStickerSet(name, form = {}) { - form.name = name; - return this._request('deleteStickerSet', { form }); - } - - /** - * Send answers to an inline query. - * - * Note: No more than 50 results per query are allowed. - * - * @param {String} inlineQueryId Unique identifier of the query - * @param {InlineQueryResult[]} results An array of results for the inline query - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, True is returned - * @see https://core.telegram.org/bots/api#answerinlinequery - */ - answerInlineQuery(inlineQueryId, results, form = {}) { - form.inline_query_id = inlineQueryId; - form.results = stringify(results); - return this._request('answerInlineQuery', { form }); - } - - /** - * Use this method to set the result of an interaction with a [Web App](https://core.telegram.org/bots/webapps) - * and send a corresponding message on behalf of the user to the chat from which the query originated. - * - * @param {String} webAppQueryId Unique identifier for the query to be answered - * @param {InlineQueryResult} result object that represents one result of an inline query - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, a [SentWebAppMessage](https://core.telegram.org/bots/api#sentwebappmessage) object is returned - * @see https://core.telegram.org/bots/api#answerwebappquery - */ - answerWebAppQuery(webAppQueryId, result, form = {}) { - form.web_app_query_id = webAppQueryId; - form.result = stringify(result); - return this._request('answerWebAppQuery', { form }); - } - - /** - * Use this method to send an invoice. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {String} title Product name, 1-32 characters - * @param {String} description Product description, 1-255 characters - * @param {String} payload Bot defined invoice payload, 1-128 bytes. This will not be displayed to the user, use for your internal processes. - * @param {String} providerToken Payments provider token, obtained via `@BotFather` - * @param {String} currency Three-letter ISO 4217 currency code - * @param {Array} prices Breakdown of prices - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, the sent [Message](https://core.telegram.org/bots/api#message) is returned - * @see https://core.telegram.org/bots/api#sendinvoice - */ - sendInvoice(chatId, title, description, payload, providerToken, currency, prices, form = {}) { - form.chat_id = chatId; - form.title = title; - form.description = description; - form.payload = payload; - form.provider_token = providerToken; - form.currency = currency; - form.prices = stringify(prices); - form.provider_data = stringify(form.provider_data); - if (form.suggested_tip_amounts) { - form.suggested_tip_amounts = stringify(form.suggested_tip_amounts); - } - return this._request('sendInvoice', { form }); - } - - /** - * Use this method to create a link for an invoice. - * - * @param {String} title Product name, 1-32 characters - * @param {String} description Product description, 1-255 characters - * @param {String} payload Bot defined invoice payload - * @param {String} providerToken Payment provider token - * @param {String} currency Three-letter ISO 4217 currency code - * @param {Array} prices Breakdown of prices - * @param {Object} [options] Additional Telegram query options - * @returns {Promise} The created invoice link as String on success. - * @see https://core.telegram.org/bots/api#createinvoicelink - */ - createInvoiceLink(title, description, payload, providerToken, currency, prices, form = {}) { - form.title = title; - form.description = description; - form.payload = payload; - form.provider_token = providerToken; - form.currency = currency; - form.prices = stringify(prices); - return this._request('createInvoiceLink', { form }); - } - - /** - * Use this method to reply to shipping queries. - * - * If you sent an invoice requesting a shipping address and the parameter is_flexible was specified, - * the Bot API will send an [Update](https://core.telegram.org/bots/api#update) with a shipping_query field to the bot - * - * @param {String} shippingQueryId Unique identifier for the query to be answered - * @param {Boolean} ok Specify if delivery of the product is possible - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, True is returned - * @see https://core.telegram.org/bots/api#answershippingquery - */ - answerShippingQuery(shippingQueryId, ok, form = {}) { - form.shipping_query_id = shippingQueryId; - form.ok = ok; - form.shipping_options = stringify(form.shipping_options); - return this._request('answerShippingQuery', { form }); - } - - /** - * Use this method to respond to such pre-checkout queries - * - * Once the user has confirmed their payment and shipping details, the Bot API sends the final confirmation in the form of - * an [Update](https://core.telegram.org/bots/api#update) with the field *pre_checkout_query*. - * - * **Note:** The Bot API must receive an answer within 10 seconds after the pre-checkout query was sent. - * - * @param {String} preCheckoutQueryId Unique identifier for the query to be answered - * @param {Boolean} ok Specify if every order details are ok - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, True is returned - * @see https://core.telegram.org/bots/api#answerprecheckoutquery - */ - answerPreCheckoutQuery(preCheckoutQueryId, ok, form = {}) { - form.pre_checkout_query_id = preCheckoutQueryId; - form.ok = ok; - return this._request('answerPreCheckoutQuery', { form }); - } - - /** - * Use this method to get the current Telegram Stars balance of the bot. - * - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns a [StarAmount](https://core.telegram.org/bots/api#staramount) object - * @see https://core.telegram.org/bots/api#getmystarbalance - */ - getMyStarBalance(form = {}) { - return this._request('getMyStarBalance', { form }); - } - - /** - * Use this method for get the bot's Telegram Star transactions in chronological order - * - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns a [StarTransactions](https://core.telegram.org/bots/api#startransactions) object - * @see https://core.telegram.org/bots/api#getstartransactions - */ - getStarTransactions(form = {}) { - return this._request('getStarTransactions', { form }); - } - - /** - * Use this method for refund a successful payment in [Telegram Stars](https://t.me/BotNews/90) - * - * @param {Number} userId Unique identifier of the user whose payment will be refunded - * @param {String} telegramPaymentChargeId Telegram payment identifier - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, True is returned - * @see https://core.telegram.org/bots/api#refundstarpayment - */ - refundStarPayment(userId, telegramPaymentChargeId, form = {}) { - form.user_id = userId; - form.telegram_payment_charge_id = telegramPaymentChargeId; - return this._request('refundStarPayment', { form }); - } - - /** - * Allows the bot to cancel or re-enable extension of a subscription paid in Telegram Stars. - * - * @param {Number} userId Unique identifier of the user whose subscription will be canceled or re-enabled - * @param {String} telegramPaymentChargeId Telegram payment identifier for the subscription - * @param {Boolean} isCanceled True, if the subscription should be canceled, False, if it should be re-enabled - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, True is returned - * @see https://core.telegram.org/bots/api#cancelrenewsubscription - */ - editUserStarSubscription(userId, telegramPaymentChargeId, isCanceled, form = {}) { - form.user_id = userId; - form.telegram_payment_charge_id = telegramPaymentChargeId; - form.is_canceled = isCanceled; - return this._request('editUserStarSubscription', { form }); - } - - /** - * Use this method to send a game. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`) - * @param {String} gameShortName name of the game to be sent. Set up your games via `@BotFather`. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, the sent [Message](https://core.telegram.org/bots/api#message) is returned - * @see https://core.telegram.org/bots/api#sendgame - */ - sendGame(chatId, gameShortName, form = {}) { - form.chat_id = chatId; - form.game_short_name = gameShortName; - return this._request('sendGame', { form }); - } - - /** - * Use this method to set the score of the specified user in a game message. - * - * @param {Number} userId Unique identifier of the target user - * @param {Number} score New score value, must be non-negative - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, if the message is not an inline message, the [Message](https://core.telegram.org/bots/api#message) is returned, otherwise True is returned - * @see https://core.telegram.org/bots/api#setgamescore - */ - setGameScore(userId, score, form = {}) { - form.user_id = userId; - form.score = score; - return this._request('setGameScore', { form }); - } - - /** - * Use this method to get data for high score tables. - * - * Will return the score of the specified user and several of their neighbors in a game. - * - * @param {Number} userId Unique identifier of the target user - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns an Array of [GameHighScore](https://core.telegram.org/bots/api#gamehighscore) objects - * @see https://core.telegram.org/bots/api#getgamehighscores - */ - getGameHighScores(userId, form = {}) { - form.user_id = userId; - return this._request('getGameHighScores', { form }); - } - - - /** - * Use this method to delete a message, including service messages, with the following limitations: - * - A message can only be deleted if it was sent less than 48 hours ago. - * - A dice message can only be deleted if it was sent more than 24 hours ago. - * - Bots can delete outgoing messages in groups and supergroups. - * - Bots can delete incoming messages in groups, supergroups and channels. - * - Bots granted `can_post_messages` permissions can delete outgoing messages in channels. - * - If the bot is an administrator of a group, it can delete any message there. - * - If the bot has `can_delete_messages` permission in a supergroup, it can delete any message there. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format @channelusername) - * @param {Number} messageId Unique identifier of the target message - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#deletemessage - */ - deleteMessage(chatId, messageId, form = {}) { - form.chat_id = chatId; - form.message_id = messageId; - return this._request('deleteMessage', { form }); - } - - /** - * Use this method to delete multiple messages simultaneously. If some of the specified messages can't be found, they are skipped. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format @channelusername) - * @param {Array} messageIds Identifiers of 1-100 messages to delete. See deleteMessage for limitations on which messages can be deleted - * @param {Object} [options] Additional Telegram query options - * @return {Promise} True on success - * @see https://core.telegram.org/bots/api#deletemessages - */ - deleteMessages(chatId, messageIds, form = {}) { - form.chat_id = chatId; - form.message_ids = stringify(messageIds); - return this._request('deleteMessages', { form }); - } - - /** - * Use this method to returns the list of gifts that can be sent by the bot to users and channel chats. - * - * @param {Object} [options] Additional Telegram query options. - * @return {Promise} On success, returns a [Gifts](https://core.telegram.org/bots/api#gifts) objects. - * @see https://core.telegram.org/bots/api#getavailablegifts - */ - getAvailableGifts(form = {}) { - return this._request('getAvailableGifts', { form }); - } - - /** - * Use this method to sends a gift to the given user or channel chat. - * - * @param {String} giftId Unique identifier of the gift - * @param {Object} [options] Additional Telegram query options. - * @return {Promise} On success, returns true. - * @see https://core.telegram.org/bots/api#getavailablegifts - */ - sendGift(giftId, form = {}) { - form.gift_id = giftId; - return this._request('sendGift', { form }); - } - - /** - * Use this method to sends a gift to the given user or channel chat. - * - * @param {Number} userId Unique identifier of the target user who will receive a Telegram Premium subscription. - * @param {Number} monthCount Number of months the Telegram Premium subscription will be active for the user; must be one of 3, 6, or 12. - * @param {String} starCount Number of Telegram Stars to pay for the Telegram Premium subscription; must be 1000 for 3 months, 1500 for 6 months, and 2500 for 12 months. - * @param {Object} [options] Additional Telegram query options. - * @return {Promise} On success, returns true. - * @see https://core.telegram.org/bots/api#getavailablegifts - */ - giftPremiumSubscription(userId, monthCount, starCount, form = {}) { - form.user_id = userId; - form.month_count = monthCount; - form.star_count = starCount; - return this._request('giftPremiumSubscription', { form }); - } - - /** - * This method verifies a user [on behalf of the organization](https://telegram.org/verify#third-party-verification) which is represented by the bot. - * - * @param {Number} userId Unique identifier of the target user. - * @param {Object} [options] Additional Telegram query options. - * @return {Promise} On success, returns true. - * @see https://core.telegram.org/bots/api#verifyuser - */ - verifyUser(userId, form = {}) { - form.user_id = userId; - return this._request('verifyUser', { form }); - } - - /** - * This method verifies a chat [on behalf of the organization](https://telegram.org/verify#third-party-verification) which is represented by the bot. - * - * @param {Number} chatId Unique identifier of the target chat. - * @return {Promise} On success, returns true. - * @param {Object} [options] Additional Telegram query options. - * @see https://core.telegram.org/bots/api#verifychat - */ - verifyChat(chatId, form = {}) { - form.chat_id = chatId; - return this._request('verifyChat', { form }); - } - - /** - * This method removes verification from a user who is currently verified [on behalf of the organization](https://telegram.org/verify#third-party-verification) which is represented by the bot. - * - * @param {Number} userId Unique identifier of the target user - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns true. - * @see https://core.telegram.org/bots/api#removeuserverification - */ - removeUserVerification(userId, form = {}) { - form.user_id = userId; - return this._request('removeUserVerification', { form }); - } - - /** - * This method removes verification from a chat who is currently verified [on behalf of the organization](https://telegram.org/verify#third-party-verification) which is represented by the bot. - * - * @param {Number} chatId Unique identifier of the target chat. - * @param {Object} [options] Additional Telegram query options. - * @return {Promise} On success, returns true. - * @see https://core.telegram.org/bots/api#removechatverification - */ - removeChatVerification(chatId, form = {}) { - form.chat_id = chatId; - return this._request('removeChatVerification', { form }); - } - - /** - * This method marks incoming message as read on behalf of a business account. - * - * Requires the **can_read_messages** business bot right - * - * @param {String} businessConnectionId Unique identifier of the business connection on behalf of which to read the message. - * @param {Number} chatId Unique identifier of the chat in which the message was received. The chat must have been active in the last 24 hours. - * @param {Number} messageId Unique identifier of the message to mark as read. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns true. - * @see https://core.telegram.org/bots/api#readbusinessmessage - */ - readBusinessMessage(businessConnectionId, chatId, messageId, form = {}) { - form.business_connection_id = businessConnectionId; - form.chat_id = chatId; - form.message_id = messageId; - return this._request('readBusinessMessage', { form }); - } - - /** - * This method delete messages on behalf of a business account. - * - * Requires the **can_delete_outgoing_messages** business bot right to delete messages sent by the bot itself, or the **can_delete_all_messages business** bot right to delete any message. - * - * @param {String} businessConnectionId Unique identifier of the business connection on behalf of which to delete the message. - * @param {Number[]} messageIds List of 1-100 identifiers of messages to delete. All messages **must be from the same chat**. - * @param {Object} [options] Additional Telegram query options. - * @return {Promise} On success, returns true. - * @see https://core.telegram.org/bots/api#deletebusinessmessages - */ - deleteBusinessMessages(businessConnectionId, messageIds, form = {}) { - form.business_connection_id = businessConnectionId; - form.message_ids = stringify(messageIds); - return this._request('deleteBusinessMessages', { form }); - } - - /** - * This method changes the first and last name of a managed business account. - * - * Requires the **can_change_name** business bot right. - * - * @param {String} businessConnectionId Unique identifier of the business connection. - * @param {String} firstName The new value of the first name for the business account; 1-64 characters. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns true. - * @see https://core.telegram.org/bots/api#setbusinessaccountname - */ - setBusinessAccountName(businessConnectionId, firstName, form = {}) { - form.business_connection_id = businessConnectionId; - form.first_name = firstName; - return this._request('setBusinessAccountName', { form }); - } - - /** - * This method changes the username of a managed business account. - * - * Requires the **can_change_username** business bot right. - * - * @param {String} businessConnectionId Unique identifier of the business connection. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns true. - * @see https://core.telegram.org/bots/api#setbusinessaccountusername - */ - setBusinessAccountUsername(businessConnectionId, form = {}) { - form.business_connection_id = businessConnectionId; - return this._request('setBusinessAccountUsername', { form }); - } - - /** - * This method changes the bio of a managed business account. - * - * Requires the **can_change_bio** business bot right. - * - * @param {String} businessConnectionId Unique identifier of the business connection. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns true. - * @see https://core.telegram.org/bots/api#setbusinessaccountbio - */ - setBusinessAccountBio(businessConnectionId, form = {}) { - form.business_connection_id = businessConnectionId; - return this._request('setBusinessAccountBio', { form }); - } - - /** - * This method changes the profile photo of a managed business account. - * - * Requires the **can_edit_profile_photo** business bot right. - * - * @param {String} businessConnectionId Unique identifier of the business connection. - * @param {String|stream.Stream|Buffer} photo New profile photo. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns true. - * @see https://core.telegram.org/bots/api#setbusinessaccountprofilephoto - */ - setBusinessAccountProfilePhoto(businessConnectionId, photo, options = {}) { - const opts = { - qs: options, - }; - - opts.qs.business_connection_id = businessConnectionId; - - try { - const sendData = this._formatSendData('photo', photo); - opts.formData = sendData[0]; - opts.qs.photo = sendData[1]; - } catch (ex) { - return Promise.reject(ex); - } - - return this._request('setBusinessAccountProfilePhoto', opts); - } - - /** - * This method removes the current profile photo of a managed business account. - * - * Requires the **can_edit_profile_photo** business bot right. - * - * @param {String} businessConnectionId Unique identifier of the business connection. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns true. - * @see https://core.telegram.org/bots/api#removebusinessaccountprofilephoto - */ - removeBusinessAccountProfilePhoto(businessConnectionId, form = {}) { - form.business_connection_id = businessConnectionId; - return this._request('removeBusinessAccountProfilePhoto', { form }); - } - - /** - * This method changes the privacy settings pertaining to incoming gifts in a managed business account. - * - * Requires the **can_change_gift_settings** business bot right. - * - * @param {String} businessConnectionId Unique identifier of the business connection. - * @param {Boolean} showGiftButton Pass True, if a button for sending a gift to the user or by the business account must always be shown in the input field. - * @param {Object} acceptedGiftTypes Types of gifts accepted by the business account. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns true. - * @see https://core.telegram.org/bots/api#setbusinessaccountgiftsettings - */ - setBusinessAccountGiftSettings(businessConnectionId, showGiftButton, acceptedGiftTypes, form = {}) { - form.business_connection_id = businessConnectionId; - form.show_gift_button = showGiftButton; - form.accepted_gift_types = acceptedGiftTypes; - return this._request('setBusinessAccountGiftSettings', { form }); - } - - /** - * This method returns the amount of Telegram Stars owned by a managed business account. - * - * Requires the **can_view_gifts_and_stars** business bot right. - * - * @param {String} businessConnectionId Unique identifier of the business connection. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns [StarAmount](https://core.telegram.org/bots/api#staramount). - * @see https://core.telegram.org/bots/api#getbusinessaccountstarbalance - */ - getBusinessAccountStarBalance(businessConnectionId, form = {}) { - form.business_connection_id = businessConnectionId; - return this._request('getBusinessAccountStarBalance', { form }); - } - - /** - * This method transfers Telegram Stars from the business account balance to the bot's balance. - * - * Requires the **can_transfer_stars** business bot right. - * - * @param {String} businessConnectionId Unique identifier of the business connection. - * @param {Number} starCount Number of Telegram Stars to transfer; 1-10000. - * @param {Object} [options] Additional Telegram query options. - * @return {Promise} On success, returns True. - * @see https://core.telegram.org/bots/api#transferbusinessaccountstars - */ - transferBusinessAccountStars(businessConnectionId, startCount, form = {}) { - form.business_connection_id = businessConnectionId; - form.star_count = startCount; - return this._request('transferBusinessAccountStars', { form }); - } - - /** - * This method returns the gifts received and owned by a managed business account. - * - * Requires the **can_view_gifts_and_stars** business bot right. - * - * @param {String} businessConnectionId Unique identifier of the business connection. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns [OwnedGifts](https://core.telegram.org/bots/api#ownedgifts). - * @see https://core.telegram.org/bots/api#getbusinessaccountgifts - */ - getBusinessAccountGifts(businessConnectionId, form = {}) { - form.business_connection_id = businessConnectionId; - return this._request('getBusinessAccountGifts', { form }); - } - - /** - * Use this method to get gifts owned by a regular user. - * - * @param {Number} userId Unique identifier of the target user. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns an [OwnedGifts](https://core.telegram.org/bots/api#ownedgifts) object. - * @see https://core.telegram.org/bots/api#getusergifts - */ - getUserGifts(userId, form = {}) { - form.user_id = userId; - return this._request('getUserGifts', { form }); - } - - /** - * Use this method to get gifts received by a channel chat or a business account managed by the bot. - * - * Requires the **can_view_gifts_and_stars** administrator right if the chat is a channel. - * - * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`). - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns an [OwnedGifts](https://core.telegram.org/bots/api#ownedgifts) object. - * @see https://core.telegram.org/bots/api#getchatgifts - */ - getChatGifts(chatId, form = {}) { - form.chat_id = chatId; - return this._request('getChatGifts', { form }); - } - - /** - * This method converts a given regular gift to Telegram Stars. - * - * Requires the **can_convert_gifts_to_stars** business bot right. - * - * @param {String} businessConnectionId Unique identifier of the business connection. - * @param {String} ownedGiftId Unique identifier of the regular gift that should be converted to Telegram Stars. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns True. - * @see https://core.telegram.org/bots/api#convertgifttostars - */ - convertGiftToStars(businessConnectionId, ownedGiftId, form = {}) { - form.business_connection_id = businessConnectionId; - form.owned_gift_id = ownedGiftId; - return this._request('convertGiftToStars', { form }); - } - - /** - * This method upgrades a given regular gift to a unique gift. - * - * Requires the **can_transfer_and_upgrade_gifts** business bot right. - * Additionally requires the **can_transfer_stars** business bot right **if the upgrade is paid**. - * - * @param {String} businessConnectionId Unique identifier of the business connection. - * @param {String} ownedGiftId Unique identifier of the regular gift that should be upgraded to a unique one. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns True. - * @see https://core.telegram.org/bots/api#upgradegift - */ - upgradeGift(businessConnectionId, ownedGiftId, form = {}) { - form.business_connection_id = businessConnectionId; - form.owned_gift_id = ownedGiftId; - return this._request('upgradeGift', { form }); - } - - /** - * This method transfers an owned unique gift to another user. - * - * Requires the **can_transfer_and_upgrade_gifts** business bot right. - * Additionally requires the **can_transfer_stars** business bot right **if the transfer is paid**. - * - * @param {String} businessConnectionId Unique identifier of the business connection. - * @param {String} ownedGiftId Unique identifier of the regular gift that should be transferred. - * @param {Number} newOwnerChatId Unique identifier of the chat which will own the gift. The chat **must be active in the last 24 hours**. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns True. - * @see https://core.telegram.org/bots/api#transfergift - */ - transferGift(businessConnectionId, ownedGiftId, newOwnerChatId, form = {}) { - form.business_connection_id = businessConnectionId; - form.owned_gift_id = ownedGiftId; - form.new_owner_chat_id = newOwnerChatId; - return this._request('transferGift', { form }); - } - - /** - * This method posts a story on behalf of a managed business account. - * - * Requires the **can_manage_stories** business bot right. - * - * @param {String} businessConnectionId Unique identifier of the business connection. - * @param {Array} content [InputStoryContent](https://core.telegram.org/bots/api#inputpaidmedia). The photo/video property can be String, Stream or Buffer. - * @param {Number} activePeriod Unique identifier of the chat which will own the gift. The chat **must be active in the last 24 hours**. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns [Story](https://core.telegram.org/bots/api#story). - * @see https://core.telegram.org/bots/api#poststory - */ - postStory(businessConnectionId, content, activePeriod, options = {}) { - const opts = { - qs: options, - }; - - opts.qs.business_connection_id = businessConnectionId; - opts.qs.active_period = activePeriod; - - try { - const inputHistoryContent = content; - opts.formData = {}; - - if (!content.type) { - return Promise.reject(new Error('content.type is required')); - } - - const { formData, fileIds } = this._formatSendMultipleData(content.type, [content]); - - opts.formData = formData; - - if (fileIds[0]) { - inputHistoryContent[content.type] = fileIds[0]; - } else { - inputHistoryContent[content.type] = `attach://${content.type}_0`; - } - - opts.qs.content = stringify(inputHistoryContent); - } catch (ex) { - return Promise.reject(ex); - } - - return this._request('postStory', opts); - } - - /** - * This method reposts a story on behalf of a managed business account. - * - * Requires the **can_manage_stories** business bot right for both the source and destination accounts. - * The story must have been originally posted or reposted by the bot itself. - * - * @param {String} businessConnectionId Unique identifier of the business connection of the account that will repost the story. - * @param {Number} fromChatId Unique identifier of the chat that originally posted the story. - * @param {Number} fromStoryId Unique identifier of the story to repost. - * @param {Number} activePeriod The period after which the story is moved to archive, in seconds; must be one of 21600, 43200, 86400, or 172800. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns a [Story](https://core.telegram.org/bots/api#story) object. - * @see https://core.telegram.org/bots/api#repoststory - */ - repostStory(businessConnectionId, fromChatId, fromStoryId, activePeriod, form = {}) { - form.business_connection_id = businessConnectionId; - form.from_chat_id = fromChatId; - form.from_story_id = fromStoryId; - form.active_period = activePeriod; - return this._request('repostStory', { form }); - } - - /** - * This method edits a story previously posted by the bot on behalf of a managed business account. - * - * Requires the **can_manage_stories** business bot right. - * - * @param {String} businessConnectionId Unique identifier of the business connection. - * @param {Number} storyId Unique identifier of the story to edit. - * @param {Array} content [InputStoryContent](https://core.telegram.org/bots/api#inputpaidmedia). The photo/video property can be String, Stream or Buffer. - * @param {Object} [options] Additional Telegram query options - * @return {Promise} On success, returns [Story](https://core.telegram.org/bots/api#story). - * @see https://core.telegram.org/bots/api#editstory - */ - editStory(businessConnectionId, storyId, content, options = {}) { - const opts = { - qs: options, - }; - - opts.qs.business_connection_id = businessConnectionId; - opts.qs.story_id = storyId; - - try { - const inputHistoryContent = content; - opts.formData = {}; - - if (!content.type) { - return Promise.reject(new Error('content.type is required')); - } - - const { formData, fileIds } = this._formatSendMultipleData(content.type, [content]); - - opts.formData = formData; - - if (fileIds[0]) { - inputHistoryContent[content.type] = fileIds[0]; - } else { - inputHistoryContent[content.type] = `attach://${content.type}_0`; - } - - opts.qs.content = stringify(inputHistoryContent); - } catch (ex) { - return Promise.reject(ex); - } - - return this._request('editStory', opts); - } - - - /** - * This method deletes a story previously posted by the bot on behalf of a managed business account. - * - * Requires the **can_manage_stories** business bot right. - * - * @param {String} businessConnectionId Unique identifier of the business connection. - * @param {Number} storyId Unique identifier of the story to delete. - * @param {Object} [options] Additional Telegram query options. - * @return {Promise} On success, returns True. - * @see https://core.telegram.org/bots/api#deletestory - */ - deleteStory(businessConnectionId, storyId, form = {}) { - form.business_connection_id = businessConnectionId; - form.story_id = storyId; - return this._request('deleteStory', { form }); - } - -} - -module.exports = TelegramBot; diff --git a/src/telegram.ts b/src/telegram.ts new file mode 100644 index 00000000..4b9c19a3 --- /dev/null +++ b/src/telegram.ts @@ -0,0 +1,2063 @@ +import createDebug from "./internal/debug.js"; +import { EventEmitter } from "node:events"; +import { createWriteStream, type WriteStream } from "node:fs"; +import path from "node:path"; +import { Readable, PassThrough } from "node:stream"; +import { pipeline } from "node:stream/promises"; + +import { FatalError } from "./errors.js"; +import { HttpClient, type HttpClientOptions, type RequestOptions } from "./http.js"; +import { TelegramBotPolling, type PollingOptions, type PollingStartOptions, type PollingStopOptions } from "./polling.js"; +import { TelegramBotWebHook, type WebHookOptions } from "./webhook.js"; +import { prepareFile, prepareFiles, stringify, type FileInput, type FileMeta, type PreparedFile } from "./utils.js"; +import { + MESSAGE_TYPES, + type ChatId, + type MessageType, + type Update, + type User, + type Message, + type MessageId, + type WebhookInfo, + type Chat, + type ChatMember, + type ChatInviteLink, + type ForumTopic, + type UserProfilePhotos, + type File as TelegramFile, + type Sticker, + type StickerSet, + type Poll, + type BotCommand, + type ChatJoinRequest, + type InputProfilePhoto, + type SentGuestMessage, + type BotAccessSettings, + type ReplyParameters, + type MessageEntity, + type LinkPreviewOptions, + type ParseMode, + type ReactionType, + type MaskPosition, + type InlineKeyboardMarkup, +} from "./types/schemas.js"; + +import type { + GetUpdatesOptions, + SetWebHookOptions, + SendMessageOptions, + ForwardMessageOptions, + ForwardMessagesOptions, + CopyMessageOptions, + CopyMessagesOptions, + SendPhotoOptions, + SendLivePhotoOptions, + SendPaidMediaOptions, + SendMediaGroupOptions, + SendAudioOptions, + SendDocumentOptions, + SendVideoOptions, + SendAnimationOptions, + SendVoiceOptions, + SendVideoNoteOptions, + SendLocationOptions, + SendVenueOptions, + SendContactOptions, + SendPollOptions, + SendDiceOptions, + SendChatActionOptions, + AnswerCallbackQueryOptions, + AnswerInlineQueryOptions, + SendInvoiceOptions, + ReplyMarkup, + SuggestedPostParameters, + InputPollOption, +} from "./types/options.js"; + +import * as errors from "./errors.js"; + +const debug = createDebug("node-telegram-bot-api"); + +export interface TelegramBotOptions { + /** Enable polling. `true` uses defaults; an object passes options through to `TelegramBotPolling`. */ + polling?: boolean | PollingOptions; + /** Enable webhook. `true` uses defaults; an object passes options through to `TelegramBotWebHook`. */ + webHook?: boolean | WebHookOptions; + /** Telegram API base URL — useful for proxying or testing against a mock server. */ + baseApiUrl?: string; + /** Use Telegram's test environment (`/bot/test/...`). */ + testEnvironment?: boolean; + /** When true, treat string file arguments that resolve to existing paths as filesystem files. */ + filepath?: boolean; + /** Additional HTTP request defaults (timeouts, headers). */ + request?: HttpClientOptions["request"]; + /** Stop processing further regex listeners after the first match. */ + onlyFirstMatch?: boolean; + /** Forward-compat flag: see `TelegramBotPolling._poll()` for details. */ + badRejection?: boolean; +} + +interface TextRegexpEntry { + regexp: RegExp; + callback: (msg: Message, match: RegExpExecArray | null) => void; +} + +interface ReplyListenerEntry { + id: number; + chatId: ChatId; + messageId: number; + callback: (msg: Message) => void; +} + +const _deprecatedMessageTypes = ["new_chat_participant", "left_chat_participant"]; + +/** + * The TelegramBot class is the main entry point of the library. It provides + * methods that map 1:1 to the Telegram Bot API and emits events for incoming + * updates received via either long polling or a webhook server. + * + * @example + * ```ts + * const bot = new TelegramBot(token, { polling: true }); + * bot.on('message', msg => bot.sendMessage(msg.chat.id, 'echo: ' + msg.text)); + * ``` + */ +export class TelegramBot extends EventEmitter { + /** Static reference to the error classes the library throws. */ + static readonly errors = errors; + + /** The set of message-type events the library understands. */ + static readonly messageTypes: readonly MessageType[] = MESSAGE_TYPES; + + /** The Telegram Bot API token. */ + public readonly token: string; + /** The bot configuration as supplied at construction time. */ + public readonly options: TelegramBotOptions; + /** Underlying HTTP client. Accessible for advanced extensions. */ + public readonly http: HttpClient; + + private _polling: TelegramBotPolling | null = null; + private _webHook: TelegramBotWebHook | null = null; + private _textRegexpCallbacks: TextRegexpEntry[] = []; + private _replyListenerId = 0; + private _replyListeners: ReplyListenerEntry[] = []; + + constructor(token: string, options: TelegramBotOptions = {}) { + super(); + this.token = token; + this.options = { + ...options, + polling: options.polling ?? false, + webHook: options.webHook ?? false, + baseApiUrl: options.baseApiUrl ?? "https://api.telegram.org", + filepath: options.filepath ?? true, + badRejection: options.badRejection ?? false, + }; + + this.http = new HttpClient(token, { + baseApiUrl: this.options.baseApiUrl, + testEnvironment: this.options.testEnvironment, + request: this.options.request, + }); + + if (this.options.polling) { + const pollingOpts = typeof this.options.polling === "boolean" ? {} : this.options.polling; + const autoStart = pollingOpts.autoStart ?? true; + this._polling = new TelegramBotPolling(this, pollingOpts); + if (autoStart) void this._polling.start(); + } + + if (this.options.webHook) { + const webhookOpts = typeof this.options.webHook === "boolean" ? {} : this.options.webHook; + const autoOpen = webhookOpts.autoOpen ?? true; + this._webHook = new TelegramBotWebHook(this, webhookOpts); + if (autoOpen) void this._webHook.open(); + } + } + + override on(event: string | symbol, listener: (...args: unknown[]) => void): this { + if (_deprecatedMessageTypes.includes(event as string)) { + const url = "https://github.com/yagop/node-telegram-bot-api/blob/master/doc/usage.md#events"; + // eslint-disable-next-line no-console + console.warn(`Events ${_deprecatedMessageTypes.join(",")} are deprecated. See: ${url}`); + } + return super.on(event, listener); + } + + // --- internal helpers --------------------------------------------------- + + private _request(method: string, opts: RequestOptions = {}): Promise { + if (opts.form) { + this._fixReplyMarkup(opts.form); + this._fixEntitiesField(opts.form); + this._fixReplyParameters(opts.form); + this._fixMessageIds(opts.form); + this._fixSuggestedPostParameters(opts.form); + this._fixLinkPreviewOptions(opts.form); + } + if (opts.qs) { + this._fixReplyMarkup(opts.qs); + this._fixEntitiesField(opts.qs); + this._fixReplyParameters(opts.qs); + this._fixMessageIds(opts.qs); + this._fixSuggestedPostParameters(opts.qs); + this._fixLinkPreviewOptions(opts.qs); + this._fixStoryAreas(opts.qs); + } + return this.http.request(method, opts); + } + + private _fixReplyMarkup(obj: Record): void { + const replyMarkup = obj.reply_markup; + if (replyMarkup && typeof replyMarkup !== "string") { + obj.reply_markup = stringify(replyMarkup); + } + } + + private _fixEntitiesField(obj: Record): void { + for (const key of ["entities", "caption_entities", "explanation_entities", "description_entities", "question_entities", "title_entities", "text_entities"] as const) { + const value = obj[key]; + if (value && typeof value !== "string") obj[key] = stringify(value); + } + } + + private _fixReplyParameters(obj: Record): void { + if ( + Object.prototype.hasOwnProperty.call(obj, "reply_parameters") && + typeof obj.reply_parameters !== "string" + ) { + obj.reply_parameters = stringify(obj.reply_parameters); + } + } + + private _fixSuggestedPostParameters(obj: Record): void { + if ( + Object.prototype.hasOwnProperty.call(obj, "suggested_post_parameters") && + typeof obj.suggested_post_parameters !== "string" + ) { + obj.suggested_post_parameters = stringify(obj.suggested_post_parameters); + } + } + + private _fixLinkPreviewOptions(obj: Record): void { + const value = obj.link_preview_options; + if (value && typeof value !== "string") { + obj.link_preview_options = stringify(value); + } + } + + private _fixStoryAreas(obj: Record): void { + const value = obj.areas; + if (value && typeof value !== "string") { + obj.areas = stringify(value); + } + } + + private _fixMessageIds(obj: Record): void { + const messageIds = obj.message_ids; + if (messageIds && typeof messageIds !== "string") { + obj.message_ids = stringify(messageIds); + } + } + + /** Submit a request whose body is a flat form. Internal helper. */ + private _form(method: string, form: Record): Promise { + return this._request(method, { form }); + } + + /** + * Common pattern: a method that uploads exactly one file (sendPhoto / sendAudio / etc). + * Falls back to a string when `data` is a Telegram fileId or HTTPS URL. + */ + private async _sendFile( + method: string, + fieldName: string, + data: FileInput, + qs: Record, + fileMeta: FileMeta = {}, + extraThumbnail?: { thumbnail?: FileInput; thumb?: FileInput }, + ): Promise { + const opts: RequestOptions = { qs }; + const { file, fileId } = await prepareFile(data, fileMeta, this.options.filepath); + if (file) { + opts.formData = { [fieldName]: file }; + } else if (fileId) { + qs[fieldName] = fileId; + } + if (extraThumbnail) { + const candidate = extraThumbnail.thumbnail ?? extraThumbnail.thumb; + if (candidate) { + const { file: thumbFile, fileId: thumbId } = await prepareFile(candidate, {}, this.options.filepath); + if (thumbFile) { + opts.formData = opts.formData ?? {}; + opts.formData["thumbnail"] = thumbFile; + qs.thumbnail = "attach://thumbnail"; + } else if (thumbId) { + qs.thumbnail = thumbId; + } + } + } + return this._request(method, opts); + } + + // --- High-level lifecycle ---------------------------------------------- + + /** Start polling. */ + async startPolling(options: PollingStartOptions = {}): Promise { + if (this.hasOpenWebHook()) { + throw new FatalError("Polling and WebHook are mutually exclusive"); + } + if (!this._polling) { + const pollingOpts = typeof this.options.polling === "object" ? this.options.polling : {}; + this._polling = new TelegramBotPolling(this, pollingOpts); + } + return this._polling.start({ restart: options.restart ?? false }); + } + + /** Stop polling. */ + async stopPolling(options: PollingStopOptions = {}): Promise { + if (!this._polling) return; + return this._polling.stop(options); + } + + isPolling(): boolean { + return this._polling?.isPolling() ?? false; + } + + async openWebHook(): Promise { + if (this.isPolling()) throw new FatalError("WebHook and Polling are mutually exclusive"); + if (!this._webHook) { + const webhookOpts = typeof this.options.webHook === "object" ? this.options.webHook : {}; + this._webHook = new TelegramBotWebHook(this, webhookOpts); + } + return this._webHook.open(); + } + + async closeWebHook(): Promise { + if (!this._webHook) return; + return this._webHook.close(); + } + + hasOpenWebHook(): boolean { + return this._webHook?.isOpen() ?? false; + } + + // --- Reply / regexp listeners ------------------------------------------ + + onText(regexp: RegExp | string, callback: TextRegexpEntry["callback"]): void { + const compiled = regexp instanceof RegExp ? regexp : new RegExp(regexp); + this._textRegexpCallbacks.push({ regexp: compiled, callback }); + } + + removeTextListener(regexp: RegExp | string): TextRegexpEntry | null { + const index = this._textRegexpCallbacks.findIndex((listener) => String(listener.regexp) === String(regexp)); + if (index === -1) return null; + return this._textRegexpCallbacks.splice(index, 1)[0] ?? null; + } + + clearTextListeners(): void { + this._textRegexpCallbacks = []; + } + + onReplyToMessage(chatId: ChatId, messageId: number, callback: ReplyListenerEntry["callback"]): number { + const id = ++this._replyListenerId; + this._replyListeners.push({ id, chatId, messageId, callback }); + return id; + } + + removeReplyListener(replyListenerId: number): ReplyListenerEntry | null { + const index = this._replyListeners.findIndex((entry) => entry.id === replyListenerId); + if (index === -1) return null; + return this._replyListeners.splice(index, 1)[0] ?? null; + } + + clearReplyListeners(): ReplyListenerEntry[] { + const removed = this._replyListeners; + this._replyListeners = []; + return removed; + } + + // --- Update processing ------------------------------------------------- + + /** + * Dispatch a single Update. Use this if you obtain updates from a source other + * than this library's polling/webhook (e.g. AWS Lambda, custom proxy, tests). + */ + processUpdate(update: Update): void { + debug("Process Update %j", update); + const m = update.message; + if (m) { + const metadata = { type: TelegramBot.messageTypes.find((t) => (m as Record)[t]) }; + this.emit("message", m, metadata); + if (metadata.type) this.emit(metadata.type, m, metadata); + if (m.text) { + for (const reg of this._textRegexpCallbacks) { + const result = reg.regexp.exec(m.text); + if (!result) continue; + reg.regexp.lastIndex = 0; + reg.callback(m, result); + if (this.options.onlyFirstMatch) break; + } + } + if (m.reply_to_message) { + for (const reply of this._replyListeners) { + if (reply.chatId === m.chat.id && reply.messageId === m.reply_to_message.message_id) { + reply.callback(m); + } + } + } + return; + } + const direct: { key: keyof Update; event: string }[] = [ + { key: "edited_message", event: "edited_message" }, + { key: "channel_post", event: "channel_post" }, + { key: "edited_channel_post", event: "edited_channel_post" }, + { key: "business_connection", event: "business_connection" }, + { key: "business_message", event: "business_message" }, + { key: "edited_business_message", event: "edited_business_message" }, + { key: "deleted_business_messages", event: "deleted_business_messages" }, + { key: "message_reaction", event: "message_reaction" }, + { key: "message_reaction_count", event: "message_reaction_count" }, + { key: "inline_query", event: "inline_query" }, + { key: "chosen_inline_result", event: "chosen_inline_result" }, + { key: "callback_query", event: "callback_query" }, + { key: "shipping_query", event: "shipping_query" }, + { key: "pre_checkout_query", event: "pre_checkout_query" }, + { key: "purchased_paid_media", event: "purchased_paid_media" }, + { key: "poll", event: "poll" }, + { key: "poll_answer", event: "poll_answer" }, + { key: "chat_member", event: "chat_member" }, + { key: "my_chat_member", event: "my_chat_member" }, + { key: "chat_join_request", event: "chat_join_request" }, + { key: "chat_boost", event: "chat_boost" }, + { key: "removed_chat_boost", event: "removed_chat_boost" }, + ]; + for (const { key, event } of direct) { + const value = update[key]; + if (value !== undefined) { + debug("Process Update %s %j", event, value); + this.emit(event, value); + // Special-case sub-events for edited messages + if (event === "edited_message") { + const em = value as Message; + if (em.text) this.emit("edited_message_text", em); + if (em.caption) this.emit("edited_message_caption", em); + } + if (event === "edited_channel_post") { + const ep = value as Message; + if (ep.text) this.emit("edited_channel_post_text", ep); + if (ep.caption) this.emit("edited_channel_post_caption", ep); + } + return; + } + } + } + + // =================================================================== + // Telegram Bot API methods + // =================================================================== + + // --- Files & downloads ------------------------------------------------- + + /** Resolve a file id to the public download URL on Telegram's servers. */ + async getFileLink(fileId: string, options: {} = {}): Promise { + const file = await this.getFile(fileId, options); + return `${this.options.baseApiUrl}/file/bot${this.token}/${file.file_path}`; + } + + /** + * Stream the contents of a Telegram file. The returned stream emits an `info` + * event with the resolved URI before the bytes start flowing. + */ + getFileStream(fileId: string, options: {} = {}): NodeJS.ReadableStream & { path: string } { + const out = new PassThrough() as PassThrough & { path: string }; + out.path = fileId; + void (async () => { + try { + const uri = await this.getFileLink(fileId, options); + out.emit("info", { uri }); + const response = await fetch(uri); + if (!response.ok || !response.body) { + throw new FatalError(`Failed to fetch file: HTTP ${response.status}`); + } + await pipeline(Readable.fromWeb(response.body as never), out); + } catch (err) { + out.emit("error", err); + } + })(); + return out; + } + + /** + * Download a Telegram file to a local directory and resolve to the resulting path. + */ + async downloadFile(fileId: string, downloadDir: string, options: {} = {}): Promise { + const uri = await this.getFileLink(fileId, options); + const fileName = uri.slice(uri.lastIndexOf("/") + 1); + const filePath = path.join(downloadDir, fileName); + const response = await fetch(uri); + if (!response.ok || !response.body) { + throw new FatalError(`Failed to download file: HTTP ${response.status}`); + } + const out: WriteStream = createWriteStream(filePath); + await pipeline(Readable.fromWeb(response.body as never), out); + return filePath; + } + + // --- Updates / webhook ------------------------------------------------- + + getUpdates(form: GetUpdatesOptions = {}): Promise { + if (form.allowed_updates && Array.isArray(form.allowed_updates)) { + form.allowed_updates = stringify(form.allowed_updates); + } + return this._form("getUpdates", form); + } + + async setWebHook(url: string, options: SetWebHookOptions = {}, fileOptions: FileMeta = {}): Promise { + const { certificate, ...rest } = options; + const qs: Record = { ...rest, url }; + if (Array.isArray(qs.allowed_updates)) qs.allowed_updates = stringify(qs.allowed_updates); + if (certificate) { + const { file, fileId } = await prepareFile(certificate, fileOptions, this.options.filepath); + if (file) { + return this._request("setWebHook", { qs, formData: { certificate: file } }); + } + qs.certificate = fileId; + } + return this._request("setWebHook", { qs }); + } + + deleteWebHook(form: { drop_pending_updates?: boolean } = {}): Promise { + return this._form("deleteWebhook", form); + } + + getWebHookInfo(form: {} = {}): Promise { + return this._form("getWebhookInfo", form); + } + + // --- Bot identity ------------------------------------------------------ + + getMe(form: {} = {}): Promise { + return this._form("getMe", form); + } + + logOut(form: {} = {}): Promise { + return this._form("logOut", form); + } + + close(form: {} = {}): Promise { + return this._form("close", form); + } + + // --- Messages ---------------------------------------------------------- + + sendMessage(chatId: ChatId, text: string, form: SendMessageOptions = {}): Promise { + return this._form("sendMessage", { ...form, chat_id: chatId, text }); + } + + forwardMessage( + chatId: ChatId, + fromChatId: ChatId, + messageId: number, + form: ForwardMessageOptions = {}, + ): Promise { + return this._form("forwardMessage", { + ...form, + chat_id: chatId, + from_chat_id: fromChatId, + message_id: messageId, + }); + } + + forwardMessages( + chatId: ChatId, + fromChatId: ChatId, + messageIds: number[], + form: ForwardMessagesOptions = {}, + ): Promise { + return this._form("forwardMessages", { + ...form, + chat_id: chatId, + from_chat_id: fromChatId, + message_ids: messageIds, + }); + } + + copyMessage( + chatId: ChatId, + fromChatId: ChatId, + messageId: number, + form: CopyMessageOptions = {}, + ): Promise { + return this._form("copyMessage", { + ...form, + chat_id: chatId, + from_chat_id: fromChatId, + message_id: messageId, + }); + } + + copyMessages( + chatId: ChatId, + fromChatId: ChatId, + messageIds: number[], + form: CopyMessagesOptions = {}, + ): Promise { + return this._form("copyMessages", { + ...form, + chat_id: chatId, + from_chat_id: fromChatId, + message_ids: stringify(messageIds), + }); + } + + // --- Send file methods ------------------------------------------------- + + sendPhoto( + chatId: ChatId, + photo: FileInput, + options: SendPhotoOptions = {}, + fileOptions: FileMeta = {}, + ): Promise { + return this._sendFile("sendPhoto", "photo", photo, { ...options, chat_id: chatId }, fileOptions); + } + + async sendLivePhoto( + chatId: ChatId, + livePhoto: FileInput, + photo: FileInput, + options: SendLivePhotoOptions = {}, + fileOptions: FileMeta = {}, + ): Promise { + const qs: Record = { ...options, chat_id: chatId }; + const opts: RequestOptions = { qs }; + + const liveResult = await prepareFile(livePhoto, fileOptions, this.options.filepath); + if (liveResult.file) { + opts.formData = { live_photo: liveResult.file }; + qs.live_photo = "attach://live_photo"; + } else if (liveResult.fileId) { + qs.live_photo = liveResult.fileId; + } + + const photoResult = await prepareFile(photo, {}, this.options.filepath); + if (photoResult.file) { + opts.formData = opts.formData ?? {}; + opts.formData.photo = photoResult.file; + qs.photo = "attach://photo"; + } else if (photoResult.fileId) { + qs.photo = photoResult.fileId; + } + + return this._request("sendLivePhoto", opts); + } + + sendAudio( + chatId: ChatId, + audio: FileInput, + options: SendAudioOptions = {}, + fileOptions: FileMeta = {}, + ): Promise { + return this._sendFile( + "sendAudio", + "audio", + audio, + { ...options, chat_id: chatId }, + fileOptions, + { thumbnail: options.thumbnail as FileInput | undefined }, + ); + } + + sendDocument( + chatId: ChatId, + doc: FileInput, + options: SendDocumentOptions = {}, + fileOptions: FileMeta = {}, + ): Promise { + return this._sendFile( + "sendDocument", + "document", + doc, + { ...options, chat_id: chatId }, + fileOptions, + { thumbnail: options.thumbnail as FileInput | undefined }, + ); + } + + sendVideo( + chatId: ChatId, + video: FileInput, + options: SendVideoOptions = {}, + fileOptions: FileMeta = {}, + ): Promise { + return this._sendFile( + "sendVideo", + "video", + video, + { ...options, chat_id: chatId }, + fileOptions, + { thumbnail: options.thumbnail as FileInput }, + ); + } + + sendAnimation( + chatId: ChatId, + animation: FileInput, + options: SendAnimationOptions = {}, + fileOptions: FileMeta = {}, + ): Promise { + return this._sendFile( + "sendAnimation", + "animation", animation, + { ...options, chat_id: chatId }, + fileOptions, + { thumbnail: options.thumbnail as FileInput } + ); + } + + sendVoice( + chatId: ChatId, + voice: FileInput, + options: SendVoiceOptions = {}, + fileOptions: FileMeta = {}, + ): Promise { + return this._sendFile("sendVoice", "voice", voice, { ...options, chat_id: chatId }, fileOptions); + } + + sendVideoNote( + chatId: ChatId, + videoNote: FileInput, + options: SendVideoNoteOptions = {}, + fileOptions: FileMeta = {}, + ): Promise { + return this._sendFile( + "sendVideoNote", + "video_note", + videoNote, + { ...options, chat_id: chatId }, + fileOptions, + { thumbnail: options.thumbnail as FileInput | undefined }, + ); + } + + async sendPaidMedia( + chatId: ChatId, + starCount: number, + media: Array<{ type: string; media: FileInput; fileOptions?: FileMeta;[key: string]: unknown }>, + options: SendPaidMediaOptions = {}, + ): Promise { + const qs: Record = { ...options, chat_id: chatId, star_count: starCount }; + const { formData, fileIds } = await prepareFiles("media", media, {}, this.options.filepath); + const inputPaidMedia = media.map((item, index) => { + const copy: Record = { ...item }; + delete copy.fileOptions; + copy.media = fileIds[index] ?? `attach://media_${index}`; + return copy; + }); + qs.media = stringify(inputPaidMedia); + return this._request("sendPaidMedia", { qs, formData }); + } + + async sendMediaGroup( + chatId: ChatId, + media: Array<{ media: FileInput; fileOptions?: FileMeta;[key: string]: unknown }>, + options: SendMediaGroupOptions = {}, + ): Promise { + const qs: Record = { ...options, chat_id: chatId }; + const formData: Record = {}; + const inputMedia: Record[] = []; + for (let index = 0; index < media.length; index++) { + const input = media[index]!; + const payload: Record = { ...input }; + delete payload.media; + delete payload.fileOptions; + const attachName = String(index); + const { file, fileId } = await prepareFile(input.media, input.fileOptions, this.options.filepath); + if (file) { + formData[attachName] = file; + payload.media = `attach://${attachName}`; + } else { + payload.media = fileId; + } + inputMedia.push(payload); + } + qs.media = stringify(inputMedia); + return this._request("sendMediaGroup", { qs, formData }); + } + + sendLocation(chatId: ChatId, latitude: number, longitude: number, form: SendLocationOptions = {}): Promise { + return this._form("sendLocation", { ...form, chat_id: chatId, latitude, longitude }); + } + + sendVenue( + chatId: ChatId, + latitude: number, + longitude: number, + title: string, + address: string, + form: SendVenueOptions = {}, + ): Promise { + return this._form("sendVenue", { ...form, chat_id: chatId, latitude, longitude, title, address }); + } + + sendContact(chatId: ChatId, phoneNumber: string, firstName: string, form: SendContactOptions = {}): Promise { + return this._form("sendContact", { ...form, chat_id: chatId, phone_number: phoneNumber, first_name: firstName }); + } + + sendPoll(chatId: ChatId, question: string, pollOptions: InputPollOption[], form: SendPollOptions = {}): Promise { + const out: Record = { ...form, chat_id: chatId, question, options: stringify(pollOptions) }; + if (out.country_codes) out.country_codes = stringify(out.country_codes); + if (out.correct_option_ids) out.correct_option_ids = stringify(out.correct_option_ids); + return this._form("sendPoll", out); + } + + editMessageLiveLocation( + latitude: number, + longitude: number, + form: { + business_connection_id?: string; + chat_id?: ChatId; + message_id?: number; + inline_message_id?: string; + live_period?: number; + horizontal_accuracy?: number; + heading?: number; + proximity_alert_radius?: number; + reply_markup?: InlineKeyboardMarkup; + } = {}, + ): Promise { + return this._form("editMessageLiveLocation", { ...form, latitude, longitude }); + } + + stopMessageLiveLocation( + form: { + business_connection_id?: string; + chat_id?: ChatId; + message_id?: number; + inline_message_id?: string; + reply_markup?: InlineKeyboardMarkup; + } = {}, + ): Promise { + return this._form("stopMessageLiveLocation", form); + } + + + sendChecklist( + businessConnectionId: string, + chatId: ChatId, + checklist: Record, + form: { + disable_notification?: boolean; + protect_content?: boolean; + message_effect_id?: string; + reply_parameters?: ReplyParameters; + reply_markup?: InlineKeyboardMarkup; + } = {}, + ): Promise { + return this._form("sendChecklist", { + ...form, + business_connection_id: businessConnectionId, + chat_id: chatId, + checklist: stringify(checklist), + }); + } + + sendDice(chatId: ChatId, options: SendDiceOptions = {}): Promise { + return this._form("sendDice", { ...options, chat_id: chatId }); + } + + sendMessageDraft( + chatId: ChatId, + draftId: number, + text: string, + form: { + message_thread_id?: number; + parse_mode?: ParseMode; + entities?: MessageEntity[]; + } = {}, + ): Promise { + return this._form("sendMessageDraft", { ...form, chat_id: chatId, draft_id: draftId, text }); + } + + sendChatAction(chatId: ChatId, action: string, form: SendChatActionOptions = {}): Promise { + return this._form("sendChatAction", { ...form, chat_id: chatId, action }); + } + + setMessageReaction( + chatId: ChatId, + messageId: number, + form: { + reaction?: ReactionType[]; + is_big?: boolean; + } = {}, + ): Promise { + const out: Record = { ...form, chat_id: chatId, message_id: messageId }; + if (out.reaction) out.reaction = stringify(out.reaction); + return this._form("setMessageReaction", out); + } + + // --- Users ------------------------------------------------------------- + + getUserProfilePhotos( + userId: number, + form: { offset?: number; limit?: number } = {}, + ): Promise { + return this._form("getUserProfilePhotos", { ...form, user_id: userId }); + } + + getUserProfileAudios( + userId: number, + form: { offset?: number; limit?: number } = {}, + ): Promise { + return this._form("getUserProfileAudios", { ...form, user_id: userId }); + } + + setUserEmojiStatus( + userId: number, + form: { emoji_status_custom_emoji_id?: string; emoji_status_expiration_date?: number } = {}, + ): Promise { + return this._form("setUserEmojiStatus", { ...form, user_id: userId }); + } + + getFile(fileId: string, form: {} = {}): Promise { + return this._form("getFile", { ...form, file_id: fileId }); + } + + getUserPersonalChatMessages(userId: number, limit: number): Promise { + return this._form("getUserPersonalChatMessages", { user_id: userId, limit }); + } + + // --- Chat membership -------------------------------------------------- + + banChatMember( + chatId: ChatId, + userId: number, + form: { until_date?: number; revoke_messages?: boolean } = {}, + ): Promise { + return this._form("banChatMember", { ...form, chat_id: chatId, user_id: userId }); + } + unbanChatMember( + chatId: ChatId, + userId: number, + form: { only_if_banned?: boolean } = {}, + ): Promise { + return this._form("unbanChatMember", { ...form, chat_id: chatId, user_id: userId }); + } + restrictChatMember( + chatId: ChatId, + userId: number, + form: { + permissions?: Record; + use_independent_chat_permissions?: boolean; + until_date?: number; + } = {}, + ): Promise { + return this._form("restrictChatMember", { ...form, chat_id: chatId, user_id: userId }); + } + promoteChatMember( + chatId: ChatId, + userId: number, + form: { + is_anonymous?: boolean; + can_manage_chat?: boolean; + can_delete_messages?: boolean; + can_manage_video_chats?: boolean; + can_restrict_members?: boolean; + can_promote_members?: boolean; + can_change_info?: boolean; + can_invite_users?: boolean; + can_post_stories?: boolean; + can_edit_stories?: boolean; + can_delete_stories?: boolean; + can_post_messages?: boolean; + can_edit_messages?: boolean; + can_pin_messages?: boolean; + can_manage_topics?: boolean; + can_manage_direct_messages?: boolean; + can_manage_tags?: boolean; + } = {}, + ): Promise { + return this._form("promoteChatMember", { ...form, chat_id: chatId, user_id: userId }); + } + setChatAdministratorCustomTitle( + chatId: ChatId, + userId: number, + customTitle: string, + form: {} = {}, + ): Promise { + return this._form("setChatAdministratorCustomTitle", { + ...form, + chat_id: chatId, + user_id: userId, + custom_title: customTitle, + }); + } + setChatMemberTag( + chatId: ChatId, + userId: number, + form: { tag?: string } = {}, + ): Promise { + return this._form("setChatMemberTag", { ...form, chat_id: chatId, user_id: userId }); + } + banChatSenderChat( + chatId: ChatId, + senderChatId: number, + form: {} = {}, + ): Promise { + return this._form("banChatSenderChat", { ...form, chat_id: chatId, sender_chat_id: senderChatId }); + } + unbanChatSenderChat( + chatId: ChatId, + senderChatId: number, + form: {} = {}, + ): Promise { + return this._form("unbanChatSenderChat", { ...form, chat_id: chatId, sender_chat_id: senderChatId }); + } + setChatPermissions( + chatId: ChatId, + chatPermissions: Record, + form: { use_independent_chat_permissions?: boolean } = {}, + ): Promise { + return this._form("setChatPermissions", { ...form, chat_id: chatId, permissions: stringify(chatPermissions) }); + } + + // --- Chat invite links ------------------------------------------------ + + exportChatInviteLink(chatId: ChatId, form: {} = {}): Promise { + return this._form("exportChatInviteLink", { ...form, chat_id: chatId }); + } + createChatInviteLink( + chatId: ChatId, + form: { + name?: string; + expire_date?: number; + member_limit?: number; + creates_join_request?: boolean; + } = {}, + ): Promise { + return this._form("createChatInviteLink", { ...form, chat_id: chatId }); + } + editChatInviteLink( + chatId: ChatId, + inviteLink: string, + form: { + name?: string; + expire_date?: number; + member_limit?: number; + creates_join_request?: boolean; + } = {}, + ): Promise { + return this._form("editChatInviteLink", { ...form, chat_id: chatId, invite_link: inviteLink }); + } + createChatSubscriptionInviteLink( + chatId: ChatId, + subscriptionPeriod: number, + subscriptionPrice: number, + form: { name?: string } = {}, + ): Promise { + return this._form("createChatSubscriptionInviteLink", { + ...form, + chat_id: chatId, + subscription_period: subscriptionPeriod, + subscription_price: subscriptionPrice, + }); + } + editChatSubscriptionInviteLink( + chatId: ChatId, + inviteLink: string, + form: { name?: string } = {}, + ): Promise { + return this._form("editChatSubscriptionInviteLink", { ...form, chat_id: chatId, invite_link: inviteLink }); + } + revokeChatInviteLink(chatId: ChatId, inviteLink: string, form: {} = {}): Promise { + return this._form("revokeChatInviteLink", { ...form, chat_id: chatId, invite_link: inviteLink }); + } + approveChatJoinRequest(chatId: ChatId, userId: number, form: {} = {}): Promise { + return this._form("approveChatJoinRequest", { ...form, chat_id: chatId, user_id: userId }); + } + declineChatJoinRequest(chatId: ChatId, userId: number, form: {} = {}): Promise { + return this._form("declineChatJoinRequest", { ...form, chat_id: chatId, user_id: userId }); + } + + // --- Chat metadata --------------------------------------------------- + + setChatPhoto( + chatId: ChatId, + photo: FileInput, + options: {} = {}, + fileOptions: FileMeta = {}, + ): Promise { + return this._sendFile("setChatPhoto", "photo", photo, { ...options, chat_id: chatId }, fileOptions); + } + deleteChatPhoto(chatId: ChatId, form: {} = {}): Promise { + return this._form("deleteChatPhoto", { ...form, chat_id: chatId }); + } + setChatTitle(chatId: ChatId, title: string, form: {} = {}): Promise { + return this._form("setChatTitle", { ...form, chat_id: chatId, title }); + } + setChatDescription(chatId: ChatId, description: string, form: {} = {}): Promise { + return this._form("setChatDescription", { ...form, chat_id: chatId, description }); + } + pinChatMessage( + chatId: ChatId, + messageId: number, + form: { business_connection_id?: string; disable_notification?: boolean } = {}, + ): Promise { + return this._form("pinChatMessage", { ...form, chat_id: chatId, message_id: messageId }); + } + unpinChatMessage( + chatId: ChatId, + form: { business_connection_id?: string; message_id?: number } = {}, + ): Promise { + return this._form("unpinChatMessage", { ...form, chat_id: chatId }); + } + unpinAllChatMessages(chatId: ChatId, form: {} = {}): Promise { + return this._form("unpinAllChatMessages", { ...form, chat_id: chatId }); + } + leaveChat(chatId: ChatId, form: {} = {}): Promise { + return this._form("leaveChat", { ...form, chat_id: chatId }); + } + getChat(chatId: ChatId, form: {} = {}): Promise { + return this._form("getChat", { ...form, chat_id: chatId }); + } + getChatAdministrators( + chatId: ChatId, + form: { return_bots?: boolean } = {}, + ): Promise { + return this._form("getChatAdministrators", { ...form, chat_id: chatId }); + } + getChatMemberCount(chatId: ChatId, form: {} = {}): Promise { + return this._form("getChatMemberCount", { ...form, chat_id: chatId }); + } + getChatMember(chatId: ChatId, userId: number, form: {} = {}): Promise { + return this._form("getChatMember", { ...form, chat_id: chatId, user_id: userId }); + } + setChatStickerSet(chatId: ChatId, stickerSetName: string, form: {} = {}): Promise { + return this._form("setChatStickerSet", { ...form, chat_id: chatId, sticker_set_name: stickerSetName }); + } + deleteChatStickerSet(chatId: ChatId, form: {} = {}): Promise { + return this._form("deleteChatStickerSet", { ...form, chat_id: chatId }); + } + + // --- Forum topics ----------------------------------------------------- + + getForumTopicIconStickers(chatId: ChatId, form: {} = {}): Promise { + return this._form("getForumTopicIconStickers", { ...form, chat_id: chatId }); + } + createForumTopic( + chatId: ChatId, + name: string, + form: { icon_color?: number; icon_custom_emoji_id?: string } = {}, + ): Promise { + return this._form("createForumTopic", { ...form, chat_id: chatId, name }); + } + editForumTopic( + chatId: ChatId, + messageThreadId: number, + form: { name?: string; icon_custom_emoji_id?: string } = {}, + ): Promise { + return this._form("editForumTopic", { ...form, chat_id: chatId, message_thread_id: messageThreadId }); + } + closeForumTopic(chatId: ChatId, messageThreadId: number, form: {} = {}): Promise { + return this._form("closeForumTopic", { ...form, chat_id: chatId, message_thread_id: messageThreadId }); + } + reopenForumTopic(chatId: ChatId, messageThreadId: number, form: {} = {}): Promise { + return this._form("reopenForumTopic", { ...form, chat_id: chatId, message_thread_id: messageThreadId }); + } + deleteForumTopic(chatId: ChatId, messageThreadId: number, form: {} = {}): Promise { + return this._form("deleteForumTopic", { ...form, chat_id: chatId, message_thread_id: messageThreadId }); + } + unpinAllForumTopicMessages(chatId: ChatId, messageThreadId: number, form: {} = {}): Promise { + return this._form("unpinAllForumTopicMessages", { + ...form, + chat_id: chatId, + message_thread_id: messageThreadId, + }); + } + editGeneralForumTopic(chatId: ChatId, name: string, form: {} = {}): Promise { + return this._form("editGeneralForumTopic", { ...form, chat_id: chatId, name }); + } + closeGeneralForumTopic(chatId: ChatId, form: {} = {}): Promise { + return this._form("closeGeneralForumTopic", { ...form, chat_id: chatId }); + } + reopenGeneralForumTopic(chatId: ChatId, form: {} = {}): Promise { + return this._form("reopenGeneralForumTopic", { ...form, chat_id: chatId }); + } + hideGeneralForumTopic(chatId: ChatId, form: {} = {}): Promise { + return this._form("hideGeneralForumTopic", { ...form, chat_id: chatId }); + } + unhideGeneralForumTopic(chatId: ChatId, form: {} = {}): Promise { + return this._form("unhideGeneralForumTopic", { ...form, chat_id: chatId }); + } + unpinAllGeneralForumTopicMessages(chatId: ChatId, form: {} = {}): Promise { + return this._form("unpinAllGeneralForumTopicMessages", { ...form, chat_id: chatId }); + } + + // --- Callback / inline queries --------------------------------------- + + answerCallbackQuery(callbackQueryId: string, form: AnswerCallbackQueryOptions = {}): Promise { + return this._form("answerCallbackQuery", { ...form, callback_query_id: callbackQueryId }); + } + answerGuestQuery(guestQueryId: string, result: Record): Promise { + return this._form("answerGuestQuery", { + guest_query_id: guestQueryId, + result: stringify(result), + }); + } + savePreparedInlineMessage( + userId: number, + result: Record, + form: { + allow_user_chats?: boolean; + allow_bot_chats?: boolean; + allow_group_chats?: boolean; + allow_channel_chats?: boolean; + } = {}, + ): Promise { + return this._form("savePreparedInlineMessage", { ...form, user_id: userId, result: stringify(result) }); + } + savePreparedKeyboardButton( + userId: number, + button: Record, + form: {} = {}, + ): Promise { + return this._form("savePreparedKeyboardButton", { ...form, user_id: userId, button: stringify(button) }); + } + getUserChatBoosts(chatId: ChatId, userId: number, form: {} = {}): Promise { + return this._form("getUserChatBoosts", { ...form, chat_id: chatId, user_id: userId }); + } + getBusinessConnection(businessConnectionId: string, form: {} = {}): Promise { + return this._form("getBusinessConnection", { ...form, business_connection_id: businessConnectionId }); + } + getManagedBotToken(userId: number, form: {} = {}): Promise { + return this._form("getManagedBotToken", { ...form, user_id: userId }); + } + replaceManagedBotToken(userId: number, form: {} = {}): Promise { + return this._form("replaceManagedBotToken", { ...form, user_id: userId }); + } + getManagedBotAccessSettings(userId: number): Promise { + return this._form("getManagedBotAccessSettings", { user_id: userId }); + } + setManagedBotAccessSettings( + userId: number, + isAccessRestricted: boolean, + form: { added_user_ids?: number[] } = {}, + ): Promise { + const out: Record = { + user_id: userId, + is_access_restricted: isAccessRestricted, + }; + if (form.added_user_ids) out.added_user_ids = stringify(form.added_user_ids); + return this._form("setManagedBotAccessSettings", out); + } + + // --- Bot identity (self-management) ---------------------------------- + + setMyCommands( + commands: BotCommand[], + form: { scope?: Record; language_code?: string } = {}, + ): Promise { + const out: Record = { ...form, commands: stringify(commands) }; + if (out.scope) out.scope = stringify(out.scope); + return this._form("setMyCommands", out); + } + deleteMyCommands( + form: { scope?: Record; language_code?: string } = {}, + ): Promise { + const out: Record = { ...form }; + if (out.scope) out.scope = stringify(out.scope); + return this._form("deleteMyCommands", out); + } + getMyCommands( + form: { scope?: Record; language_code?: string } = {}, + ): Promise { + const out: Record = { ...form }; + if (out.scope) out.scope = stringify(out.scope); + return this._form("getMyCommands", out); + } + setMyName(form: { name?: string; language_code?: string } = {}): Promise { + return this._form("setMyName", form); + } + getMyName(form: { language_code?: string } = {}): Promise<{ name: string }> { + return this._form("getMyName", form); + } + setMyDescription(form: { description?: string; language_code?: string } = {}): Promise { + return this._form("setMyDescription", form); + } + getMyDescription(form: { language_code?: string } = {}): Promise<{ description: string }> { + return this._form("getMyDescription", form); + } + setMyShortDescription( + form: { short_description?: string; language_code?: string } = {}, + ): Promise { + return this._form("setMyShortDescription", form); + } + getMyShortDescription(form: { language_code?: string } = {}): Promise<{ short_description: string }> { + return this._form("getMyShortDescription", form); + } + async setMyProfilePhoto(photo: FileInput, options: {} = {}): Promise { + return this._sendFile("setMyProfilePhoto", "photo", photo, { ...options }); + } + removeMyProfilePhoto(form: {} = {}): Promise { + return this._form("removeMyProfilePhoto", form); + } + setChatMenuButton( + form: { chat_id?: number; menu_button?: Record } = {}, + ): Promise { + return this._form("setChatMenuButton", form); + } + getChatMenuButton(form: { chat_id?: number } = {}): Promise { + return this._form("getChatMenuButton", form); + } + setMyDefaultAdministratorRights( + form: { rights?: Record; for_channels?: boolean } = {}, + ): Promise { + return this._form("setMyDefaultAdministratorRights", form); + } + getMyDefaultAdministratorRights(form: { for_channels?: boolean } = {}): Promise { + return this._form("getMyDefaultAdministratorRights", form); + } + + // --- Editing messages ------------------------------------------------- + + editMessageText( + text: string, + form: { + business_connection_id?: string; + chat_id?: ChatId; + message_id?: number; + inline_message_id?: string; + parse_mode?: ParseMode; + entities?: MessageEntity[]; + link_preview_options?: LinkPreviewOptions; + reply_markup?: InlineKeyboardMarkup; + } = {}, + ): Promise { + return this._form("editMessageText", { ...form, text }); + } + editMessageCaption( + caption: string, + form: { + business_connection_id?: string; + chat_id?: ChatId; + message_id?: number; + inline_message_id?: string; + parse_mode?: ParseMode; + caption_entities?: MessageEntity[]; + show_caption_above_media?: boolean; + reply_markup?: InlineKeyboardMarkup; + } = {}, + ): Promise { + return this._form("editMessageCaption", { ...form, caption }); + } + async editMessageMedia( + media: { media: string | FileInput; type: string; fileOptions?: FileMeta;[key: string]: unknown }, + form: { + business_connection_id?: string; + chat_id?: ChatId; + message_id?: number; + inline_message_id?: string; + reply_markup?: InlineKeyboardMarkup; + } = {}, + ): Promise { + const regexAttach = /^attach:\/\/.+/; + if (typeof media.media === "string" && regexAttach.test(media.media)) { + const qs: Record = { ...form }; + const payload: Record = { ...media }; + delete payload.media; + delete payload.fileOptions; + const attachName = "0"; + const data = (media.media as string).replace("attach://", ""); + const { file } = await prepareFile(data, media.fileOptions, this.options.filepath); + if (!file) throw new FatalError(`Failed to process the replacement action for your ${media.type}`); + payload.media = `attach://${attachName}`; + qs.media = stringify(payload); + return this._request("editMessageMedia", { qs, formData: { [attachName]: file } }); + } + const out: Record = { ...form, media: stringify(media) }; + return this._form("editMessageMedia", out); + } + editMessageChecklist( + businessConnectionId: string, + chatId: ChatId, + messageId: number, + checklist: Record, + form: { reply_markup?: InlineKeyboardMarkup } = {}, + ): Promise { + return this._form("editMessageChecklist", { + ...form, + business_connection_id: businessConnectionId, + chat_id: chatId, + message_id: messageId, + checklist: stringify(checklist), + }); + } + editMessageReplyMarkup( + replyMarkup: InlineKeyboardMarkup, + form: { + business_connection_id?: string; + chat_id?: ChatId; + message_id?: number; + inline_message_id?: string; + } = {}, + ): Promise { + return this._form("editMessageReplyMarkup", { ...form, reply_markup: replyMarkup }); + } + stopPoll( + chatId: ChatId, + pollId: number, + form: { business_connection_id?: string; reply_markup?: InlineKeyboardMarkup } = {}, + ): Promise { + return this._form("stopPoll", { ...form, chat_id: chatId, message_id: pollId }); + } + + // --- Suggested posts -------------------------------------------------- + + approveSuggestedPost( + chatId: ChatId, + messageId: number, + form: { send_date?: number } = {}, + ): Promise { + return this._form("approveSuggestedPost", { ...form, chat_id: chatId, message_id: messageId }); + } + declineSuggestedPost( + chatId: ChatId, + messageId: number, + form: { comment?: string } = {}, + ): Promise { + return this._form("declineSuggestedPost", { ...form, chat_id: chatId, message_id: messageId }); + } + + // --- Stickers -------------------------------------------------------- + + sendSticker( + chatId: ChatId, + sticker: FileInput, + options: { + business_connection_id?: string; + message_thread_id?: number; + direct_messages_topic_id?: number; + emoji?: string; + disable_notification?: boolean; + protect_content?: boolean; + allow_paid_broadcast?: boolean; + message_effect_id?: string; + suggested_post_parameters?: SuggestedPostParameters; + reply_parameters?: ReplyParameters; + reply_markup?: ReplyMarkup; + } = {}, + fileOptions: FileMeta = {}, + ): Promise { + return this._sendFile("sendSticker", "sticker", sticker, { ...options, chat_id: chatId }, fileOptions); + } + getStickerSet(name: string, form: {} = {}): Promise { + return this._form("getStickerSet", { ...form, name }); + } + getCustomEmojiStickers(customEmojiIds: string[], form: {} = {}): Promise { + return this._form("getCustomEmojiStickers", { ...form, custom_emoji_ids: stringify(customEmojiIds) }); + } + uploadStickerFile( + userId: number, + sticker: FileInput, + stickerFormat: "static" | "animated" | "video" = "static", + options: {} = {}, + fileOptions: FileMeta = {}, + ): Promise { + return this._sendFile( + "uploadStickerFile", + "sticker", + sticker, + { ...options, user_id: userId, sticker_format: stickerFormat }, + fileOptions, + ); + } + createNewStickerSet( + userId: number, + name: string, + title: string, + pngSticker: FileInput, + emojis: string, + options: { mask_position?: MaskPosition; sticker_type?: string; needs_repainting?: boolean } = {}, + fileOptions: FileMeta = {}, + ): Promise { + const qs: Record = { ...options, user_id: userId, name, title, emojis }; + if (options.mask_position) qs.mask_position = stringify(options.mask_position); + return this._sendFile("createNewStickerSet", "png_sticker", pngSticker, qs, fileOptions); + } + addStickerToSet( + userId: number, + name: string, + sticker: FileInput, + emojis: string, + stickerType: "png_sticker" | "tgs_sticker" | "webm_sticker" = "png_sticker", + options: { mask_position?: MaskPosition } = {}, + fileOptions: FileMeta = {}, + ): Promise { + if (!["png_sticker", "tgs_sticker", "webm_sticker"].includes(stickerType)) { + return Promise.reject(new Error("stickerType must be one of: png_sticker, tgs_sticker, webm_sticker")); + } + const qs: Record = { ...options, user_id: userId, name, emojis }; + if (options.mask_position) qs.mask_position = stringify(options.mask_position); + return this._sendFile("addStickerToSet", stickerType, sticker, qs, fileOptions); + } + setStickerPositionInSet(sticker: string, position: number, form: {} = {}): Promise { + return this._form("setStickerPositionInSet", { ...form, sticker, position }); + } + deleteStickerFromSet(sticker: string, form: {} = {}): Promise { + return this._form("deleteStickerFromSet", { ...form, sticker }); + } + replaceStickerInSet( + userId: number, + name: string, + oldSticker: string, + form: { sticker?: Record } = {}, + ): Promise { + return this._form("replaceStickerInSet", { ...form, user_id: userId, name, old_sticker: oldSticker }); + } + setStickerEmojiList(sticker: string, emojiList: string[], form: {} = {}): Promise { + return this._form("setStickerEmojiList", { ...form, sticker, emoji_list: stringify(emojiList) }); + } + setStickerKeywords( + sticker: string, + form: { keywords?: string[] } = {}, + ): Promise { + const out: Record = { ...form, sticker }; + if (out.keywords) out.keywords = stringify(out.keywords); + return this._form("setStickerKeywords", out); + } + setStickerMaskPosition( + sticker: string, + form: { mask_position?: MaskPosition } = {}, + ): Promise { + const out: Record = { ...form, sticker }; + if (out.mask_position) out.mask_position = stringify(out.mask_position); + return this._form("setStickerMaskPosition", out); + } + setStickerSetTitle(name: string, title: string, form: {} = {}): Promise { + return this._form("setStickerSetTitle", { ...form, name, title }); + } + setStickerSetThumbnail( + userId: number, + name: string, + thumbnail: FileInput, + options: { format?: "static" | "animated" | "video" } = {}, + fileOptions: FileMeta = {}, + ): Promise { + return this._sendFile( + "setStickerSetThumbnail", + "thumbnail", + thumbnail, + { ...options, user_id: userId, name }, + fileOptions, + ); + } + setCustomEmojiStickerSetThumbnail( + name: string, + form: { custom_emoji_id?: string } = {}, + ): Promise { + return this._form("setCustomEmojiStickerSetThumbnail", { ...form, name }); + } + deleteStickerSet(name: string, form: {} = {}): Promise { + return this._form("deleteStickerSet", { ...form, name }); + } + + // --- Inline / web app ------------------------------------------------- + + answerInlineQuery( + inlineQueryId: string, + results: Array>, + form: AnswerInlineQueryOptions = {}, + ): Promise { + return this._form("answerInlineQuery", { ...form, inline_query_id: inlineQueryId, results: stringify(results) }); + } + answerWebAppQuery( + webAppQueryId: string, + result: Record, + form: {} = {}, + ): Promise { + return this._form("answerWebAppQuery", { ...form, web_app_query_id: webAppQueryId, result: stringify(result) }); + } + + // --- Payments -------------------------------------------------------- + + sendInvoice( + chatId: ChatId, + title: string, + description: string, + payload: string, + providerToken: string, + currency: string, + prices: Array<{ label: string; amount: number }>, + form: SendInvoiceOptions = {}, + ): Promise { + const out: Record = { + ...form, + chat_id: chatId, + title, + description, + payload, + provider_token: providerToken, + currency, + prices: stringify(prices), + }; + if (out.provider_data !== undefined) out.provider_data = stringify(out.provider_data); + if (out.suggested_tip_amounts) out.suggested_tip_amounts = stringify(out.suggested_tip_amounts); + return this._form("sendInvoice", out); + } + createInvoiceLink( + title: string, + description: string, + payload: string, + providerToken: string, + currency: string, + prices: Array<{ label: string; amount: number }>, + form: { + business_connection_id?: string; + subscription_period?: number; + max_tip_amount?: number; + suggested_tip_amounts?: number[]; + provider_data?: string; + photo_url?: string; + photo_size?: number; + photo_width?: number; + photo_height?: number; + need_name?: boolean; + need_phone_number?: boolean; + need_email?: boolean; + need_shipping_address?: boolean; + send_phone_number_to_provider?: boolean; + send_email_to_provider?: boolean; + is_flexible?: boolean; + } = {}, + ): Promise { + return this._form("createInvoiceLink", { + ...form, + title, + description, + payload, + provider_token: providerToken, + currency, + prices: stringify(prices), + }); + } + answerShippingQuery( + shippingQueryId: string, + ok: boolean, + form: { shipping_options?: Array>; error_message?: string } = {}, + ): Promise { + const out: Record = { ...form, shipping_query_id: shippingQueryId, ok }; + if (out.shipping_options) out.shipping_options = stringify(out.shipping_options); + return this._form("answerShippingQuery", out); + } + answerPreCheckoutQuery( + preCheckoutQueryId: string, + ok: boolean, + form: { error_message?: string } = {}, + ): Promise { + return this._form("answerPreCheckoutQuery", { ...form, pre_checkout_query_id: preCheckoutQueryId, ok }); + } + + // --- Telegram Stars -------------------------------------------------- + + getMyStarBalance(form: {} = {}): Promise { + return this._form("getMyStarBalance", form); + } + getStarTransactions(form: { offset?: number; limit?: number } = {}): Promise { + return this._form("getStarTransactions", form); + } + refundStarPayment( + userId: number, + telegramPaymentChargeId: string, + form: {} = {}, + ): Promise { + return this._form("refundStarPayment", { + ...form, + user_id: userId, + telegram_payment_charge_id: telegramPaymentChargeId, + }); + } + editUserStarSubscription( + userId: number, + telegramPaymentChargeId: string, + isCanceled: boolean, + form: {} = {}, + ): Promise { + return this._form("editUserStarSubscription", { + ...form, + user_id: userId, + telegram_payment_charge_id: telegramPaymentChargeId, + is_canceled: isCanceled, + }); + } + + // --- Games ----------------------------------------------------------- + + sendGame( + chatId: ChatId, + gameShortName: string, + form: { + business_connection_id?: string; + message_thread_id?: number; + disable_notification?: boolean; + protect_content?: boolean; + allow_paid_broadcast?: boolean; + message_effect_id?: string; + reply_parameters?: ReplyParameters; + reply_markup?: InlineKeyboardMarkup; + } = {}, + ): Promise { + return this._form("sendGame", { ...form, chat_id: chatId, game_short_name: gameShortName }); + } + setGameScore( + userId: number, + score: number, + form: { + force?: boolean; + disable_edit_message?: boolean; + chat_id?: number; + message_id?: number; + inline_message_id?: string; + } = {}, + ): Promise { + return this._form("setGameScore", { ...form, user_id: userId, score }); + } + getGameHighScores( + userId: number, + form: { chat_id?: number; message_id?: number; inline_message_id?: string } = {}, + ): Promise { + return this._form("getGameHighScores", { ...form, user_id: userId }); + } + + // --- Delete messages ------------------------------------------------ + + deleteMessage(chatId: ChatId, messageId: number, form: {} = {}): Promise { + return this._form("deleteMessage", { ...form, chat_id: chatId, message_id: messageId }); + } + deleteMessages(chatId: ChatId, messageIds: number[], form: {} = {}): Promise { + return this._form("deleteMessages", { ...form, chat_id: chatId, message_ids: stringify(messageIds) }); + } + deleteMessageReaction( + chatId: ChatId, + messageId: number, + form: { user_id?: number; actor_chat_id?: number } = {}, + ): Promise { + return this._form("deleteMessageReaction", { ...form, chat_id: chatId, message_id: messageId }); + } + deleteAllMessageReactions( + chatId: ChatId, + form: { user_id?: number; actor_chat_id?: number } = {}, + ): Promise { + return this._form("deleteAllMessageReactions", { ...form, chat_id: chatId }); + } + // --- Gifts ----------------------------------------------------------- + + getAvailableGifts(form: {} = {}): Promise { + return this._form("getAvailableGifts", form); + } + sendGift( + giftId: string, + form: { + user_id?: number; + chat_id?: ChatId; + pay_for_upgrade?: boolean; + text?: string; + text_parse_mode?: ParseMode; + text_entities?: MessageEntity[]; + } = {}, + ): Promise { + return this._form("sendGift", { ...form, gift_id: giftId }); + } + giftPremiumSubscription( + userId: number, + monthCount: number, + starCount: number, + form: { text?: string; text_parse_mode?: ParseMode; text_entities?: MessageEntity[] } = {}, + ): Promise { + return this._form("giftPremiumSubscription", { + ...form, + user_id: userId, + month_count: monthCount, + star_count: starCount, + }); + } + + // --- Verification --------------------------------------------------- + + verifyUser( + userId: number, + form: { custom_description?: string } = {}, + ): Promise { + return this._form("verifyUser", { ...form, user_id: userId }); + } + verifyChat( + chatId: ChatId, + form: { custom_description?: string } = {}, + ): Promise { + return this._form("verifyChat", { ...form, chat_id: chatId }); + } + removeUserVerification(userId: number, form: {} = {}): Promise { + return this._form("removeUserVerification", { ...form, user_id: userId }); + } + removeChatVerification(chatId: ChatId, form: {} = {}): Promise { + return this._form("removeChatVerification", { ...form, chat_id: chatId }); + } + + // --- Business accounts ---------------------------------------------- + + readBusinessMessage( + businessConnectionId: string, + chatId: ChatId, + messageId: number, + form: {} = {}, + ): Promise { + return this._form("readBusinessMessage", { + ...form, + business_connection_id: businessConnectionId, + chat_id: chatId, + message_id: messageId, + }); + } + deleteBusinessMessages( + businessConnectionId: string, + messageIds: number[], + form: {} = {}, + ): Promise { + return this._form("deleteBusinessMessages", { + ...form, + business_connection_id: businessConnectionId, + message_ids: stringify(messageIds), + }); + } + setBusinessAccountName( + businessConnectionId: string, + firstName: string, + form: { last_name?: string } = {}, + ): Promise { + return this._form("setBusinessAccountName", { + ...form, + business_connection_id: businessConnectionId, + first_name: firstName, + }); + } + setBusinessAccountUsername( + businessConnectionId: string, + form: { username?: string } = {}, + ): Promise { + return this._form("setBusinessAccountUsername", { ...form, business_connection_id: businessConnectionId }); + } + setBusinessAccountBio( + businessConnectionId: string, + form: { bio?: string } = {}, + ): Promise { + return this._form("setBusinessAccountBio", { ...form, business_connection_id: businessConnectionId }); + } + setBusinessAccountProfilePhoto( + businessConnectionId: string, + photo: FileInput, + options: { is_public?: boolean } = {}, + ): Promise { + return this._sendFile("setBusinessAccountProfilePhoto", "photo", photo, { + ...options, + business_connection_id: businessConnectionId, + }); + } + removeBusinessAccountProfilePhoto( + businessConnectionId: string, + form: { is_public?: boolean } = {}, + ): Promise { + return this._form("removeBusinessAccountProfilePhoto", { ...form, business_connection_id: businessConnectionId }); + } + setBusinessAccountGiftSettings( + businessConnectionId: string, + showGiftButton: boolean, + acceptedGiftTypes: Record, + form: {} = {}, + ): Promise { + return this._form("setBusinessAccountGiftSettings", { + ...form, + business_connection_id: businessConnectionId, + show_gift_button: showGiftButton, + accepted_gift_types: acceptedGiftTypes, + }); + } + getBusinessAccountStarBalance(businessConnectionId: string, form: {} = {}): Promise { + return this._form("getBusinessAccountStarBalance", { ...form, business_connection_id: businessConnectionId }); + } + transferBusinessAccountStars( + businessConnectionId: string, + starCount: number, + form: {} = {}, + ): Promise { + return this._form("transferBusinessAccountStars", { + ...form, + business_connection_id: businessConnectionId, + star_count: starCount, + }); + } + getBusinessAccountGifts( + businessConnectionId: string, + form: { + exclude_unsaved?: boolean; + exclude_saved?: boolean; + exclude_unlimited?: boolean; + exclude_limited_upgradable?: boolean; + exclude_limited_non_upgradable?: boolean; + exclude_unique?: boolean; + exclude_from_blockchain?: boolean; + sort_by_price?: boolean; + offset?: string; + limit?: number; + } = {}, + ): Promise { + return this._form("getBusinessAccountGifts", { ...form, business_connection_id: businessConnectionId }); + } + getUserGifts( + userId: number, + form: { + exclude_unlimited?: boolean; + exclude_limited_upgradable?: boolean; + exclude_limited_non_upgradable?: boolean; + exclude_from_blockchain?: boolean; + exclude_unique?: boolean; + sort_by_price?: boolean; + offset?: string; + limit?: number; + } = {}, + ): Promise { + return this._form("getUserGifts", { ...form, user_id: userId }); + } + getChatGifts( + chatId: ChatId, + form: { + exclude_unsaved?: boolean; + exclude_saved?: boolean; + exclude_unlimited?: boolean; + exclude_limited_upgradable?: boolean; + exclude_limited_non_upgradable?: boolean; + exclude_unique?: boolean; + exclude_from_blockchain?: boolean; + sort_by_price?: boolean; + offset?: string; + limit?: number; + } = {}, + ): Promise { + return this._form("getChatGifts", { ...form, chat_id: chatId }); + } + convertGiftToStars( + businessConnectionId: string, + ownedGiftId: string, + form: {} = {}, + ): Promise { + return this._form("convertGiftToStars", { + ...form, + business_connection_id: businessConnectionId, + owned_gift_id: ownedGiftId, + }); + } + upgradeGift( + businessConnectionId: string, + ownedGiftId: string, + form: { keep_original_details?: boolean; star_count?: number } = {}, + ): Promise { + return this._form("upgradeGift", { + ...form, + business_connection_id: businessConnectionId, + owned_gift_id: ownedGiftId, + }); + } + transferGift( + businessConnectionId: string, + ownedGiftId: string, + newOwnerChatId: number, + form: { star_count?: number } = {}, + ): Promise { + return this._form("transferGift", { + ...form, + business_connection_id: businessConnectionId, + owned_gift_id: ownedGiftId, + new_owner_chat_id: newOwnerChatId, + }); + } + + // --- Stories -------------------------------------------------------- + + async postStory( + businessConnectionId: string, + content: { type: string;[key: string]: unknown }, + activePeriod: number, + options: { + caption?: string; + parse_mode?: ParseMode; + caption_entities?: MessageEntity[]; + areas?: Array>; + post_to_chat_page?: boolean; + protect_content?: boolean; + } = {}, + ): Promise { + if (!content.type) throw new FatalError("content.type is required"); + const qs: Record = { + ...options, + business_connection_id: businessConnectionId, + active_period: activePeriod, + }; + const fileInput = content[content.type] as FileInput | undefined; + const { formData, fileIds } = await prepareFiles(content.type, [{ media: fileInput }], {}, this.options.filepath); + const inputContent: Record = { ...content }; + inputContent[content.type] = fileIds[0] ?? `attach://${content.type}_0`; + qs.content = stringify(inputContent); + return this._request("postStory", { qs, formData }); + } + repostStory( + businessConnectionId: string, + fromChatId: ChatId, + fromStoryId: number, + activePeriod: number, + form: { post_to_chat_page?: boolean; protect_content?: boolean } = {}, + ): Promise { + return this._form("repostStory", { + ...form, + business_connection_id: businessConnectionId, + from_chat_id: fromChatId, + from_story_id: fromStoryId, + active_period: activePeriod, + }); + } + async editStory( + businessConnectionId: string, + storyId: number, + content: { type: string;[key: string]: unknown }, + options: { + caption?: string; + parse_mode?: ParseMode; + caption_entities?: MessageEntity[]; + areas?: Array>; + } = {}, + ): Promise { + if (!content.type) throw new FatalError("content.type is required"); + const qs: Record = { + ...options, + business_connection_id: businessConnectionId, + story_id: storyId, + }; + const fileInput = content[content.type] as FileInput | undefined; + const { formData, fileIds } = await prepareFiles(content.type, [{ media: fileInput }], {}, this.options.filepath); + const inputContent: Record = { ...content }; + inputContent[content.type] = fileIds[0] ?? `attach://${content.type}_0`; + qs.content = stringify(inputContent); + return this._request("editStory", { qs, formData }); + } + deleteStory( + businessConnectionId: string, + storyId: number, + form: {} = {}, + ): Promise { + return this._form("deleteStory", { + ...form, + business_connection_id: businessConnectionId, + story_id: storyId, + }); + } +} + +export type { ChatJoinRequest }; +export default TelegramBot; diff --git a/src/telegramPolling.js b/src/telegramPolling.js deleted file mode 100644 index c63c5af6..00000000 --- a/src/telegramPolling.js +++ /dev/null @@ -1,203 +0,0 @@ -const errors = require('./errors'); -const debug = require('debug')('node-telegram-bot-api'); -const deprecate = require('./utils').deprecate; -const ANOTHER_WEB_HOOK_USED = 409; - - -class TelegramBotPolling { - /** - * Handles polling against the Telegram servers. - * @param {TelegramBot} bot - * @see https://core.telegram.org/bots/api#getting-updates - */ - constructor(bot) { - this.bot = bot; - this.options = (typeof bot.options.polling === 'boolean') ? {} : bot.options.polling; - this.options.interval = (typeof this.options.interval === 'number') ? this.options.interval : 300; - this.options.params = (typeof this.options.params === 'object') ? this.options.params : {}; - this.options.params.offset = (typeof this.options.params.offset === 'number') ? this.options.params.offset : 0; - this.options.params.timeout = (typeof this.options.params.timeout === 'number') ? this.options.params.timeout : 10; - if (typeof this.options.timeout === 'number') { - deprecate('`options.polling.timeout` is deprecated. Use `options.polling.params` instead.'); - this.options.params.timeout = this.options.timeout; - } - this._lastUpdate = 0; - this._lastRequest = null; - this._abort = false; - this._pollingTimeout = null; - } - - /** - * Start polling - * @param {Object} [options] - * @param {Object} [options.restart] - * @return {Promise} - */ - start(options = {}) { - if (this._lastRequest) { - if (!options.restart) { - return Promise.resolve(); - } - return this.stop({ - cancel: true, - reason: 'Polling restart', - }).then(() => { - return this._polling(); - }); - } - return this._polling(); - } - - /** - * Stop polling - * @param {Object} [options] Options - * @param {Boolean} [options.cancel] Cancel current request - * @param {String} [options.reason] Reason for stopping polling - * @return {Promise} - */ - stop(options = {}) { - if (!this._lastRequest) { - return Promise.resolve(); - } - const lastRequest = this._lastRequest; - this._lastRequest = null; - clearTimeout(this._pollingTimeout); - if (options.cancel) { - const reason = options.reason || 'Polling stop'; - lastRequest.cancel(reason); - return Promise.resolve(); - } - this._abort = true; - return lastRequest.finally(() => { - this._abort = false; - }); - } - - /** - * Return `true` if is polling. Otherwise, `false`. - */ - isPolling() { - return !!this._lastRequest; - } - - /** - * Handle error thrown during polling. - * @private - * @param {Error} error - */ - _error(error) { - if (!this.bot.listeners('polling_error').length) { - return console.error(`${new Date().toISOString()} error: [polling_error] %j`, error); // eslint-disable-line no-console - } - return this.bot.emit('polling_error', error); - } - - /** - * Invokes polling (with recursion!) - * @return {Promise} promise of the current request - * @private - */ - _polling() { - this._lastRequest = this - ._getUpdates() - .then(updates => { - this._lastUpdate = Date.now(); - debug('polling data %j', updates); - updates.forEach(update => { - this.options.params.offset = update.update_id + 1; - debug('updated offset: %s', this.options.params.offset); - try { - this.bot.processUpdate(update); - } catch (err) { - err._processing = true; - throw err; - } - }); - return null; - }) - .catch(err => { - debug('polling error: %s', err.message); - if (!err._processing) { - return this._error(err); - } - delete err._processing; - /* - * An error occured while processing the items, - * i.e. in `this.bot.processUpdate()` above. - * We need to mark the already-processed items - * to avoid fetching them again once the application - * is restarted, or moves to next polling interval - * (in cases where unhandled rejections do not terminate - * the process). - * See https://github.com/yagop/node-telegram-bot-api/issues/36#issuecomment-268532067 - */ - if (!this.bot.options.badRejection) { - return this._error(err); - } - const opts = { - offset: this.options.params.offset, - limit: 1, - timeout: 0, - }; - return this.bot.getUpdates(opts).then(() => { - return this._error(err); - }).catch(requestErr => { - /* - * We have been unable to handle this error. - * We have to log this to stderr to ensure devops - * understands that they may receive already-processed items - * on app restart. - * We simply can not rescue this situation, emit "error" - * event, with the hope that the application exits. - */ - /* eslint-disable no-console */ - const bugUrl = 'https://github.com/yagop/node-telegram-bot-api/issues/36#issuecomment-268532067'; - const ts = new Date().toISOString(); - console.error(`${ts} error: Internal handling of The Offset Infinite Loop failed`); - console.error(`${ts} error: Due to error '${requestErr}'`); - console.error(`${ts} error: You may receive already-processed updates on app restart`); - console.error(`${ts} error: Please see ${bugUrl} for more information`); - /* eslint-enable no-console */ - return this.bot.emit('error', new errors.FatalError(err)); - }); - }) - .finally(() => { - if (this._abort) { - debug('Polling is aborted!'); - } else { - debug('setTimeout for %s miliseconds', this.options.interval); - this._pollingTimeout = setTimeout(() => this._polling(), this.options.interval); - } - }); - return this._lastRequest; - } - - /** - * Unset current webhook. Used when we detect that a webhook has been set - * and we are trying to poll. Polling and WebHook are mutually exclusive. - * @see https://core.telegram.org/bots/api#getting-updates - * @private - */ - _unsetWebHook() { - debug('unsetting webhook'); - return this.bot._request('setWebHook'); - } - - /** - * Retrieve updates - */ - _getUpdates() { - debug('polling with options: %j', this.options.params); - return this.bot.getUpdates(this.options.params) - .catch(err => { - if (err.response && err.response.statusCode === ANOTHER_WEB_HOOK_USED) { - return this._unsetWebHook().then(() => { - return this.bot.getUpdates(this.options.params); - }); - } - throw err; - }); - } -} - -module.exports = TelegramBotPolling; diff --git a/src/telegramWebHook.js b/src/telegramWebHook.js deleted file mode 100644 index 1ee767a5..00000000 --- a/src/telegramWebHook.js +++ /dev/null @@ -1,158 +0,0 @@ -const errors = require('./errors'); -const debug = require('debug')('node-telegram-bot-api'); -const https = require('https'); -const http = require('http'); -const fs = require('fs'); -const bl = require('bl'); - -class TelegramBotWebHook { - /** - * Sets up a webhook to receive updates - * @param {TelegramBot} bot - * @see https://core.telegram.org/bots/api#getting-updates - */ - constructor(bot) { - this.bot = bot; - this.options = (typeof bot.options.webHook === 'boolean') ? {} : bot.options.webHook; - this.options.host = this.options.host || '0.0.0.0'; - this.options.port = this.options.port || 8443; - this.options.https = this.options.https || {}; - this.options.healthEndpoint = this.options.healthEndpoint || '/healthz'; - this._healthRegex = new RegExp(this.options.healthEndpoint); - this._webServer = null; - this._open = false; - this._requestListener = this._requestListener.bind(this); - this._parseBody = this._parseBody.bind(this); - - if (this.options.key && this.options.cert) { - debug('HTTPS WebHook enabled (by key/cert)'); - this.options.https.key = fs.readFileSync(this.options.key); - this.options.https.cert = fs.readFileSync(this.options.cert); - this._webServer = https.createServer(this.options.https, this._requestListener); - } else if (this.options.pfx) { - debug('HTTPS WebHook enabled (by pfx)'); - this.options.https.pfx = fs.readFileSync(this.options.pfx); - this._webServer = https.createServer(this.options.https, this._requestListener); - } else if (Object.keys(this.options.https).length) { - debug('HTTPS WebHook enabled by (https)'); - this._webServer = https.createServer(this.options.https, this._requestListener); - } else { - debug('HTTP WebHook enabled'); - this._webServer = http.createServer(this._requestListener); - } - } - - /** - * Open WebHook by listening on the port - * @return {Promise} - */ - open() { - if (this.isOpen()) { - return Promise.resolve(); - } - return new Promise((resolve, reject) => { - this._webServer.listen(this.options.port, this.options.host, () => { - debug('WebHook listening on port %s', this.options.port); - this._open = true; - return resolve(); - }); - - this._webServer.once('error', (err) => { - reject(err); - }); - }); - } - - /** - * Close the webHook - * @return {Promise} - */ - close() { - if (!this.isOpen()) { - return Promise.resolve(); - } - return new Promise((resolve, reject) => { - this._webServer.close(error => { - if (error) return reject(error); - this._open = false; - return resolve(); - }); - }); - } - - /** - * Return `true` if server is listening. Otherwise, `false`. - */ - isOpen() { - // NOTE: Since `http.Server.listening` was added in v5.7.0 - // and we still need to support Node v4, - // we are going to fallback to 'this._open'. - // The following LOC would suffice for newer versions of Node.js - // return this._webServer.listening; - return this._open; - } - - /** - * Handle error thrown during processing of webhook request. - * @private - * @param {Error} error - */ - _error(error) { - if (!this.bot.listeners('webhook_error').length) { - return console.error(`${new Date().toISOString()} error: [webhook_error] %j`, error); // eslint-disable-line no-console - } - return this.bot.emit('webhook_error', error); - } - - /** - * Handle request body by passing it to 'callback' - * @private - */ - _parseBody(error, body) { - if (error) { - return this._error(new errors.FatalError(error)); - } - - let data; - try { - data = JSON.parse(body.toString()); - } catch (parseError) { - return this._error(new errors.ParseError(parseError.message)); - } - - return this.bot.processUpdate(data); - } - - /** - * Listener for 'request' event on server - * @private - * @see https://nodejs.org/docs/latest/api/http.html#http_http_createserver_requestlistener - * @see https://nodejs.org/docs/latest/api/https.html#https_https_createserver_options_requestlistener - */ - _requestListener(req, res) { - debug('WebHook request URL: %s', req.url); - debug('WebHook request headers: %j', req.headers); - - if (req.url.indexOf(this.bot.token) !== -1) { - if (req.method !== 'POST') { - debug('WebHook request isn\'t a POST'); - res.statusCode = 418; // I'm a teabot! - res.end(); - } else { - req - .pipe(bl(this._parseBody)) - .on('finish', () => res.end('OK')); - } - } else if (this._healthRegex.test(req.url)) { - debug('WebHook health check passed'); - res.statusCode = 200; - res.end('OK'); - } else { - debug('WebHook request unauthorized'); - res.statusCode = 401; - res.end(); - } - } -} - -module.exports = TelegramBotWebHook; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 00000000..f4ccf61c --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,28 @@ +export * from "./schemas.js"; + +export type { + BaseSendOptions, + SendMessageOptions, + ForwardMessageOptions, + ForwardMessagesOptions, + CopyMessageOptions, + CopyMessagesOptions, + SendPhotoOptions, + SendAudioOptions, + SendDocumentOptions, + SendVideoOptions, + SendAnimationOptions, + SendVoiceOptions, + SendVideoNoteOptions, + SendLocationOptions, + SendVenueOptions, + SendContactOptions, + SendPollOptions, + SendDiceOptions, + SendChatActionOptions, + AnswerCallbackQueryOptions, + AnswerInlineQueryOptions, + SendInvoiceOptions, + SetWebHookOptions, + GetUpdatesOptions, +} from "./options.js"; diff --git a/src/types/options.ts b/src/types/options.ts new file mode 100644 index 00000000..8c675dea --- /dev/null +++ b/src/types/options.ts @@ -0,0 +1,441 @@ +/** + * TypeScript option/argument types for the most common TelegramBot methods. + * + * These mirror the request payloads documented at + * https://core.telegram.org/bots/api. The library accepts any extra fields + * via index signatures so downstream callers stay forward-compatible when + * Telegram introduces new optional parameters. + */ + +import type { + ChatId, + InlineKeyboardMarkup, + LinkPreviewOptions, + MessageEntity, + ParseMode, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, + ForceReply, + ReplyParameters, + SuggestedPostPrice, +} from "./schemas.js"; + +export type ReplyMarkup = + | InlineKeyboardMarkup + | ReplyKeyboardMarkup + | ReplyKeyboardRemove + | ForceReply; + +export interface InputMediaAnimation { + type: "animation"; + media: string; + thumbnail?: string; + caption?: string; + parse_mode?: ParseMode; + caption_entities?: MessageEntity[]; + show_caption_above_media?: boolean; + width?: number; + height?: number; + duration?: number; + has_spoiler?: boolean; + [key: string]: unknown; +} + +export interface InputMediaAudio { + type: "audio"; + media: string; + thumbnail?: string; + caption?: string; + parse_mode?: ParseMode; + caption_entities?: MessageEntity[]; + duration?: number; + performer?: string; + title?: string; + [key: string]: unknown; +} + +export interface InputMediaDocument { + type: "document"; + media: string; + thumbnail?: string; + caption?: string; + parse_mode?: ParseMode; + caption_entities?: MessageEntity[]; + disable_content_type_detection?: boolean; + [key: string]: unknown; +} + +export interface InputMediaLivePhoto { + type: "live_photo"; + media: string; + photo: string; + caption?: string; + parse_mode?: ParseMode; + caption_entities?: MessageEntity[]; + show_caption_above_media?: boolean; + has_spoiler?: boolean; + [key: string]: unknown; +} + +export interface InputMediaLocation { + type: "location"; + latitude: number; + longitude: number; + horizontal_accuracy?: number; + [key: string]: unknown; +} + +export interface InputMediaPhoto { + type: "photo"; + media: string; + caption?: string; + parse_mode?: ParseMode; + caption_entities?: MessageEntity[]; + show_caption_above_media?: boolean; + has_spoiler?: boolean; + [key: string]: unknown; +} + +export interface InputMediaSticker { + type: "sticker"; + media: string; + emoji?: string; + [key: string]: unknown; +} + +export interface InputMediaVenue { + type: "venue"; + latitude: number; + longitude: number; + title: string; + address: string; + foursquare_id?: string; + foursquare_type?: string; + google_place_id?: string; + google_place_type?: string; + [key: string]: unknown; +} + +export interface InputMediaVideo { + type: "video"; + media: string; + thumbnail?: string; + cover?: string; + start_timestamp?: number; + caption?: string; + parse_mode?: ParseMode; + caption_entities?: MessageEntity[]; + show_caption_above_media?: boolean; + width?: number; + height?: number; + duration?: number; + supports_streaming?: boolean; + has_spoiler?: boolean; + [key: string]: unknown; +} + +/** Media that can be attached to an individual poll option. */ +export type InputPollOptionMedia = + | InputMediaAnimation + | InputMediaLivePhoto + | InputMediaLocation + | InputMediaPhoto + | InputMediaSticker + | InputMediaVenue + | InputMediaVideo; + +/** Media that can be attached to a poll description or quiz explanation. */ +export type InputPollMedia = + | InputMediaAnimation + | InputMediaAudio + | InputMediaDocument + | InputMediaLivePhoto + | InputMediaLocation + | InputMediaPhoto + | InputMediaVenue + | InputMediaVideo; + +export interface InputPollOption { + text: string; + text_parse_mode?: ParseMode; + text_entities?: MessageEntity[]; + media?: InputPollOptionMedia; + [key: string]: unknown; +} + +export interface BaseSendOptions { + message_thread_id?: number; + direct_messages_topic_id?: number; + disable_notification?: boolean; + protect_content?: boolean; + allow_paid_broadcast?: boolean; + message_effect_id?: string; + reply_parameters?: ReplyParameters; + reply_markup?: ReplyMarkup; + [key: string]: unknown; +} + +export interface SendMessageOptions extends BaseSendOptions { + business_connection_id?: string; + parse_mode?: ParseMode; + entities?: MessageEntity[]; + link_preview_options?: LinkPreviewOptions; + suggested_post_parameters?: SuggestedPostParameters; +} + +export interface ForwardMessageOptions { + message_thread_id?: number; + direct_messages_topic_id?: number; + video_start_timestamp?: number; + disable_notification?: boolean; + protect_content?: boolean; + message_effect_id?: string; + suggested_post_parameters?: SuggestedPostParameters; + [key: string]: unknown; +} + +export interface ForwardMessagesOptions { + message_thread_id?: number; + direct_messages_topic_id?: number; + disable_notification?: boolean; + protect_content?: boolean; + [key: string]: unknown; +} + +export interface CopyMessageOptions extends BaseSendOptions { + video_start_timestamp?: number; + caption?: string; + parse_mode?: ParseMode; + caption_entities?: MessageEntity[]; + show_caption_above_media?: boolean; + suggested_post_parameters?: SuggestedPostParameters; +} + +export interface CopyMessagesOptions { + message_thread_id?: number; + direct_messages_topic_id?: number; + disable_notification?: boolean; + protect_content?: boolean; + remove_caption?: boolean; + [key: string]: unknown; +} + +export interface SuggestedPostParameters { + price?: SuggestedPostPrice; + send_date?: number; + [key: string]: unknown; +} + +export interface SendPhotoOptions extends BaseSendOptions { + business_connection_id?: string; + caption?: string; + parse_mode?: ParseMode; + caption_entities?: MessageEntity[]; + message_effect_id?: string; + suggested_post_parameters?: SuggestedPostParameters; + show_caption_above_media?: boolean; + has_spoiler?: boolean; +} + +export interface SendLivePhotoOptions extends SendPhotoOptions { } + +export interface SendAudioOptions extends BaseSendOptions { + business_connection_id?: string; + thumbnail?: string; + caption?: string; + parse_mode?: ParseMode; + caption_entities?: MessageEntity[]; + message_effect_id?: string; + duration?: number; + performer?: string; + title?: string; + suggested_post_parameters?: SuggestedPostParameters; +} + +export interface SendDocumentOptions extends BaseSendOptions { + business_connection_id?: string; + caption?: string; + thumbnail?: string; + parse_mode?: ParseMode; + caption_entities?: MessageEntity[]; + disable_content_type_detection?: boolean; + suggested_post_parameters?: SuggestedPostParameters; +} + +export interface SendVideoOptions extends SendPhotoOptions { + duration?: number; + width?: number; + height?: number; + cover?: string; + start_timestamp?: number; + thumbnail?: string; + supports_streaming?: boolean; +} + +export interface SendAnimationOptions extends SendVideoOptions { } + +export interface SendVoiceOptions extends BaseSendOptions { + business_connection_id?: string; + caption?: string; + parse_mode?: ParseMode; + caption_entities?: MessageEntity[]; + duration?: number; + suggested_post_parameters?: SuggestedPostParameters; +} + +export interface SendVideoNoteOptions extends BaseSendOptions { + business_connection_id?: string; + duration?: number; + length?: number; + thumbnail?: string; + suggested_post_parameters?: SuggestedPostParameters; +} + +export interface SendPaidMediaOptions extends BaseSendOptions { + business_connection_id?: string; + start_count?: number; + payload?: string; + caption?: string; + parse_mode?: ParseMode; + caption_entities?: MessageEntity[]; + show_caption_above_media?: boolean; + suggested_post_parameters?: SuggestedPostParameters; +} + +export interface SendMediaGroupOptions { + business_connection_id?: string; + message_thread_id?: number; + direct_messages_topic_id?: number; + disable_notification?: boolean; + protect_content?: boolean; + allow_paid_broadcast?: boolean; + message_effect_id?: string; + reply_parameters?: ReplyParameters; + [key: string]: unknown; +} +export interface SendLocationOptions extends BaseSendOptions { + business_connection_id?: string; + horizontal_accuracy?: number; + live_period?: number; + heading?: number; + proximity_alert_radius?: number; + suggested_post_parameters?: SuggestedPostParameters; +} + +export interface SendVenueOptions extends BaseSendOptions { + business_connection_id?: string; + foursquare_id?: string; + foursquare_type?: string; + google_place_id?: string; + google_place_type?: string; + suggested_post_parameters?: SuggestedPostParameters; +} + +export interface SendContactOptions extends BaseSendOptions { + business_connection_id?: string; + last_name?: string; + vcard?: string; + suggested_post_parameters?: SuggestedPostParameters; +} + +export interface SendPollOptions extends BaseSendOptions { + business_connection_id?: string; + question_parse_mode?: ParseMode; + question_entities?: MessageEntity[]; + is_anonymous?: boolean; + type?: "regular" | "quiz"; + allows_multiple_answers?: boolean; + allow_revoting?: boolean; + shuffle_options?: boolean; + allow_adding_options?: boolean; + hide_results_until_closes?: boolean; + members_only?: boolean; + country_codes?: string[]; + correct_option_ids?: number[]; + explanation?: string; + explanation_parse_mode?: ParseMode; + explanation_entities?: MessageEntity[]; + explanation_media?: InputPollMedia; + open_period?: number; + close_date?: number; + is_closed?: boolean; + description?: string; + description_parse_mode?: ParseMode; + description_entities?: MessageEntity[]; + media?: InputPollMedia; +} + +export interface SendDiceOptions extends BaseSendOptions { + emoji?: string; +} + +export interface SendChatActionOptions { + business_connection_id?: string; + message_thread_id?: number; + [key: string]: unknown; +} + +export interface AnswerCallbackQueryOptions { + text?: string; + show_alert?: boolean; + url?: string; + cache_time?: number; + [key: string]: unknown; +} + +export interface AnswerInlineQueryOptions { + cache_time?: number; + is_personal?: boolean; + next_offset?: string; + button?: { text: string; web_app?: { url: string }; start_parameter?: string }; + [key: string]: unknown; +} + +export interface SendInvoiceOptions { + provider_data?: Record; + photo_url?: string; + photo_size?: number; + photo_width?: number; + photo_height?: number; + need_name?: boolean; + need_phone_number?: boolean; + need_email?: boolean; + need_shipping_address?: boolean; + send_phone_number_to_provider?: boolean; + send_email_to_provider?: boolean; + is_flexible?: boolean; + max_tip_amount?: number; + suggested_tip_amounts?: number[]; + start_parameter?: string; + reply_parameters?: ReplyParameters; + reply_markup?: InlineKeyboardMarkup; + business_connection_id?: string; + message_thread_id?: number; + disable_notification?: boolean; + protect_content?: boolean; + allow_paid_broadcast?: boolean; + [key: string]: unknown; +} + +export interface SetWebHookOptions { + certificate?: string | NodeJS.ReadableStream | Buffer; + ip_address?: string; + max_connections?: number; + allowed_updates?: string[] | string; + drop_pending_updates?: boolean; + secret_token?: string; + [key: string]: unknown; +} + +export interface GetUpdatesOptions { + offset?: number; + limit?: number; + timeout?: number; + allowed_updates?: string[] | string; + [key: string]: unknown; +} + +/** + * Common type for the `chatId` parameter (Number for IDs, String for `@username`). + */ +export type { ChatId }; diff --git a/src/types/schemas.ts b/src/types/schemas.ts new file mode 100644 index 00000000..4a381824 --- /dev/null +++ b/src/types/schemas.ts @@ -0,0 +1,1087 @@ +/** + * Zod schemas for the Telegram Bot API. + * + * The schemas mirror the structures documented at + * https://core.telegram.org/bots/api and were cross-referenced against the + * model package of https://github.com/go-telegram/bot. + * + * Schemas are exported alongside their inferred TypeScript types so callers + * can either runtime-validate (`UpdateSchema.parse(payload)`) or rely on + * static typing alone (`UpdateSchema.safeParse(payload)` / `Update`). + * + * Permissive policy + * ----------------- + * Telegram regularly extends payloads with new optional fields. To keep the + * library forward-compatible we use `.passthrough()` on top-level objects so + * unknown properties survive parsing and are still accessible to users. + */ + +import { z } from "zod"; + +// --------------------------------------------------------------------------- +// Atomic / primitive helpers +// --------------------------------------------------------------------------- + +export const ChatIdSchema = z.union([z.number().int(), z.string()]); +export type ChatId = z.infer; + +const obj = (shape: T) => z.object(shape).passthrough(); + +// --------------------------------------------------------------------------- +// User & Chat +// --------------------------------------------------------------------------- + +export const UserSchema = obj({ + id: z.number().int(), + is_bot: z.boolean(), + first_name: z.string(), + last_name: z.string().optional(), + username: z.string().optional(), + language_code: z.string().optional(), + is_premium: z.boolean().optional(), + added_to_attachment_menu: z.boolean().optional(), + can_join_groups: z.boolean().optional(), + can_read_all_group_messages: z.boolean().optional(), + supports_inline_queries: z.boolean().optional(), + can_connect_to_business: z.boolean().optional(), + has_main_web_app: z.boolean().optional(), +}); +export type User = z.infer; + +export const ChatTypeSchema = z.enum(["private", "group", "supergroup", "channel"]); +export type ChatType = z.infer; + +export const ChatSchema: z.ZodType<{ + id: number; + type: ChatType; + title?: string; + username?: string; + first_name?: string; + last_name?: string; + is_forum?: boolean; + [key: string]: unknown; +}> = obj({ + id: z.number().int(), + type: ChatTypeSchema, + title: z.string().optional(), + username: z.string().optional(), + first_name: z.string().optional(), + last_name: z.string().optional(), + is_forum: z.boolean().optional(), +}); +export type Chat = z.infer; + +// --------------------------------------------------------------------------- +// Files (PhotoSize, Audio, Video, Document, …) +// --------------------------------------------------------------------------- + +const fileBase = { + file_id: z.string(), + file_unique_id: z.string(), + file_size: z.number().int().optional(), +}; + +export const PhotoSizeSchema = obj({ + ...fileBase, + width: z.number().int(), + height: z.number().int(), +}); +export type PhotoSize = z.infer; + +export const AnimationSchema = obj({ + ...fileBase, + width: z.number().int(), + height: z.number().int(), + duration: z.number().int(), + thumbnail: PhotoSizeSchema.optional(), + file_name: z.string().optional(), + mime_type: z.string().optional(), +}); +export type Animation = z.infer; + +export const AudioSchema = obj({ + ...fileBase, + duration: z.number().int(), + performer: z.string().optional(), + title: z.string().optional(), + file_name: z.string().optional(), + mime_type: z.string().optional(), + thumbnail: PhotoSizeSchema.optional(), +}); +export type Audio = z.infer; + +export const DocumentSchema = obj({ + ...fileBase, + thumbnail: PhotoSizeSchema.optional(), + file_name: z.string().optional(), + mime_type: z.string().optional(), +}); +export type Document = z.infer; + +export const VideoSchema = obj({ + ...fileBase, + width: z.number().int(), + height: z.number().int(), + duration: z.number().int(), + thumbnail: PhotoSizeSchema.optional(), + file_name: z.string().optional(), + mime_type: z.string().optional(), +}); +export type Video = z.infer; + +export const VoiceSchema = obj({ + ...fileBase, + duration: z.number().int(), + mime_type: z.string().optional(), +}); +export type Voice = z.infer; + +export const VideoNoteSchema = obj({ + ...fileBase, + length: z.number().int(), + duration: z.number().int(), + thumbnail: PhotoSizeSchema.optional(), +}); +export type VideoNote = z.infer; + +export const FileSchema = obj({ + ...fileBase, + file_path: z.string().optional(), +}); +export type File = z.infer; + +// --------------------------------------------------------------------------- +// Stickers +// --------------------------------------------------------------------------- + +export const MaskPositionSchema = obj({ + point: z.enum(["forehead", "eyes", "mouth", "chin"]), + x_shift: z.number(), + y_shift: z.number(), + scale: z.number(), +}); +export type MaskPosition = z.infer; + +export const StickerTypeSchema = z.enum(["regular", "mask", "custom_emoji"]); + +export const StickerSchema = obj({ + ...fileBase, + type: StickerTypeSchema, + width: z.number().int(), + height: z.number().int(), + is_animated: z.boolean(), + is_video: z.boolean(), + thumbnail: PhotoSizeSchema.optional(), + emoji: z.string().optional(), + set_name: z.string().optional(), + premium_animation: FileSchema.optional(), + mask_position: MaskPositionSchema.optional(), + custom_emoji_id: z.string().optional(), + needs_repainting: z.boolean().optional(), +}); +export type Sticker = z.infer; + +export const StickerSetSchema = obj({ + name: z.string(), + title: z.string(), + sticker_type: StickerTypeSchema, + stickers: z.array(StickerSchema), + thumbnail: PhotoSizeSchema.optional(), +}); +export type StickerSet = z.infer; + +// --------------------------------------------------------------------------- +// Contact / Location / Venue / Dice / Poll +// --------------------------------------------------------------------------- + +export const ContactSchema = obj({ + phone_number: z.string(), + first_name: z.string(), + last_name: z.string().optional(), + user_id: z.number().int().optional(), + vcard: z.string().optional(), +}); +export type Contact = z.infer; + +export const LocationSchema = obj({ + longitude: z.number(), + latitude: z.number(), + horizontal_accuracy: z.number().optional(), + live_period: z.number().int().optional(), + heading: z.number().int().optional(), + proximity_alert_radius: z.number().int().optional(), +}); +export type Location = z.infer; + +export const VenueSchema = obj({ + location: LocationSchema, + title: z.string(), + address: z.string(), + foursquare_id: z.string().optional(), + foursquare_type: z.string().optional(), + google_place_id: z.string().optional(), + google_place_type: z.string().optional(), +}); +export type Venue = z.infer; + +export const DiceSchema = obj({ + emoji: z.string(), + value: z.number().int(), +}); +export type Dice = z.infer; + +export const PollOptionSchema = obj({ + text: z.string(), + voter_count: z.number().int(), +}); + +export const PollSchema = obj({ + id: z.string(), + question: z.string(), + options: z.array(PollOptionSchema), + total_voter_count: z.number().int(), + is_closed: z.boolean(), + is_anonymous: z.boolean(), + type: z.enum(["regular", "quiz"]), + allows_multiple_answers: z.boolean(), + correct_option_id: z.number().int().optional(), + explanation: z.string().optional(), + open_period: z.number().int().optional(), + close_date: z.number().int().optional(), +}); +export type Poll = z.infer; + +export const PollAnswerSchema = obj({ + poll_id: z.string(), + voter_chat: ChatSchema.optional(), + user: UserSchema.optional(), + option_ids: z.array(z.number().int()), +}); +export type PollAnswer = z.infer; + +// --------------------------------------------------------------------------- +// Message entities, reply markup, parse mode +// --------------------------------------------------------------------------- + +export const ParseModeSchema = z.enum(["MarkdownV2", "Markdown", "HTML"]); +export type ParseMode = z.infer; + +export const MessageEntityTypeSchema = z.enum([ + "mention", + "hashtag", + "cashtag", + "bot_command", + "url", + "email", + "phone_number", + "bold", + "italic", + "underline", + "strikethrough", + "spoiler", + "blockquote", + "expandable_blockquote", + "code", + "pre", + "text_link", + "text_mention", + "custom_emoji", +]); +export type MessageEntityType = z.infer; + +export const MessageEntitySchema = obj({ + type: MessageEntityTypeSchema, + offset: z.number().int(), + length: z.number().int(), + url: z.string().optional(), + user: UserSchema.optional(), + language: z.string().optional(), + custom_emoji_id: z.string().optional(), +}); +export type MessageEntity = z.infer; + +// Inline keyboard / reply keyboard / etc. +export const LoginUrlSchema = obj({ + url: z.string(), + forward_text: z.string().optional(), + bot_username: z.string().optional(), + request_write_access: z.boolean().optional(), +}); + +export const WebAppInfoSchema = obj({ url: z.string() }); + +export const SwitchInlineQueryChosenChatSchema = obj({ + query: z.string().optional(), + allow_user_chats: z.boolean().optional(), + allow_bot_chats: z.boolean().optional(), + allow_group_chats: z.boolean().optional(), + allow_channel_chats: z.boolean().optional(), +}); + +export const CallbackGameSchema = obj({}); +export const CopyTextButtonSchema = obj({ text: z.string() }); + +export const InlineKeyboardButtonSchema = obj({ + text: z.string(), + url: z.string().optional(), + callback_data: z.string().optional(), + web_app: WebAppInfoSchema.optional(), + login_url: LoginUrlSchema.optional(), + switch_inline_query: z.string().optional(), + switch_inline_query_current_chat: z.string().optional(), + switch_inline_query_chosen_chat: SwitchInlineQueryChosenChatSchema.optional(), + copy_text: CopyTextButtonSchema.optional(), + callback_game: CallbackGameSchema.optional(), + pay: z.boolean().optional(), +}); +export type InlineKeyboardButton = z.infer; + +export const InlineKeyboardMarkupSchema = obj({ + inline_keyboard: z.array(z.array(InlineKeyboardButtonSchema)), +}); +export type InlineKeyboardMarkup = z.infer; + +export const KeyboardButtonPollTypeSchema = obj({ type: z.string().optional() }); +export const KeyboardButtonRequestUsersSchema = obj({ + request_id: z.number().int(), + user_is_bot: z.boolean().optional(), + user_is_premium: z.boolean().optional(), + max_quantity: z.number().int().optional(), + request_name: z.boolean().optional(), + request_username: z.boolean().optional(), + request_photo: z.boolean().optional(), +}); +export const KeyboardButtonRequestChatSchema = obj({ + request_id: z.number().int(), + chat_is_channel: z.boolean(), + chat_is_forum: z.boolean().optional(), + chat_has_username: z.boolean().optional(), + chat_is_created: z.boolean().optional(), + bot_is_member: z.boolean().optional(), + request_title: z.boolean().optional(), + request_username: z.boolean().optional(), + request_photo: z.boolean().optional(), +}); + +export const KeyboardButtonSchema = obj({ + text: z.string(), + request_users: KeyboardButtonRequestUsersSchema.optional(), + request_chat: KeyboardButtonRequestChatSchema.optional(), + request_contact: z.boolean().optional(), + request_location: z.boolean().optional(), + request_poll: KeyboardButtonPollTypeSchema.optional(), + web_app: WebAppInfoSchema.optional(), +}); +export type KeyboardButton = z.infer; + +export const ReplyKeyboardMarkupSchema = obj({ + keyboard: z.array(z.array(KeyboardButtonSchema)), + is_persistent: z.boolean().optional(), + resize_keyboard: z.boolean().optional(), + one_time_keyboard: z.boolean().optional(), + input_field_placeholder: z.string().optional(), + selective: z.boolean().optional(), +}); +export type ReplyKeyboardMarkup = z.infer; + +export const ReplyKeyboardRemoveSchema = obj({ + remove_keyboard: z.literal(true), + selective: z.boolean().optional(), +}); +export type ReplyKeyboardRemove = z.infer; + +export const ForceReplySchema = obj({ + force_reply: z.literal(true), + input_field_placeholder: z.string().optional(), + selective: z.boolean().optional(), +}); +export type ForceReply = z.infer; + +export const ReplyMarkupSchema = z.union([ + InlineKeyboardMarkupSchema, + ReplyKeyboardMarkupSchema, + ReplyKeyboardRemoveSchema, + ForceReplySchema, +]); +export type ReplyMarkup = z.infer; + +export const ReplyParametersSchema = obj({ + message_id: z.number().int(), + chat_id: ChatIdSchema.optional(), + allow_sending_without_reply: z.boolean().optional(), + quote: z.string().optional(), + quote_parse_mode: ParseModeSchema.optional(), + quote_entities: z.array(MessageEntitySchema).optional(), + quote_position: z.number().int().optional(), +}); +export type ReplyParameters = z.infer; + +export const LinkPreviewOptionsSchema = obj({ + is_disabled: z.boolean().optional(), + url: z.string().optional(), + prefer_small_media: z.boolean().optional(), + prefer_large_media: z.boolean().optional(), + show_above_text: z.boolean().optional(), +}); +export type LinkPreviewOptions = z.infer; + +// --------------------------------------------------------------------------- +// Reactions +// --------------------------------------------------------------------------- + +export const ReactionTypeSchema = z.discriminatedUnion("type", [ + obj({ type: z.literal("emoji"), emoji: z.string() }), + obj({ type: z.literal("custom_emoji"), custom_emoji_id: z.string() }), + obj({ type: z.literal("paid") }), +]); +export type ReactionType = z.infer; + +export const MessageReactionUpdatedSchema = obj({ + chat: ChatSchema, + message_id: z.number().int(), + user: UserSchema.optional(), + actor_chat: ChatSchema.optional(), + date: z.number().int(), + old_reaction: z.array(ReactionTypeSchema), + new_reaction: z.array(ReactionTypeSchema), +}); +export type MessageReactionUpdated = z.infer; + +export const ReactionCountSchema = obj({ + type: ReactionTypeSchema, + total_count: z.number().int(), +}); +export const MessageReactionCountUpdatedSchema = obj({ + chat: ChatSchema, + message_id: z.number().int(), + date: z.number().int(), + reactions: z.array(ReactionCountSchema), +}); +export type MessageReactionCountUpdated = z.infer; + +// --------------------------------------------------------------------------- +// Payments / Paid Media +// --------------------------------------------------------------------------- + +export const SuggestedPostPriceSchema = obj({ + currency: z.enum(["XTR", "TON"]), + amount: z.number().int(), +}); +export type SuggestedPostPrice = z.infer; + +export const SuggestedPostInfoSchema = obj({ + state: z.enum(["pending", "approved", "declined"]), + price: SuggestedPostPriceSchema.optional(), + send_date: z.number().int().optional(), +}); +export type SuggestedPostInfo = z.infer; + +// --------------------------------------------------------------------------- +// Payments +// --------------------------------------------------------------------------- + +export const InvoiceSchema = obj({ + title: z.string(), + description: z.string(), + start_parameter: z.string(), + currency: z.string(), + total_amount: z.number().int(), +}); +export type Invoice = z.infer; + +export const ShippingAddressSchema = obj({ + country_code: z.string(), + state: z.string(), + city: z.string(), + street_line1: z.string(), + street_line2: z.string(), + post_code: z.string(), +}); + +export const OrderInfoSchema = obj({ + name: z.string().optional(), + phone_number: z.string().optional(), + email: z.string().optional(), + shipping_address: ShippingAddressSchema.optional(), +}); +export type OrderInfo = z.infer; + +export const SuccessfulPaymentSchema = obj({ + currency: z.string(), + total_amount: z.number().int(), + invoice_payload: z.string(), + shipping_option_id: z.string().optional(), + order_info: OrderInfoSchema.optional(), + telegram_payment_charge_id: z.string(), + provider_payment_charge_id: z.string(), +}); +export type SuccessfulPayment = z.infer; + +export const ShippingQuerySchema = obj({ + id: z.string(), + from: UserSchema, + invoice_payload: z.string(), + shipping_address: ShippingAddressSchema, +}); +export type ShippingQuery = z.infer; + +export const PreCheckoutQuerySchema = obj({ + id: z.string(), + from: UserSchema, + currency: z.string(), + total_amount: z.number().int(), + invoice_payload: z.string(), + shipping_option_id: z.string().optional(), + order_info: OrderInfoSchema.optional(), +}); +export type PreCheckoutQuery = z.infer; + +// --------------------------------------------------------------------------- +// Inline / Callback queries +// --------------------------------------------------------------------------- + +export const CallbackQuerySchema = obj({ + id: z.string(), + from: UserSchema, + message: z.unknown().optional(), + inline_message_id: z.string().optional(), + chat_instance: z.string(), + data: z.string().optional(), + game_short_name: z.string().optional(), +}); +export type CallbackQuery = z.infer; + +export const InlineQuerySchema = obj({ + id: z.string(), + from: UserSchema, + query: z.string(), + offset: z.string(), + chat_type: z.enum(["sender", "private", "group", "supergroup", "channel"]).optional(), + location: LocationSchema.optional(), +}); +export type InlineQuery = z.infer; + +export const ChosenInlineResultSchema = obj({ + result_id: z.string(), + from: UserSchema, + location: LocationSchema.optional(), + inline_message_id: z.string().optional(), + query: z.string(), +}); +export type ChosenInlineResult = z.infer; + +// --------------------------------------------------------------------------- +// Forum / topics / chat boost / business +// --------------------------------------------------------------------------- + +export const ForumTopicCreatedSchema = obj({ + name: z.string(), + icon_color: z.number().int(), + icon_custom_emoji_id: z.string().optional(), +}); +export const ForumTopicClosedSchema = obj({}); +export const ForumTopicReopenedSchema = obj({}); +export const ForumTopicEditedSchema = obj({ + name: z.string().optional(), + icon_custom_emoji_id: z.string().optional(), +}); +export const GeneralForumTopicHiddenSchema = obj({}); +export const GeneralForumTopicUnhiddenSchema = obj({}); + +export const VideoChatStartedSchema = obj({}); +export const VideoChatEndedSchema = obj({ duration: z.number().int() }); +export const VideoChatScheduledSchema = obj({ start_date: z.number().int() }); +export const VideoChatParticipantsInvitedSchema = obj({ users: z.array(UserSchema) }); + +export const WebAppDataSchema = obj({ data: z.string(), button_text: z.string() }); + +export const ChatBoostSourceSchema = obj({ + source: z.enum(["premium", "gift_code", "giveaway"]), + user: UserSchema.optional(), + giveaway_message_id: z.number().int().optional(), + prize_star_count: z.number().int().optional(), + is_unclaimed: z.boolean().optional(), +}); + +export const ChatBoostSchema = obj({ + boost_id: z.string(), + add_date: z.number().int(), + expiration_date: z.number().int(), + source: ChatBoostSourceSchema, +}); + +export const ChatBoostUpdatedSchema = obj({ chat: ChatSchema, boost: ChatBoostSchema }); +export const ChatBoostRemovedSchema = obj({ + chat: ChatSchema, + boost_id: z.string(), + remove_date: z.number().int(), + source: ChatBoostSourceSchema, +}); + +export const ChatJoinRequestSchema = obj({ + chat: ChatSchema, + from: UserSchema, + user_chat_id: z.number().int(), + date: z.number().int(), + bio: z.string().optional(), + invite_link: z.unknown().optional(), +}); +export type ChatJoinRequest = z.infer; + +export const BusinessConnectionSchema = obj({ + id: z.string(), + user: UserSchema, + user_chat_id: z.number().int(), + date: z.number().int(), + can_reply: z.boolean(), + is_enabled: z.boolean(), +}); +export type BusinessConnection = z.infer; + +export const BusinessMessagesDeletedSchema = obj({ + business_connection_id: z.string(), + chat: ChatSchema, + message_ids: z.array(z.number().int()), +}); + +// --------------------------------------------------------------------------- +// Chat member updates +// --------------------------------------------------------------------------- + +export const ChatMemberStatusSchema = z.enum([ + "creator", + "administrator", + "member", + "restricted", + "left", + "kicked", +]); + +export const ChatMemberSchema = obj({ + status: ChatMemberStatusSchema, + user: UserSchema, +}).passthrough(); +export type ChatMember = z.infer; + +export const ChatMemberUpdatedSchema = obj({ + chat: ChatSchema, + from: UserSchema, + date: z.number().int(), + old_chat_member: ChatMemberSchema, + new_chat_member: ChatMemberSchema, + invite_link: z.unknown().optional(), + via_join_request: z.boolean().optional(), + via_chat_folder_invite_link: z.boolean().optional(), +}); +export type ChatMemberUpdated = z.infer; + +// --------------------------------------------------------------------------- +// Message +// --------------------------------------------------------------------------- + +export interface Message { + message_id: number; + message_thread_id?: number; + from?: User; + sender_chat?: Chat; + date: number; + chat: Chat; + reply_to_message?: Message; + text?: string; + caption?: string; + entities?: MessageEntity[]; + caption_entities?: MessageEntity[]; + photo?: PhotoSize[]; + audio?: Audio; + document?: Document; + animation?: Animation; + video?: Video; + voice?: Voice; + video_note?: VideoNote; + sticker?: Sticker; + contact?: Contact; + location?: Location; + venue?: Venue; + poll?: Poll; + dice?: Dice; + new_chat_members?: User[]; + left_chat_member?: User; + new_chat_title?: string; + new_chat_photo?: PhotoSize[]; + pinned_message?: Message; + invoice?: Invoice; + successful_payment?: SuccessfulPayment; + reply_markup?: InlineKeyboardMarkup; + [key: string]: unknown; +} + +export const MessageSchema: z.ZodType = z.lazy(() => + obj({ + message_id: z.number().int(), + message_thread_id: z.number().int().optional(), + from: UserSchema.optional(), + sender_chat: ChatSchema.optional(), + date: z.number().int(), + chat: ChatSchema, + reply_to_message: MessageSchema.optional(), + text: z.string().optional(), + caption: z.string().optional(), + entities: z.array(MessageEntitySchema).optional(), + caption_entities: z.array(MessageEntitySchema).optional(), + photo: z.array(PhotoSizeSchema).optional(), + audio: AudioSchema.optional(), + document: DocumentSchema.optional(), + animation: AnimationSchema.optional(), + video: VideoSchema.optional(), + voice: VoiceSchema.optional(), + video_note: VideoNoteSchema.optional(), + sticker: StickerSchema.optional(), + contact: ContactSchema.optional(), + location: LocationSchema.optional(), + venue: VenueSchema.optional(), + poll: PollSchema.optional(), + dice: DiceSchema.optional(), + new_chat_members: z.array(UserSchema).optional(), + left_chat_member: UserSchema.optional(), + new_chat_title: z.string().optional(), + new_chat_photo: z.array(PhotoSizeSchema).optional(), + pinned_message: MessageSchema.optional(), + invoice: InvoiceSchema.optional(), + successful_payment: SuccessfulPaymentSchema.optional(), + reply_markup: InlineKeyboardMarkupSchema.optional(), + }), +); + +export const MessageIdSchema = obj({ message_id: z.number().int() }); +export type MessageId = z.infer; + +// --------------------------------------------------------------------------- +// Updates +// --------------------------------------------------------------------------- + +export const UpdateSchema = obj({ + update_id: z.number().int(), + message: MessageSchema.optional(), + edited_message: MessageSchema.optional(), + channel_post: MessageSchema.optional(), + edited_channel_post: MessageSchema.optional(), + business_connection: BusinessConnectionSchema.optional(), + business_message: MessageSchema.optional(), + edited_business_message: MessageSchema.optional(), + deleted_business_messages: BusinessMessagesDeletedSchema.optional(), + message_reaction: MessageReactionUpdatedSchema.optional(), + message_reaction_count: MessageReactionCountUpdatedSchema.optional(), + inline_query: InlineQuerySchema.optional(), + chosen_inline_result: ChosenInlineResultSchema.optional(), + callback_query: CallbackQuerySchema.optional(), + shipping_query: ShippingQuerySchema.optional(), + pre_checkout_query: PreCheckoutQuerySchema.optional(), + poll: PollSchema.optional(), + poll_answer: PollAnswerSchema.optional(), + my_chat_member: ChatMemberUpdatedSchema.optional(), + chat_member: ChatMemberUpdatedSchema.optional(), + chat_join_request: ChatJoinRequestSchema.optional(), + chat_boost: ChatBoostUpdatedSchema.optional(), + removed_chat_boost: ChatBoostRemovedSchema.optional(), + purchased_paid_media: z.unknown().optional(), +}); +export type Update = z.infer; + +// --------------------------------------------------------------------------- +// Misc top-level results +// --------------------------------------------------------------------------- + +export const WebhookInfoSchema = obj({ + url: z.string(), + has_custom_certificate: z.boolean(), + pending_update_count: z.number().int(), + ip_address: z.string().optional(), + last_error_date: z.number().int().optional(), + last_error_message: z.string().optional(), + last_synchronization_error_date: z.number().int().optional(), + max_connections: z.number().int().optional(), + allowed_updates: z.array(z.string()).optional(), +}); +export type WebhookInfo = z.infer; + +export const BotCommandSchema = obj({ command: z.string(), description: z.string() }); +export type BotCommand = z.infer; + +export const BotNameSchema = obj({ name: z.string() }); +export const BotDescriptionSchema = obj({ description: z.string() }); +export const BotShortDescriptionSchema = obj({ short_description: z.string() }); + +export const ChatInviteLinkSchema = obj({ + invite_link: z.string(), + creator: UserSchema, + creates_join_request: z.boolean(), + is_primary: z.boolean(), + is_revoked: z.boolean(), + name: z.string().optional(), + expire_date: z.number().int().optional(), + member_limit: z.number().int().optional(), + pending_join_request_count: z.number().int().optional(), + subscription_period: z.number().int().optional(), + subscription_price: z.number().int().optional(), +}); +export type ChatInviteLink = z.infer; + +export const ForumTopicSchema = obj({ + message_thread_id: z.number().int(), + name: z.string(), + icon_color: z.number().int(), + icon_custom_emoji_id: z.string().optional(), +}); +export type ForumTopic = z.infer; + +export const UserProfilePhotosSchema = obj({ + total_count: z.number().int(), + photos: z.array(z.array(PhotoSizeSchema)), +}); +export type UserProfilePhotos = z.infer; + +export const InputProfilePhotoStaticSchema = obj({ + type: z.literal("static"), + photo: z.string(), +}); + +export const InputProfilePhotoAnimatedSchema = obj({ + type: z.literal("animated"), + animation: z.string(), + main_frame_timestamp: z.number().optional(), +}); + +export const InputProfilePhotoSchema = z.discriminatedUnion("type", [ + InputProfilePhotoStaticSchema, + InputProfilePhotoAnimatedSchema, +]); +export type InputProfilePhoto = z.infer; + +// --------------------------------------------------------------------------- +// Input media types +// --------------------------------------------------------------------------- + +export const InputMediaAnimationSchema = obj({ + type: z.literal("animation"), + media: z.string(), + thumbnail: z.string().optional(), + caption: z.string().optional(), + parse_mode: ParseModeSchema.optional(), + caption_entities: z.array(MessageEntitySchema).optional(), + show_caption_above_media: z.boolean().optional(), + width: z.number().int().optional(), + height: z.number().int().optional(), + duration: z.number().int().optional(), + has_spoiler: z.boolean().optional(), +}); + +export const InputMediaAudioSchema = obj({ + type: z.literal("audio"), + media: z.string(), + thumbnail: z.string().optional(), + caption: z.string().optional(), + parse_mode: ParseModeSchema.optional(), + caption_entities: z.array(MessageEntitySchema).optional(), + duration: z.number().int().optional(), + performer: z.string().optional(), + title: z.string().optional(), +}); + +export const InputMediaDocumentSchema = obj({ + type: z.literal("document"), + media: z.string(), + thumbnail: z.string().optional(), + caption: z.string().optional(), + parse_mode: ParseModeSchema.optional(), + caption_entities: z.array(MessageEntitySchema).optional(), + disable_content_type_detection: z.boolean().optional(), +}); + +export const InputMediaLivePhotoSchema = obj({ + type: z.literal("live_photo"), + media: z.string(), + photo: z.string(), + caption: z.string().optional(), + parse_mode: ParseModeSchema.optional(), + caption_entities: z.array(MessageEntitySchema).optional(), + show_caption_above_media: z.boolean().optional(), + has_spoiler: z.boolean().optional(), +}); + +export const InputMediaLocationSchema = obj({ + type: z.literal("location"), + latitude: z.number(), + longitude: z.number(), + horizontal_accuracy: z.number().optional(), +}); + +export const InputMediaPhotoSchema = obj({ + type: z.literal("photo"), + media: z.string(), + caption: z.string().optional(), + parse_mode: ParseModeSchema.optional(), + caption_entities: z.array(MessageEntitySchema).optional(), + show_caption_above_media: z.boolean().optional(), + has_spoiler: z.boolean().optional(), +}); + +export const InputMediaStickerSchema = obj({ + type: z.literal("sticker"), + media: z.string(), + emoji: z.string().optional(), +}); + +export const InputMediaVenueSchema = obj({ + type: z.literal("venue"), + latitude: z.number(), + longitude: z.number(), + title: z.string(), + address: z.string(), + foursquare_id: z.string().optional(), + foursquare_type: z.string().optional(), + google_place_id: z.string().optional(), + google_place_type: z.string().optional(), +}); + +export const InputMediaVideoSchema = obj({ + type: z.literal("video"), + media: z.string(), + thumbnail: z.string().optional(), + cover: z.string().optional(), + start_timestamp: z.number().int().optional(), + caption: z.string().optional(), + parse_mode: ParseModeSchema.optional(), + caption_entities: z.array(MessageEntitySchema).optional(), + show_caption_above_media: z.boolean().optional(), + width: z.number().int().optional(), + height: z.number().int().optional(), + duration: z.number().int().optional(), + supports_streaming: z.boolean().optional(), + has_spoiler: z.boolean().optional(), +}); + +/** All InputMedia variants (used by sendMediaGroup and editMessageMedia). */ +export const InputMediaSchema = z.discriminatedUnion("type", [ + InputMediaAnimationSchema, + InputMediaAudioSchema, + InputMediaDocumentSchema, + InputMediaLivePhotoSchema, + InputMediaLocationSchema, + InputMediaPhotoSchema, + InputMediaVideoSchema, +]); + +/** Media that can be attached to an individual poll option. */ +export const InputPollOptionMediaSchema = z.discriminatedUnion("type", [ + InputMediaAnimationSchema, + InputMediaLivePhotoSchema, + InputMediaLocationSchema, + InputMediaPhotoSchema, + InputMediaStickerSchema, + InputMediaVenueSchema, + InputMediaVideoSchema, +]); +export type InputPollOptionMedia = z.infer; + +/** Media that can be attached to a poll description or quiz explanation. */ +export const InputPollMediaSchema = z.discriminatedUnion("type", [ + InputMediaAnimationSchema, + InputMediaAudioSchema, + InputMediaDocumentSchema, + InputMediaLivePhotoSchema, + InputMediaLocationSchema, + InputMediaPhotoSchema, + InputMediaVenueSchema, + InputMediaVideoSchema, +]); +export type InputPollMedia = z.infer; + +export const InputPollOptionSchema = obj({ + text: z.string(), + text_parse_mode: ParseModeSchema.optional(), + text_entities: z.array(MessageEntitySchema).optional(), + media: InputPollOptionMediaSchema.optional(), +}); +export type InputPollOption = z.infer; + +export const SentGuestMessageSchema = obj({ + inline_message_id: z.string(), +}); +export type SentGuestMessage = z.infer; + +export const BotAccessSettingsSchema = obj({ + is_access_restricted: z.boolean(), + added_users: z.array(UserSchema).optional(), +}); +export type BotAccessSettings = z.infer; + +// --------------------------------------------------------------------------- +// Telegram envelope (raw HTTP response) +// --------------------------------------------------------------------------- + +export const TelegramApiResponseSchema = z.object({ + ok: z.boolean(), + result: z.unknown().optional(), + description: z.string().optional(), + error_code: z.number().int().optional(), + parameters: z + .object({ + migrate_to_chat_id: z.number().int().optional(), + retry_after: z.number().int().optional(), + }) + .passthrough() + .optional(), +}); +export type TelegramApiResponse = { + ok: boolean; + result?: T; + description?: string; + error_code?: number; + parameters?: { migrate_to_chat_id?: number; retry_after?: number }; +}; + +// --------------------------------------------------------------------------- +// Message types enum (the events emitted on `message`) +// --------------------------------------------------------------------------- + +export const MESSAGE_TYPES = [ + "text", + "animation", + "audio", + "channel_chat_created", + "contact", + "delete_chat_photo", + "dice", + "document", + "game", + "group_chat_created", + "invoice", + "left_chat_member", + "location", + "migrate_from_chat_id", + "migrate_to_chat_id", + "new_chat_members", + "new_chat_photo", + "new_chat_title", + "passport_data", + "photo", + "pinned_message", + "poll", + "sticker", + "successful_payment", + "supergroup_chat_created", + "video", + "video_note", + "voice", + "video_chat_started", + "video_chat_ended", + "video_chat_participants_invited", + "video_chat_scheduled", + "message_auto_delete_timer_changed", + "chat_invite_link", + "chat_member_updated", + "web_app_data", + "message_reaction", +] as const; + +export type MessageType = (typeof MESSAGE_TYPES)[number]; diff --git a/src/utils.js b/src/utils.js deleted file mode 100644 index a2d36ebf..00000000 --- a/src/utils.js +++ /dev/null @@ -1,3 +0,0 @@ -const util = require('util'); -// Native deprecation warning -exports.deprecate = (msg) => util.deprecate(() => { }, msg, 'node-telegram-bot-api')(); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 00000000..385969b4 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,158 @@ +import { createReadStream, existsSync, type ReadStream } from "node:fs"; +import path from "node:path"; +import { Readable } from "node:stream"; +import util from "node:util"; + +import { FatalError } from "./errors.js"; +import { detectFileType } from "./internal/file-type.js"; +import { lookupMime } from "./internal/mime.js"; + +/** + * Stable JSON-serializer used to convert structured Telegram options + * (entities, reply_markup, reply_parameters, etc.) into the string form + * the Bot API expects on x-www-form-urlencoded / multipart bodies. + * + * Strings are passed through unchanged so callers may opt into + * pre-serialized payloads. + */ +export function stringify(data: unknown): string { + if (typeof data === "string") return data; + return JSON.stringify(data); +} + +/** + * Lightweight `util.deprecate` wrapper. Logs each unique message exactly once. + */ +export const deprecate: (msg: string) => void = (() => { + const issued = new Set(); + return (msg: string) => { + if (issued.has(msg)) return; + issued.add(msg); + util.deprecate(() => {}, msg, "node-telegram-bot-api")(); + }; +})(); + +export type FileInput = string | Buffer | Readable | NodeJS.ReadableStream; + +export interface FileMeta { + filename?: string; + contentType?: string; +} + +export interface PreparedFile { + /** Final value to attach to a multipart form. */ + value: Buffer | Readable | NodeJS.ReadableStream; + filename: string; + contentType: string; +} + +/** + * Best-effort filename extraction from a stream's `path` (when the stream + * was created via `fs.createReadStream`). + */ +function filenameFromStream(stream: NodeJS.ReadableStream | Readable): string | undefined { + const maybe = (stream as ReadStream).path; + if (!maybe) return undefined; + return path.basename(typeof maybe === "string" ? maybe : maybe.toString()); +} + +function isReadable(value: unknown): value is NodeJS.ReadableStream { + return ( + !!value && + typeof value === "object" && + !Buffer.isBuffer(value) && + typeof (value as { pipe?: unknown }).pipe === "function" + ); +} + +/** + * Format an arbitrary file-like input into: + * - a {@link PreparedFile} (multipart upload) OR + * - a string `fileId` / URL (no upload necessary) + * + * @param data the user-supplied value (path, buffer, stream, or fileId/URL) + * @param meta optional filename / content type hints + * @param filepathLookup whether to treat strings as filesystem paths (mirrors + * legacy `options.filepath`). + */ +export async function prepareFile( + data: FileInput | undefined | null, + meta: FileMeta = {}, + filepathLookup = true, +): Promise<{ file: PreparedFile | null; fileId: string | null }> { + if (data === undefined || data === null) { + return { file: null, fileId: null }; + } + + let { filename, contentType } = meta; + + if (Buffer.isBuffer(data)) { + if (!contentType) { + const detected = detectFileType(data); + if (detected) { + contentType = detected.mime; + if (!filename) filename = `data.${detected.ext}`; + } + } + filename = filename ?? "file"; + contentType = contentType ?? lookupMime(filename) ?? "application/octet-stream"; + return { file: { value: data, filename, contentType }, fileId: null }; + } + + if (isReadable(data)) { + if (!filename) filename = filenameFromStream(data) ?? "file"; + contentType = contentType ?? lookupMime(filename) ?? "application/octet-stream"; + return { file: { value: data, filename, contentType }, fileId: null }; + } + + if (typeof data === "string") { + if (filepathLookup && existsSync(data)) { + const stream = createReadStream(data); + filename = filename ?? path.basename(data); + contentType = contentType ?? lookupMime(filename) ?? "application/octet-stream"; + return { file: { value: stream, filename, contentType }, fileId: null }; + } + // Treat as fileId or already-public URL. + return { file: null, fileId: data }; + } + + throw new FatalError(`Unsupported file input: ${typeof data}`); +} + +/** + * Variant for multi-file methods (sendMediaGroup, sendPaidMedia, postStory, …). + */ +export async function prepareFiles( + attachKey: string, + inputs: T[], + defaultMeta: FileMeta = {}, + filepathLookup = true, +): Promise<{ formData: Record; fileIds: Record }> { + const formData: Record = {}; + const fileIds: Record = {}; + + for (let index = 0; index < inputs.length; index++) { + const item = inputs[index]!; + const value = item.media ?? item.data; + const meta = { ...defaultMeta, ...item.fileOptions }; + const { file, fileId } = await prepareFile(value, meta, filepathLookup); + if (file) { + formData[`${attachKey}_${index}`] = file; + } else if (fileId !== null) { + fileIds[index] = fileId; + } + } + + return { formData, fileIds }; +} + +/** + * Read a Node.js stream into a Buffer. + */ +export async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : (chunk as Buffer)); + } + return Buffer.concat(chunks); +} diff --git a/src/webhook.ts b/src/webhook.ts new file mode 100644 index 00000000..f1130394 --- /dev/null +++ b/src/webhook.ts @@ -0,0 +1,189 @@ +import createDebug from "./internal/debug.js"; +import { readFileSync } from "node:fs"; +import http from "node:http"; +import https from "node:https"; +import type { Server as HttpServer, IncomingMessage, ServerResponse } from "node:http"; +import type { Server as HttpsServer } from "node:https"; +import { Buffer } from "node:buffer"; + +import { BaseError, FatalError, ParseError } from "./errors.js"; +import type { TelegramBot } from "./telegram.js"; +import type { Update } from "./types/schemas.js"; + +const debug = createDebug("node-telegram-bot-api:webhook"); + +export interface WebHookOptions { + host?: string; + port?: number; + /** Path to a PEM private key. Read synchronously at construction time. */ + key?: string; + /** Path to a PEM certificate. Read synchronously at construction time. */ + cert?: string; + /** Path to a PFX archive. Read synchronously at construction time. */ + pfx?: string; + /** Raw `https.createServer` options that are merged with key/cert/pfx above. */ + https?: https.ServerOptions; + /** Endpoint that always returns 200 OK — useful for healthchecks. */ + healthEndpoint?: string; + /** Open the webhook automatically when the bot is constructed. */ + autoOpen?: boolean; + /** When set, requests must include this token in `X-Telegram-Bot-Api-Secret-Token`. */ + secretToken?: string; +} + +const MAX_PAYLOAD_BYTES = 50 * 1024 * 1024; // 50MB + +async function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let total = 0; + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => { + total += chunk.length; + if (total > MAX_PAYLOAD_BYTES) { + reject(new Error("Webhook payload exceeds 50MB safety cap")); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + +export class TelegramBotWebHook { + private readonly bot: TelegramBot; + public readonly host: string; + public readonly port: number; + public readonly healthEndpoint: string; + private readonly _secretToken?: string; + private readonly _server: HttpServer | HttpsServer; + private _open = false; + + constructor(bot: TelegramBot, options: WebHookOptions = {}) { + this.bot = bot; + this.host = options.host ?? "0.0.0.0"; + this.port = options.port ?? 8443; + this.healthEndpoint = options.healthEndpoint ?? "/healthz"; + this._secretToken = options.secretToken; + + const httpsOptions: https.ServerOptions = { ...(options.https ?? {}) }; + + if (options.key && options.cert) { + debug("HTTPS WebHook enabled (by key/cert)"); + httpsOptions.key = readFileSync(options.key); + httpsOptions.cert = readFileSync(options.cert); + this._server = https.createServer(httpsOptions, this._handleRequest); + } else if (options.pfx) { + debug("HTTPS WebHook enabled (by pfx)"); + httpsOptions.pfx = readFileSync(options.pfx); + this._server = https.createServer(httpsOptions, this._handleRequest); + } else if (Object.keys(httpsOptions).length) { + debug("HTTPS WebHook enabled (by https options)"); + this._server = https.createServer(httpsOptions, this._handleRequest); + } else { + debug("HTTP WebHook enabled"); + this._server = http.createServer(this._handleRequest); + } + } + + /** Begin listening for incoming Telegram webhook requests. */ + async open(): Promise { + if (this.isOpen()) return; + await new Promise((resolve, reject) => { + const onError = (err: Error) => reject(err); + this._server.once("error", onError); + this._server.listen(this.port, this.host, () => { + this._server.off("error", onError); + debug("WebHook listening on %s:%s", this.host, this.port); + this._open = true; + resolve(); + }); + }); + } + + /** Stop accepting requests. Resolves once existing connections drain. */ + async close(): Promise { + if (!this.isOpen()) return; + await new Promise((resolve, reject) => { + this._server.close((err) => { + if (err) { + reject(err); + return; + } + this._open = false; + resolve(); + }); + }); + } + + isOpen(): boolean { + return this._open; + } + + private _emitError(err: unknown): void { + if (!this.bot.listeners("webhook_error").length) { + // eslint-disable-next-line no-console + console.error(`${new Date().toISOString()} error: [webhook_error] %j`, err); + return; + } + this.bot.emit("webhook_error", err); + } + + private readonly _handleRequest = async (req: IncomingMessage, res: ServerResponse) => { + debug("WebHook request URL: %s", req.url ?? ""); + + const url = req.url ?? ""; + const pathname = url.split("?")[0]!; + + if (pathname === this.healthEndpoint) { + debug("WebHook health check passed"); + res.statusCode = 200; + res.end("OK"); + return; + } + + if (!pathname.includes(this.bot.token)) { + debug("WebHook request unauthorized"); + res.statusCode = 401; + res.end(); + return; + } + + if (req.method !== "POST") { + debug("WebHook request isn't a POST"); + res.statusCode = 418; // I'm a teabot! + res.end(); + return; + } + + if (this._secretToken) { + const provided = req.headers["x-telegram-bot-api-secret-token"]; + if (provided !== this._secretToken) { + debug("WebHook secret-token mismatch"); + res.statusCode = 401; + res.end(); + return; + } + } + + try { + const buffer = await readBody(req); + let update: Update; + try { + update = JSON.parse(buffer.toString("utf8")) as Update; + } catch (parseError) { + this._emitError(new ParseError((parseError as Error).message)); + res.statusCode = 400; + res.end("Bad Request"); + return; + } + this.bot.processUpdate(update); + res.end("OK"); + } catch (err) { + this._emitError(err instanceof BaseError ? err : new FatalError(err as Error)); + res.statusCode = 500; + res.end("Server Error"); + } + }; +} diff --git a/test/data/live_photo.mp4 b/test/data/live_photo.mp4 new file mode 100644 index 00000000..757e215d Binary files /dev/null and b/test/data/live_photo.mp4 differ diff --git a/test/data/photo_live_photo.jpg b/test/data/photo_live_photo.jpg new file mode 100644 index 00000000..c8f7f21e Binary files /dev/null and b/test/data/photo_live_photo.jpg differ diff --git a/test/integration/telegram.test.ts b/test/integration/telegram.test.ts new file mode 100644 index 00000000..5179dc8b --- /dev/null +++ b/test/integration/telegram.test.ts @@ -0,0 +1,734 @@ +/** + * Integration tests against the real Telegram Bot API. + * + * Required environment variables: + * - NODE_TELEGRAM_TOKEN — the Bot token used for the run. + * - TEST_GROUP_ID — chat id where messages can be sent (group or private). + * - TEST_USER_ID — a user id the bot can resolve in TEST_GROUP_ID. + * + * Optional: + * - TEST_STICKER_SET_NAME — a known public sticker set name (defaults to "pusheen"). + * - TEST_CUSTOM_EMOJI_ID — a custom emoji id (skips the test if unset). + * + * The suite hits api.telegram.org directly. Tests that would mutate + * irreversible bot configuration (logOut, close, deleteWebHook, setMyName, + * setMyProfilePhoto, removeMyProfilePhoto, deleteStickerSet, etc.) are + * deliberately skipped. + */ + +import { after, afterEach, before, describe, it } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + TelegramBot, + ChatSchema, + ChatInviteLinkSchema, + ChatMemberSchema, + FileSchema, + MessageSchema, + PollSchema, + StickerSetSchema, + UserProfilePhotosSchema, + UserSchema, + WebhookInfoSchema, +} from "../../src/index.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const DATA_DIR = path.join(__dirname, "..", "data"); +const PHOTO_PATH = path.join(DATA_DIR, "photo.png"); +const LIVE_PHOTO_PATH = path.join(DATA_DIR, "live_photo.mp4"); +const PHOTO_FOR_LIVE_PHOTO_PATH = path.join(DATA_DIR, "photo_live_photo.jpg"); +const PHOTO_GIF_PATH = path.join(DATA_DIR, "photo.gif"); +const AUDIO_PATH = path.join(DATA_DIR, "audio.mp3"); +const VIDEO_PATH = path.join(DATA_DIR, "video.mp4"); +const VOICE_PATH = path.join(DATA_DIR, "voice.ogg"); +const STICKER_PATH = path.join(DATA_DIR, "sticker.png"); +const STICKER_THUMB_PATH = path.join(DATA_DIR, "sticker_thumb.png"); + +const TOKEN = process.env.NODE_TELEGRAM_TOKEN ?? process.env.TEST_TELEGRAM_TOKEN; +const GROUP_ID_RAW = process.env.TEST_GROUP_ID; +const USER_ID_RAW = process.env.TEST_USER_ID; +const STICKER_SET_NAME = process.env.TEST_STICKER_SET_NAME ?? "pusheen"; +const CUSTOM_EMOJI_ID = process.env.TEST_CUSTOM_EMOJI_ID; + +if (!TOKEN) { + throw new Error( + "NODE_TELEGRAM_TOKEN is required to run integration tests against api.telegram.org.", + ); +} +if (!GROUP_ID_RAW) { + throw new Error("TEST_GROUP_ID is required to run integration tests."); +} +if (!USER_ID_RAW) { + throw new Error("TEST_USER_ID is required to run integration tests."); +} + +// Telegram group/supergroup chat ids are negative. Accept TEST_GROUP_ID with +// or without the leading minus and normalize to the canonical negative form. +const GROUP_ID_PARSED = Number(GROUP_ID_RAW); +const GROUP_ID: number = GROUP_ID_PARSED > 0 ? -GROUP_ID_PARSED : GROUP_ID_PARSED; +const USER_ID: number = Number(USER_ID_RAW); + +const TIMESTAMP = Date.now(); + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Portable test-skip helper. Bun's node:test shim does not yet implement + * `t.skip()` and throws `NotImplementedError`; on Bun the test simply + * passes with no assertions, which is acceptable for our soft-skips. + */ +function softSkip(t: { skip: (reason?: string) => void }, reason: string): void { + try { + t.skip(reason); + } catch { + // Bun: skip() is not implemented — let the test pass quietly. + } +} + +describe("Telegram Bot API (integration)", () => { + const bot = new TelegramBot(TOKEN, { request: { timeoutMs: 60_000 } }); + + // Send one photo up front; we reuse its file_id across tests that need + // a Telegram-hosted file (sendPhoto from id, getFile, getFileLink, ...). + let photoFileId: string; + + before(async () => { + const sent = await bot.sendPhoto(GROUP_ID, PHOTO_PATH); + if (!sent.photo || sent.photo.length === 0) { + throw new Error("expected sendPhoto to return a non-empty photo array"); + } + photoFileId = sent.photo[sent.photo.length - 1]!.file_id; + }); + + afterEach(async () => { + // Throttle to stay under Telegram's per-chat ~1 msg/sec rate limit. + await sleep(1100); + }); + + after(async () => { + await bot.stopPolling().catch(() => undefined); + }); + + // --- Bot identity ------------------------------------------------------ + + describe("Bot identity", () => { + it("getMe() returns a User describing the bot", async () => { + const result = await bot.getMe(); + UserSchema.parse(result); + assert.equal(result.is_bot, true); + assert.equal(typeof result.id, "number"); + }); + + it("getMyName() returns an object with a name string", async () => { + const result = await bot.getMyName(); + assert.equal(typeof result.name, "string"); + }); + + it("getMyDescription() returns an object with a description string", async () => { + const result = await bot.getMyDescription(); + assert.equal(typeof result.description, "string"); + }); + + it("getMyShortDescription() returns an object with a short_description string", async () => { + const result = await bot.getMyShortDescription(); + assert.equal(typeof result.short_description, "string"); + }); + + it("getMyDefaultAdministratorRights() returns a rights object", async () => { + const rights = await bot.getMyDefaultAdministratorRights(); + assert.equal(typeof rights, "object"); + }); + + it("getChatMenuButton() returns a MenuButton object", async () => { + const button = await bot.getChatMenuButton(); + assert.equal(typeof button, "object"); + }); + }); + + // --- Webhook / updates ------------------------------------------------- + + describe("Webhook & updates", () => { + it("getWebHookInfo() returns a WebhookInfo that validates against the schema", async () => { + const info = await bot.getWebHookInfo(); + WebhookInfoSchema.parse(info); + assert.equal(typeof info.url, "string"); + assert.equal(typeof info.has_custom_certificate, "boolean"); + assert.equal(typeof info.pending_update_count, "number"); + }); + + it("getUpdates() with timeout=0 returns an Array", async () => { + const updates = await bot.getUpdates({ timeout: 0, limit: 1 }); + assert.ok(Array.isArray(updates)); + }); + }); + + // --- Sending text-like content ---------------------------------------- + + describe("Sending messages", () => { + it("sendMessage() sends a plain text message", async () => { + const sent = await bot.sendMessage(GROUP_ID, `hello ${TIMESTAMP}`); + MessageSchema.parse(sent); + assert.equal(sent.text, `hello ${TIMESTAMP}`); + }); + + it("sendMessage() honors parse_mode and reply_markup", async () => { + const sent = await bot.sendMessage(GROUP_ID, "*bold* text", { + parse_mode: "Markdown", + reply_markup: { + inline_keyboard: [[{ text: "btn", callback_data: "noop" }]], + }, + }); + MessageSchema.parse(sent); + assert.ok(sent.reply_markup); + }); + + it("sendChatAction() returns true", async () => { + const ok = await bot.sendChatAction(GROUP_ID, "typing"); + assert.equal(ok, true); + }); + + it("sendDice() returns a Message with a dice value", async () => { + const sent = await bot.sendDice(GROUP_ID); + MessageSchema.parse(sent); + assert.ok(sent.dice); + assert.equal(typeof sent.dice!.value, "number"); + }); + + it("sendLocation() returns a Message with a location", async () => { + const sent = await bot.sendLocation(GROUP_ID, 47.5351072, -52.7508537); + MessageSchema.parse(sent); + assert.ok(sent.location); + }); + + it("sendVenue() returns a Message with a venue", async () => { + const sent = await bot.sendVenue( + GROUP_ID, + 47.5351072, + -52.7508537, + "Venue Title", + "Venue Address", + ); + MessageSchema.parse(sent); + assert.ok(sent.venue); + }); + + it("sendPoll() returns a Message with a Poll (skipped if chat disallows polls)", async (t) => { + try { + const sent = await bot.sendPoll(GROUP_ID, "Choose:", [{ text: "A" }, { text: "B" }, { text: "C" }], { + is_anonymous: true, + }); + MessageSchema.parse(sent); + assert.ok(sent.poll); + PollSchema.parse(sent.poll); + } catch (err: unknown) { + const code = (err as { code?: string }).code; + if (code !== "ETELEGRAM") throw err; + softSkip(t, "chat does not permit polls"); + } + }); + }); + + // --- File sending: every variant ------------------------------------- + + describe("Sending files", () => { + it("sendPhoto() from a filesystem path", async () => { + const sent = await bot.sendPhoto(GROUP_ID, PHOTO_PATH); + MessageSchema.parse(sent); + assert.ok(Array.isArray(sent.photo)); + }); + + it("sendPhoto() from a Buffer", async () => { + const buf = fs.readFileSync(PHOTO_PATH); + const sent = await bot.sendPhoto(GROUP_ID, buf, {}, { filename: "photo.png" }); + MessageSchema.parse(sent); + assert.ok(Array.isArray(sent.photo)); + }); + + it("sendPhoto() from a Readable stream", async () => { + const stream = fs.createReadStream(PHOTO_PATH); + const sent = await bot.sendPhoto(GROUP_ID, stream); + MessageSchema.parse(sent); + assert.ok(Array.isArray(sent.photo)); + }); + + it("sendPhoto() from a previously-uploaded file_id", async () => { + const sent = await bot.sendPhoto(GROUP_ID, photoFileId); + MessageSchema.parse(sent); + assert.ok(Array.isArray(sent.photo)); + }); + + it("sendLivePhoto() from a filesystem path", async () => { + const sent = await bot.sendLivePhoto(GROUP_ID, LIVE_PHOTO_PATH, PHOTO_FOR_LIVE_PHOTO_PATH); + MessageSchema.parse(sent); + assert.ok(Array.isArray(sent.photo)); + assert.ok(sent.live_photo); + }); + + it("sendAudio() from a filesystem path", async () => { + const sent = await bot.sendAudio(GROUP_ID, AUDIO_PATH); + MessageSchema.parse(sent); + assert.ok(sent.audio); + }); + + it("sendDocument() from a filesystem path", async () => { + const sent = await bot.sendDocument(GROUP_ID, PHOTO_PATH); + MessageSchema.parse(sent); + assert.ok(sent.document); + }); + + it("sendVideo() from a filesystem path", async () => { + const sent = await bot.sendVideo(GROUP_ID, VIDEO_PATH); + MessageSchema.parse(sent); + assert.ok(sent.video); + }); + + it("sendAnimation() from a filesystem path (gif)", async (t) => { + try { + const sent = await bot.sendAnimation(GROUP_ID, PHOTO_GIF_PATH); + MessageSchema.parse(sent); + assert.ok(sent.animation || sent.document); + } catch (err: unknown) { + const code = (err as { code?: string }).code; + if (code !== "ETELEGRAM") throw err; + softSkip(t, "chat does not permit GIFs/animations"); + } + }); + + it("sendVoice() from a filesystem path", async () => { + const sent = await bot.sendVoice(GROUP_ID, VOICE_PATH); + MessageSchema.parse(sent); + assert.ok(sent.voice); + }); + + it("sendVideoNote() from a Buffer (skipped if Telegram rejects the format)", async (t) => { + const buf = fs.readFileSync(VIDEO_PATH); + let sent; + try { + sent = await bot.sendVideoNote(GROUP_ID, buf, {}, { filename: "video.mp4" }); + } catch (err: unknown) { + const code = (err as { code?: string }).code; + if (code !== "ETELEGRAM") throw err; + softSkip(t, "video_note rejected by Telegram (must be a square video)"); + return; + } + MessageSchema.parse(sent); + // Telegram occasionally classifies non-square clips as plain video. + // Either branch demonstrates the round-trip succeeded. + assert.ok(sent.video_note || sent.video); + }); + + it("sendSticker() from a filesystem path", async (t) => { + try { + const sent = await bot.sendSticker(GROUP_ID, STICKER_PATH); + MessageSchema.parse(sent); + assert.ok(sent.sticker); + } catch (err: unknown) { + const code = (err as { code?: string }).code; + if (code !== "ETELEGRAM") throw err; + softSkip(t, "chat does not permit stickers"); + } + }); + + it("sendMediaGroup() returns an array of Message", async () => { + const sent = await bot.sendMediaGroup(GROUP_ID, [ + { type: "photo", media: PHOTO_PATH }, + { type: "photo", media: photoFileId }, + ]); + assert.ok(Array.isArray(sent)); + assert.equal(sent.length, 2); + sent.forEach((m) => MessageSchema.parse(m)); + }); + }); + + // --- File downloads -------------------------------------------------- + + describe("File metadata & downloads", () => { + it("getFile() returns a TelegramFile that validates", async () => { + const file = await bot.getFile(photoFileId); + FileSchema.parse(file); + assert.equal(file.file_id, photoFileId); + }); + + it("getFileLink() returns an https URL pointing to the file path", async () => { + const link = await bot.getFileLink(photoFileId); + assert.match(link, /^https:\/\/api\.telegram\.org\/file\/bot/); + }); + + it("downloadFile() writes the file under the destination directory", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tg-int-")); + try { + const filePath = await bot.downloadFile(photoFileId, dir); + assert.ok(fs.statSync(filePath).size > 0); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("getFileStream() emits 'info' and streams the bytes", async () => { + const stream = bot.getFileStream(photoFileId); + let infoUri: string | undefined; + stream.on("info", (info: { uri: string }) => { + infoUri = info.uri; + }); + const chunks: Buffer[] = []; + for await (const chunk of stream as AsyncIterable) { + chunks.push(chunk); + } + assert.ok(infoUri && infoUri.startsWith("https://")); + assert.ok(Buffer.concat(chunks).length > 0); + }); + }); + + // --- Forwarding & copying --------------------------------------------- + + describe("Forwarding & copying", () => { + let messageId: number; + + before(async () => { + const sent = await bot.sendMessage(GROUP_ID, `forward-source ${TIMESTAMP}`); + messageId = sent.message_id; + }); + + it("forwardMessage() forwards a single message", async () => { + const sent = await bot.forwardMessage(GROUP_ID, GROUP_ID, messageId); + MessageSchema.parse(sent); + }); + + it("copyMessage() returns a MessageId", async () => { + const result = await bot.copyMessage(GROUP_ID, GROUP_ID, messageId); + assert.equal(typeof result.message_id, "number"); + }); + + it("forwardMessages() forwards an array of messages", async () => { + const a = (await bot.sendMessage(GROUP_ID, "fwd-1")).message_id; + const b = (await bot.sendMessage(GROUP_ID, "fwd-2")).message_id; + const result = await bot.forwardMessages(GROUP_ID, GROUP_ID, [a, b]); + assert.ok(Array.isArray(result)); + assert.equal(result.length, 2); + }); + + it("copyMessages() copies an array of messages", async () => { + const a = (await bot.sendMessage(GROUP_ID, "cpy-1")).message_id; + const b = (await bot.sendMessage(GROUP_ID, "cpy-2")).message_id; + const result = await bot.copyMessages(GROUP_ID, GROUP_ID, [a, b]); + assert.ok(Array.isArray(result)); + assert.equal(result.length, 2); + }); + }); + + // --- Editing & deleting ---------------------------------------------- + + describe("Editing & deleting", () => { + it("editMessageText() updates a previously-sent message", async () => { + const sent = await bot.sendMessage(GROUP_ID, `edit-me ${TIMESTAMP}`); + const edited = await bot.editMessageText("edited!", { + chat_id: GROUP_ID, + message_id: sent.message_id, + }); + // editMessageText returns Message | true. Assert one of those is true. + assert.ok(edited === true || (typeof edited === "object" && (edited as { text?: string }).text === "edited!")); + }); + + it("editMessageCaption() updates a photo caption", async () => { + const sent = await bot.sendPhoto(GROUP_ID, photoFileId, { caption: "before" }); + const edited = await bot.editMessageCaption("after", { + chat_id: GROUP_ID, + message_id: sent.message_id, + }); + assert.ok(edited === true || typeof edited === "object"); + }); + + it("editMessageReplyMarkup() updates an inline keyboard", async () => { + const sent = await bot.sendMessage(GROUP_ID, "with-buttons", { + reply_markup: { + inline_keyboard: [[{ text: "a", callback_data: "a" }]], + }, + }); + const edited = await bot.editMessageReplyMarkup( + { inline_keyboard: [[{ text: "b", callback_data: "b" }]] }, + { chat_id: GROUP_ID, message_id: sent.message_id }, + ); + assert.ok(edited === true || typeof edited === "object"); + }); + + it("editMessageMedia() replaces a photo with a new file via attach://", async () => { + // Start from a different photo so the edit actually changes the message + // (Telegram rejects no-op edits with "message is not modified"). + const sent = await bot.sendPhoto(GROUP_ID, STICKER_THUMB_PATH); + const edited = await bot.editMessageMedia( + { type: "photo", media: `attach://${PHOTO_PATH}` }, + { chat_id: GROUP_ID, message_id: sent.message_id }, + ); + assert.ok(edited === true || typeof edited === "object"); + }); + + it("editMessageMedia() replaces a photo using a Telegram file_id", async () => { + // Send a different image first so swapping in `photoFileId` is a real change. + const sent = await bot.sendPhoto(GROUP_ID, STICKER_THUMB_PATH); + const edited = await bot.editMessageMedia( + { type: "photo", media: photoFileId }, + { chat_id: GROUP_ID, message_id: sent.message_id }, + ); + assert.ok(edited === true || typeof edited === "object"); + }); + + it("deleteMessage() removes a message", async () => { + const sent = await bot.sendMessage(GROUP_ID, "to-delete"); + const ok = await bot.deleteMessage(GROUP_ID, sent.message_id); + assert.equal(ok, true); + }); + + it("deleteMessages() removes a batch", async () => { + const a = (await bot.sendMessage(GROUP_ID, "to-delete-1")).message_id; + const b = (await bot.sendMessage(GROUP_ID, "to-delete-2")).message_id; + const ok = await bot.deleteMessages(GROUP_ID, [a, b]); + assert.equal(ok, true); + }); + + it("setMessageReaction() adds a reaction", async () => { + const sent = await bot.sendMessage(GROUP_ID, "react-to-me"); + const ok = await bot.setMessageReaction(GROUP_ID, sent.message_id, { + reaction: [{ type: "emoji", emoji: "👍" }], + }); + assert.equal(ok, true); + }); + + it("deleteMessageReaction() removes the bot's reaction", async (t) => { + const sent = await bot.sendMessage(GROUP_ID, "react-then-undo"); + await bot.setMessageReaction(GROUP_ID, sent.message_id, { + reaction: [{ type: "emoji", emoji: "👍" }], + }); + try { + const ok = await bot.deleteMessageReaction(GROUP_ID, sent.message_id); + assert.equal(ok, true); + } catch (err: unknown) { + const code = (err as { code?: string }).code; + if (code !== "ETELEGRAM") throw err; + softSkip(t, "deleteMessageReaction not available in this chat / API version"); + } + }); + + it("deleteAllMessageReactions() clears chat-wide reactions added by the bot", async (t) => { + const sent = await bot.sendMessage(GROUP_ID, "clear-all-reactions"); + await bot.setMessageReaction(GROUP_ID, sent.message_id, { + reaction: [{ type: "emoji", emoji: "🔥" }], + }); + try { + const ok = await bot.deleteAllMessageReactions(GROUP_ID); + assert.equal(ok, true); + } catch (err: unknown) { + const code = (err as { code?: string }).code; + if (code !== "ETELEGRAM") throw err; + softSkip(t, "deleteAllMessageReactions requires can_delete_messages admin rights"); + } + }); + + it("stopPoll() stops a previously-sent poll (skipped if chat disallows polls)", async (t) => { + let sentMessageId: number; + try { + const sent = await bot.sendPoll(GROUP_ID, "stoppable?", [{ text: "yes" }, { text: "no" }]); + sentMessageId = sent.message_id; + } catch (err: unknown) { + const code = (err as { code?: string }).code; + if (code !== "ETELEGRAM") throw err; + softSkip(t, "chat does not permit polls"); + return; + } + try { + const stopped = await bot.stopPoll(GROUP_ID, sentMessageId); + PollSchema.parse(stopped); + } catch (err: unknown) { + const code = (err as { code?: string }).code; + if (code !== "ETELEGRAM") throw err; + // Telegram rejects stopPoll on polls the bot didn't create or that + // are otherwise un-stoppable in this chat. The roundtrip above + // already proved the wire format works. + softSkip(t, "poll could not be stopped by the bot in this chat"); + } + }); + }); + + // --- Chat info / membership ------------------------------------------ + + describe("Chat info", () => { + it("getChat() returns a Chat object", async () => { + const chat = await bot.getChat(GROUP_ID); + ChatSchema.parse(chat); + assert.equal(chat.id, GROUP_ID); + }); + + it("getChatMember() returns a ChatMember", async () => { + const member = await bot.getChatMember(GROUP_ID, USER_ID); + ChatMemberSchema.parse(member); + }); + + it("getChatAdministrators() returns an Array (empty in private chats)", async () => { + try { + const admins = await bot.getChatAdministrators(GROUP_ID); + assert.ok(Array.isArray(admins)); + } catch (err: unknown) { + // Telegram returns Bad Request: method is available only for groups + // and supergroup chats. Treat as a soft pass when running against a + // private chat. + const code = (err as { code?: string }).code; + assert.equal(code, "ETELEGRAM"); + } + }); + + it("getChatMemberCount() returns an Integer (or rejects on private chats)", async () => { + try { + const count = await bot.getChatMemberCount(GROUP_ID); + assert.equal(typeof count, "number"); + } catch (err: unknown) { + const code = (err as { code?: string }).code; + assert.equal(code, "ETELEGRAM"); + } + }); + + it("getUserProfilePhotos() returns a UserProfilePhotos object", async () => { + const photos = await bot.getUserProfilePhotos(USER_ID); + UserProfilePhotosSchema.parse(photos); + assert.equal(typeof photos.total_count, "number"); + }); + }); + + // --- Chat invite links (group/supergroup-only) ----------------------- + + describe("Chat invite links", () => { + it("createChatInviteLink → editChatInviteLink → revokeChatInviteLink round-trip", async (t) => { + let created: { invite_link: string }; + try { + created = await bot.createChatInviteLink(GROUP_ID, { + name: `link-${TIMESTAMP}`, + }); + } catch (err: unknown) { + // Private chats / chats where the bot isn't an admin can't create + // invite links. Skip rather than fail. + const code = (err as { code?: string }).code; + assert.equal(code, "ETELEGRAM"); + softSkip(t, "invite link APIs require an admin bot in a group/supergroup/channel"); + return; + } + ChatInviteLinkSchema.parse(created); + + const edited = await bot.editChatInviteLink(GROUP_ID, created.invite_link, { + name: `link-${TIMESTAMP}-edited`, + }); + ChatInviteLinkSchema.parse(edited); + + const revoked = await bot.revokeChatInviteLink(GROUP_ID, created.invite_link); + ChatInviteLinkSchema.parse(revoked); + assert.equal(revoked.is_revoked, true); + }); + }); + + // --- Bot self-management (idempotent operations only) ---------------- + + describe("Bot self-management", () => { + it("setMyCommands() / getMyCommands() / deleteMyCommands() round-trip", async () => { + const commands = [ + { command: "ping", description: "ping the bot" }, + { command: "help", description: "show help" }, + ]; + assert.equal(await bot.setMyCommands(commands), true); + const fetched = await bot.getMyCommands(); + assert.ok(Array.isArray(fetched)); + assert.ok(fetched.some((c) => c.command === "ping")); + assert.equal(await bot.deleteMyCommands(), true); + }); + + it("setMyDescription() / getMyDescription() round-trip", async () => { + const original = (await bot.getMyDescription()).description; + const sample = `desc-${TIMESTAMP}`; + assert.equal(await bot.setMyDescription({ description: sample }), true); + const after = await bot.getMyDescription(); + assert.equal(after.description, sample); + // Restore. + await bot.setMyDescription({ description: original }); + }); + + it("setMyShortDescription() / getMyShortDescription() round-trip", async () => { + const original = (await bot.getMyShortDescription()).short_description; + const sample = `short-${TIMESTAMP}`; + assert.equal(await bot.setMyShortDescription({ short_description: sample }), true); + const after = await bot.getMyShortDescription(); + assert.equal(after.short_description, sample); + // Restore. + await bot.setMyShortDescription({ short_description: original }); + }); + }); + + // --- Stickers -------------------------------------------------------- + + describe("Stickers", () => { + it("getStickerSet() returns a StickerSet for a known public set", async () => { + const set = await bot.getStickerSet(STICKER_SET_NAME); + StickerSetSchema.parse(set); + assert.ok(set.stickers.length > 0); + }); + + it("getCustomEmojiStickers() returns an Array", async (t) => { + if (!CUSTOM_EMOJI_ID) { + softSkip(t, "TEST_CUSTOM_EMOJI_ID not provided"); + return; + } + const stickers = await bot.getCustomEmojiStickers([CUSTOM_EMOJI_ID]); + assert.ok(Array.isArray(stickers)); + }); + }); + + // --- Text/reply listeners (in-process) ------------------------------- + + describe("In-process listeners", () => { + it("onText() registers and removeTextListener() unregisters a callback", () => { + const localBot = new TelegramBot(TOKEN); + const regex = /^\/ping/; + const cb = () => { }; + localBot.onText(regex, cb); + const removed = localBot.removeTextListener(regex); + assert.ok(removed); + assert.equal(removed!.regexp.source, regex.source); + }); + + it("removeTextListener() returns null for an unknown regex", () => { + const localBot = new TelegramBot(TOKEN); + assert.equal(localBot.removeTextListener(/nope/), null); + }); + + it("onReplyToMessage() returns an id; removeReplyListener() returns the entry", () => { + const localBot = new TelegramBot(TOKEN); + const id = localBot.onReplyToMessage(GROUP_ID, 1, () => { }); + const entry = localBot.removeReplyListener(id); + assert.ok(entry); + assert.equal(entry!.id, id); + }); + + it("clearReplyListeners() removes all listeners", () => { + const localBot = new TelegramBot(TOKEN); + localBot.onReplyToMessage(GROUP_ID, 1, () => { }); + localBot.onReplyToMessage(GROUP_ID, 2, () => { }); + const cleared = localBot.clearReplyListeners(); + assert.equal(cleared.length, 2); + }); + }); + + // --- Errors ---------------------------------------------------------- + + describe("Errors", () => { + it("sendMessage() to chat id 0 raises ETELEGRAM", async () => { + await assert.rejects(bot.sendMessage(0, "should not arrive"), (err: unknown) => { + const code = (err as { code?: string }).code; + assert.equal(code, "ETELEGRAM"); + return true; + }); + }); + }); +}); diff --git a/test/mocha.opts b/test/mocha.opts deleted file mode 100644 index c7f44ab8..00000000 --- a/test/mocha.opts +++ /dev/null @@ -1,3 +0,0 @@ ---reporter spec ---require babel-register ---timeout 30000 diff --git a/test/run-unit.mjs b/test/run-unit.mjs new file mode 100644 index 00000000..21785c0d --- /dev/null +++ b/test/run-unit.mjs @@ -0,0 +1,23 @@ +// Cross-platform unit-test runner. +// Windows cmd.exe doesn't expand globs, and `node --test` only added native +// glob support in v22 — so on Node 18/20 we resolve the file list ourselves +// and forward to `node --test --import tsx `. +import { readdirSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const unitDir = join(here, "unit"); + +const files = readdirSync(unitDir) + .filter((name) => name.endsWith(".test.ts")) + .map((name) => join(unitDir, name)); + +const result = spawnSync( + process.execPath, + ["--test", "--test-reporter=spec", "--import", "tsx", ...files], + { stdio: "inherit" }, +); + +process.exit(result.status ?? 1); diff --git a/test/telegram.js b/test/telegram.js deleted file mode 100644 index 8677ae23..00000000 --- a/test/telegram.js +++ /dev/null @@ -1,2276 +0,0 @@ -const TelegramBot = require('..'); -const request = require('@cypress/request-promise'); -const assert = require('assert'); -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const stream = require('stream'); -const is = require('is'); -const utils = require('./utils'); -const isCI = require('is-ci'); -const concat = require('concat-stream'); - -// Allows self-signed certificates to be used in our tests -process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; - -const TOKEN = process.env.TEST_TELEGRAM_TOKEN || '730721138:AAEXxx6JH4tSUk4TNrnV99C5PVi7DHEa5vw'; -if (!TOKEN) { - throw new Error('Bot token not provided'); -} - -const PROVIDER_TOKEN = process.env.TEST_PROVIDER_TOKEN || '284685063:TEST:ODMyZDI5Y2ZmMDE5'; -if (!PROVIDER_TOKEN && !isCI) { // If is not running in Travis / Appveyor - throw new Error('Provider token not supplied'); -} - -// Telegram service if not User Id -const USERID = process.env.TEST_USER_ID || 260280003; -const GROUPID = process.env.TEST_GROUP_ID || -1001300564902; -const GAME_SHORT_NAME = process.env.TEST_GAME_SHORT_NAME || 'medusalab_test'; -const STICKER_SET_NAME = process.env.TEST_STICKER_SET_NAME || 'pusheen'; -const CURRENT_TIMESTAMP = Date.now(); -const timeout = 60 * 1000; -let portindex = 8091; -const staticPort = portindex++; -const pollingPort = portindex++; -const webHookPort = portindex++; -const pollingPort2 = portindex++; -const webHookPort2 = portindex++; -const badTgServerPort = portindex++; -const staticUrl = `http://127.0.0.1:${staticPort}`; -const key = `${__dirname}/../examples/ssl/key.pem`; -const cert = `${__dirname}/../examples/ssl/crt.pem`; -const ip = '216.58.210.174'; // Google IP ¯\_(ツ)_/¯ -const lat = 47.5351072; -const long = -52.7508537; -const FILE_PATH = `${__dirname}/data/photo.png`; -let FILE_ID; -let GAME_CHAT_ID; -let GAME_MSG_ID; -let BOT_USERNAME; -let CHAT_INFO; -let STICKER_FILE_ID_FROM_SET; -let STICKERS_FROM_BOT_SET; - -before(function beforeAll() { - utils.startStaticServer(staticPort); - return utils.startMockServer(pollingPort) - .then(() => { - return utils.startMockServer(pollingPort2); - }).then(() => { - return utils.startMockServer(badTgServerPort, { bad: true }); - }); -}); - - -describe('module.exports', function moduleExportsSuite() { - const nodeVersion = parseInt(process.versions.node.split('.')[0], 10); - it('is loaded from src/ on Node.js v6+ and above', function test() { - if (nodeVersion <= 5) this.skip(); // skip on Node.js v5 and below - assert.strictEqual(TelegramBot, require('../src/telegram')); - }); - it('is loaded from lib/ on Node.js v5 and below', function test() { - if (nodeVersion > 5) this.skip(); // skip on newer versions - assert.strictEqual(TelegramBot, require('../lib/telegram')); - }); -}); - - -describe('TelegramBot', function telegramSuite() { - let bot; - let testbot; - let botPolling; - let botWebHook; - - before(function beforeAll() { - this.timeout(timeout); - bot = new TelegramBot(TOKEN); - testbot = new TelegramBot(TOKEN, { - baseApiUrl: `http://127.0.0.1:${pollingPort}`, - polling: { - autoStart: false, - }, - webHook: { - autoOpen: false, - port: webHookPort, - }, - }); - botPolling = new TelegramBot(TOKEN, { - baseApiUrl: `http://127.0.0.1:${pollingPort2}`, - polling: true, - }); - botWebHook = new TelegramBot(TOKEN, { - webHook: { - port: webHookPort2, - }, - }); - - utils.handleRatelimit(bot, 'sendPhoto', this); - utils.handleRatelimit(bot, 'sendMessage', this); - utils.handleRatelimit(bot, 'sendGame', this); - utils.handleRatelimit(bot, 'getMe', this); - utils.handleRatelimit(bot, 'getChat', this); - - return bot.sendPhoto(USERID, FILE_PATH).then(resp => { - FILE_ID = resp.photo[0].file_id; - return bot.sendMessage(USERID, 'chat'); - }).then(resp => { - GAME_CHAT_ID = resp.chat.id; - return bot.sendGame(USERID, GAME_SHORT_NAME); - }).then(resp => { - GAME_MSG_ID = resp.message_id; - }).then(() => { - return bot.getMe().then(resp => { - BOT_USERNAME = resp.username; - }); - }).then(() => - bot.getChat(GROUPID).then(resp => { - CHAT_INFO = resp; - })); - }); - - it('automatically starts polling', function test() { - assert.strictEqual(botPolling.isPolling(), true); - return utils.isPollingMockServer(pollingPort2); - }); - - it('automatically opens webhook', function test() { - assert.strictEqual(botWebHook.hasOpenWebHook(), true); - return utils.hasOpenWebHook(webHookPort2); - }); - - it('does not automatically poll if "autoStart" is false', function test() { - assert.strictEqual(testbot.isPolling(), false); - return utils.isPollingMockServer(pollingPort, true); - }); - - it('does not automatically open webhook if "autoOpen" is false', function test() { - assert.strictEqual(testbot.hasOpenWebHook(), false); - return utils.hasOpenWebHook(webHookPort, true); - }); - - it('correctly deletes the webhook if polling', function test() { - const myBot = new TelegramBot(TOKEN, { - polling: { autoStart: false, params: { timeout: 0 } }, - }); - utils.handleRatelimit(myBot, 'setWebHook', this); - myBot.on('polling_error', (error) => { - assert.ifError(error); - }); - return myBot.setWebHook(ip, {}).then(() => { - return myBot.startPolling(); - }).then(() => { - return myBot.stopPolling(); - }); - }); - - describe('Events', function eventsSuite() { - it('(polling) emits "message" on receiving message', function test(done) { - botPolling.once('message', () => { - return done(); - }); - }); - it('(polling) emits "polling_error" if error occurs during polling', function test(done) { - const myBot = new TelegramBot(12345, { polling: true }); - myBot.once('polling_error', (error) => { - assert.ok(error); - assert.strictEqual(error.code, 'ETELEGRAM'); - return myBot.stopPolling().then(() => { done(); }).catch(done); - }); - }); - it('(webhook) emits "message" on receiving message', function test(done) { - botWebHook.once('message', () => { - return done(); - }); - utils.sendWebHookMessage(webHookPort2, TOKEN); - }); - it('(webhook) emits "webhook_error" if could not parse webhook request body', function test(done) { - botWebHook.once('webhook_error', (error) => { - assert.ok(error); - assert.strictEqual(error.code, 'EPARSE'); - return done(); - }); - utils.sendWebHookMessage(webHookPort2, TOKEN, { update: 'unparseable!', json: false }); - }); - }); - - describe('WebHook', function webHookSuite() { - it('returns 200 OK for health endpoint', function test(done) { - utils.sendWebHookRequest(webHookPort2, '/healthz').then(resp => { - assert.strictEqual(resp, 'OK'); - return done(); - }); - }); - it('returns 401 error if token is wrong', function test(done) { - utils.sendWebHookMessage(webHookPort2, 'wrong-token').catch(resp => { - assert.strictEqual(resp.statusCode, 401); - return done(); - }); - }); - it('only accepts POST method', function test() { - const methods = ['GET', 'PUT', 'DELETE', 'OPTIONS']; - return Promise.all(methods, (method) => { - return utils.sendWebHookMessage(webHookPort2, TOKEN, { - method, - }).then(() => { - throw new Error(`expected error with webhook ${method} request`); - }).catch(resp => { - if (!resp.statusCode) throw resp; - if (resp.statusCode !== 418) throw new Error(`unexpected error: ${resp.body}`); - }); - }); // Promise.each - }); - }); - - describe('WebHook HTTPS', function webHookHTTPSSuite() { - const port = portindex++; - let httpsbot; - afterEach(function afterEach() { - return httpsbot.closeWebHook(); - }); - it('is enabled, through options.key and options.cert', function test() { - httpsbot = new TelegramBot(TOKEN, { webHook: { port, key, cert } }); - return utils.sendWebHookMessage(port, TOKEN, { https: true }); - }); - it('is enabled, through options.pfx'); - it('is enabled, through options.https', function test() { - httpsbot = new TelegramBot(TOKEN, { - webHook: { - port, - https: { - key: fs.readFileSync(key), - cert: fs.readFileSync(cert), - }, - }, - }); - return utils.sendWebHookMessage(port, TOKEN, { https: true }); - }); - }); - - describe('errors', function errorsSuite() { - const botParse = new TelegramBot('useless-token', { - baseApiUrl: `http://localhost:${badTgServerPort}`, - }); - it('FatalError is thrown if token is missing', function test() { - const myBot = new TelegramBot(null); - return myBot.sendMessage(USERID, 'text').catch(error => { - // FIX: assert.ok(error instanceof TelegramBot.errors.FatalError); - assert.strictEqual(error.code, 'EFATAL'); - assert.ok(error.message.indexOf('not provided') > -1); - }); - }); - it('FatalError is thrown if file-type of Buffer could not be determined', function test() { - let buffer; - try { - buffer = Buffer.from('12345'); - } catch (ex) { - buffer = new Buffer('12345'); - } - return bot.sendPhoto(USERID, buffer).catch(error => { - // FIX: assert.ok(error instanceof TelegramBot.errors.FatalError); - assert.strictEqual(error.code, 'EFATAL'); - assert.ok(error.message.indexOf('Unsupported') > -1); - }); - }); - it('FatalError is thrown on network error', function test() { - const myBot = new TelegramBot('useless-token', { - baseApiUrl: 'http://localhost:23', // are we sure this port is not bound to? - }); - return myBot.getMe().catch(error => { - // FIX: assert.ok(error instanceof TelegramBot.errors.FatalError); - assert.strictEqual(error.code, 'EFATAL'); - }); - }); - it('ParseError is thrown if response body could not be parsed', function test() { - botParse.sendMessage(USERID, 'text').catch(error => { - // FIX: assert.ok(error instanceof TelegramBot.errors.ParseError); - assert.strictEqual(error.code, 'EPARSE'); - assert.ok(typeof error.response === 'object'); - assert.ok(typeof error.response.body === 'string'); - }); - }); - it('TelegramError is thrown if error is from Telegram', function test() { - return bot.sendMessage('404', 'text').catch(error => { - // FIX: assert.ok(error instanceof TelegramBot.errors.TelegramError); - assert.strictEqual(error.code, 'ETELEGRAM'); - assert.ok(typeof error.response === 'object'); - assert.ok(typeof error.response.body === 'object'); - }); - }); - }); - - describe('#startPolling', function initPollingSuite() { - it('initiates polling', function test() { - return testbot.startPolling().then(() => { - return utils.isPollingMockServer(pollingPort); - }); - }); - it('returns error if using webhook', function test() { - return botWebHook.startPolling().catch((err) => { - // TODO: check for error in a better way - // FIX: assert.ok(err instanceof TelegramBot.errors.FatalError); - assert.strictEqual(err.code, 'EFATAL'); - assert.ok(err.message.indexOf('mutually exclusive') !== -1); - }); - }); - }); - - describe('#isPolling', function isPollingSuite() { - it('returns true if bot is polling', function test() { - assert.strictEqual(testbot.isPolling(), true); - return utils.isPollingMockServer(pollingPort); - }); - it('returns false if bot is not polling', function test() { - return testbot.stopPolling().then(() => { - assert.strictEqual(testbot.isPolling(), false); - utils.clearPollingCheck(pollingPort); - return utils.isPollingMockServer(pollingPort, true); - }); - }); - after(function after() { - return testbot.initPolling(); - }); - }); - - describe('#stopPolling', function stopPollingSuite() { - it('stops polling by bot', function test() { - return testbot.stopPolling().then(() => { - utils.clearPollingCheck(pollingPort); - return utils.isPollingMockServer(pollingPort, true); - }); - }); - }); - - describe('#openWebHook', function openWebHookSuite() { - it('opens webhook', function test() { - return testbot.openWebHook().then(() => { - return utils.hasOpenWebHook(webHookPort); - }); - }); - it('returns error if using polling', function test() { - return botPolling.openWebHook().catch((err) => { - // TODO: check for error in a better way - // FIX: assert.ok(err instanceof TelegramBot.errors.FatalError); - assert.strictEqual(err.code, 'EFATAL'); - assert.ok(err.message.indexOf('mutually exclusive') !== -1); - }); - }); - }); - - describe('#hasOpenWebHook', function hasOpenWebHookSuite() { - it('returns true if webhook is opened', function test() { - assert.strictEqual(testbot.hasOpenWebHook(), true); - return utils.hasOpenWebHook(webHookPort); - }); - it('returns false if webhook is closed', function test() { - testbot.closeWebHook().then(() => { - assert.strictEqual(testbot.hasOpenWebHook(), false); - return utils.hasOpenWebHook(webHookPort, true); - }); - }); - after(function after() { - return testbot.openWebHook(); - }); - }); - - describe('#closeWebHook', function closeWebHookSuite() { - it('closes webhook', function test() { - testbot.closeWebHook().then(() => { - return utils.hasOpenWebHook(webHookPort, true); - }); - }); - }); - - - describe('#setWebHook', function setWebHookSuite() { - before(function before() { - utils.handleRatelimit(bot, 'setWebHook', this); - }); - it('should set a webHook', function test() { - return bot - .setWebHook(ip, {}) - .then(resp => { - assert.strictEqual(resp, true); - }); - }); - it('should set a webHook with certificate', function test() { - return bot - .setWebHook(ip, { certificate: cert }) - .then(resp => { - assert.strictEqual(resp, true); - }); - }); - it('(v0.25.0 and lower) should set a webHook with certificate', function test() { - return bot - .setWebHook(ip, cert) - .then(resp => { - assert.strictEqual(resp, true); - }); - }); - it('should delete the webHook', function test() { - return bot - .setWebHook('', {}) - .then(resp => { - assert.strictEqual(resp, true); - }); - }); - }); - - describe('#getWebHookInfo', function getWebHookInfoSuite() { - before(function before() { - utils.handleRatelimit(bot, 'getWebHookInfo', this); - }); - it('should return WebhookInfo', function test() { - return bot.getWebHookInfo().then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.boolean(resp.has_custom_certificate)); - assert.ok(is.number(resp.pending_update_count)); - }); - }); - }); - - describe('#deleteWebHook', function deleteWebHookSuite() { - before(function before() { - utils.handleRatelimit(bot, 'deleteWebHook', this); - }); - it('should delete webhook', function test() { - return bot.deleteWebHook().then(resp => { - assert.strictEqual(resp, true); - }); - }); - }); - - describe('#getUpdates', function getUpdatesSuite() { - const opts = { - timeout: 0, - limit: 10, - }; - before(function before() { - utils.handleRatelimit(bot, 'setWebHook', this); - utils.handleRatelimit(bot, 'getUpdates', this); - return bot.deleteWebHook(); - }); - it('should return an Array', function test() { - return bot.getUpdates(opts).then(resp => { - assert.strictEqual(Array.isArray(resp), true); - }); - }); - it('(v0.25.0 and lower) should return an Array', function test() { - return bot.getUpdates(opts.timeout, opts.limit).then(resp => { - assert.strictEqual(Array.isArray(resp), true); - }); - }); - }); - - describe('#getMe', function getMeSuite() { - before(function before() { - utils.handleRatelimit(bot, 'getMe', this); - }); - it('should return an User object', function test() { - return bot.getMe().then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.number(resp.id)); - assert.ok(is.string(resp.first_name)); - }); - }); - }); - - describe('#getFileLink', function getFileLinkSuite() { - this.timeout(timeout); - before(function before() { - utils.handleRatelimit(bot, 'getFileLink', this); - }); - it('should get a file link', function test() { - return bot.getFileLink(FILE_ID) - .then(fileURI => { - assert.ok(is.string(fileURI)); - assert.ok(utils.isTelegramFileURI(fileURI)); - }); - }); - }); - - describe('#getFileStream', function getFileStreamSuite() { - this.timeout(timeout); - before(function before() { - // utils.handleRatelimit(bot, 'getFileStream', this); - }); - it('should get a file stream', function test(done) { - const fileStream = bot.getFileStream(FILE_ID); - assert.ok(fileStream instanceof stream.Readable); - assert.strictEqual(fileStream.path, FILE_ID); - fileStream.on('info', (info) => { - assert.ok(info); - assert.ok(utils.isTelegramFileURI(info.uri), `${info.uri} is not a file URI`); - fileStream.pipe(concat(function readFile(buffer) { - buffer.equals(fs.readFileSync(FILE_PATH)); // sync :( - return done(); - })); - }); - }); - }); - - describe('#downloadFile', function downloadFileSuite() { - const downloadPath = os.tmpdir(); - this.timeout(timeout); - before(function before() { - utils.handleRatelimit(bot, 'downloadFile', this); - }); - it('should download a file', function test() { - return bot.downloadFile(FILE_ID, downloadPath) - .then(filePath => { - assert.ok(is.string(filePath)); - assert.strictEqual(path.dirname(filePath), downloadPath); - assert.ok(fs.existsSync(filePath)); - fs.unlinkSync(filePath); // Delete file after test - }); - }); - }); - - describe('#onText', function onTextSuite() { - it('should call `onText` callback on match', function test(done) { - const regexp = /\/onText (.+)/; - botWebHook.onText(regexp, (msg, match) => { - assert.strictEqual(match[1], 'ECHO ALOHA'); - assert.ok(botWebHook.removeTextListener(regexp)); - return done(); - }); - utils.sendWebHookMessage(webHookPort2, TOKEN, { - message: { text: '/onText ECHO ALOHA' }, - }); - }); - it('should reset the global regex state with each message', function test(done) { - const regexp = /\/onText (.+)/g; - botWebHook.onText(regexp, () => { - assert.strictEqual(regexp.lastIndex, 0); - assert.ok(botWebHook.removeTextListener(regexp)); - return done(); - }); - utils.sendWebHookMessage(webHookPort2, TOKEN, { - message: { text: '/onText ECHO ALOHA' }, - }); - }); - }); - - describe('#removeTextListener', function removeTextListenerSuite() { - const regexp = /\/onText/; - const regexp2 = /\/onText/; - const callback = function noop() { }; - after(function after() { - bot.removeTextListener(regexp); - bot.removeTextListener(regexp2); - }); - it('removes the right text-listener', function test() { - bot.onText(regexp, callback); - bot.onText(regexp2, callback); - const textListener = bot.removeTextListener(regexp); - assert.strictEqual(regexp, textListener.regexp); - }); - it('returns `null` if missing', function test() { - assert.strictEqual(null, bot.removeTextListener(/404/)); - }); - }); - - describe.skip('#onReplyToMessage', function onReplyToMessageSuite() { }); - - describe('#removeReplyListener', function removeReplyListenerSuite() { - const chatId = -1234; - const messageId = 1; - const callback = function noop() { }; - it('returns the right reply-listener', function test() { - const id = bot.onReplyToMessage(chatId, messageId, callback); - const replyListener = bot.removeReplyListener(id); - assert.strictEqual(id, replyListener.id); - assert.strictEqual(chatId, replyListener.chatId); - assert.strictEqual(messageId, replyListener.messageId); - assert.strictEqual(callback, replyListener.callback); - }); - it('returns `null` if missing', function test() { - // NOTE: '0' is never a valid reply listener ID :) - assert.strictEqual(null, bot.removeReplyListener(0)); - }); - }); - - /** Telegram Bot API Methods */ - - describe.skip('#logOut', function logOutSuite() { }); - - describe.skip('#close', function closeSuite() { }); - - describe('#sendMessage', function sendMessageSuite() { - before(function before() { - utils.handleRatelimit(bot, 'sendMessage', this); - }); - it('should send a message', function test() { - return bot.sendMessage(USERID, 'test').then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.number(resp.message_id)); - }); - }); - }); - - describe('#forwardMessage', function forwardMessageSuite() { - before(function before() { - utils.handleRatelimit(bot, 'sendMessage', this); - utils.handleRatelimit(bot, 'forwardMessage', this); - }); - it('should forward a message', function test() { - return bot.sendMessage(USERID, 'test').then(resp => { - const messageId = resp.message_id; - return bot.forwardMessage(USERID, USERID, messageId) - .then(forwarded => { - assert.ok(is.object(forwarded)); - assert.ok(is.number(forwarded.message_id)); - }); - }); - }); - }); - - describe('#forwardMessages', function forwardMessagesSuite() { - before(function before() { - utils.handleRatelimit(bot, 'sendMessage', this); - utils.handleRatelimit(bot, 'forwardMessages', this); - }); - it('should forward multiple messages', function test() { - return Promise.all([ - bot.sendMessage(USERID, 'test 1'), - bot.sendMessage(USERID, 'test 2'), - ]).then(responses => { - const messageIds = [ - responses[0].message_id, - responses[1].message_id, - ].sort(); - return bot.forwardMessages(GROUPID, USERID, messageIds).then(forwarded => { - assert.ok(is.array(forwarded)); - assert.ok(forwarded.length === 2); - }); - }); - }); - }); - - describe('#copyMessage', function copyMessageSuite() { - before(function before() { - utils.handleRatelimit(bot, 'sendMessage', this); - utils.handleRatelimit(bot, 'copyMessage', this); - }); - it('should send copy of a message', function test() { - return bot.sendMessage(USERID, 'test').then(resp => { - const messageId = resp.message_id; - return bot.copyMessage(USERID, USERID, messageId) - .then(copy => { - assert.ok(is.object(copy)); - assert.ok(is.number(copy.message_id)); - }); - }); - }); - }); - - describe('#sendPhoto', function sendPhotoSuite() { - let photoId; - this.timeout(timeout); - before(function before() { - utils.handleRatelimit(bot, 'sendPhoto', this); - }); - it('should send a photo from file', function test() { - const photo = `${__dirname}/data/photo.png`; - return bot.sendPhoto(USERID, photo).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.array(resp.photo)); - photoId = resp.photo[0].file_id; - }); - }); - it('should send a photo from id', function test() { - // Send the same photo as before - const photo = photoId; - return bot.sendPhoto(USERID, photo).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.array(resp.photo)); - }); - }); - it('should send a photo from fs.readStream', function test() { - const photo = fs.createReadStream(`${__dirname}/data/photo.png`); - return bot.sendPhoto(USERID, photo).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.array(resp.photo)); - }); - }); - it('should send a photo from request Stream', function test() { - const photo = request(`${staticUrl}/photo.png`); - return bot.sendPhoto(USERID, photo).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.array(resp.photo)); - }); - }); - it('should send a photo from a Buffer', function test() { - const photo = fs.readFileSync(`${__dirname}/data/photo.png`); - return bot.sendPhoto(USERID, photo).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.array(resp.photo)); - }); - }); - }); - - describe('#sendAudio', function sendAudioSuite() { - let audioId; - this.timeout(timeout); - before(function before() { - utils.handleRatelimit(bot, 'sendAudio', this); - }); - it('should send an MP3 audio', function test() { - const audio = `${__dirname}/data/audio.mp3`; - return bot.sendAudio(USERID, audio).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.audio)); - audioId = resp.audio.file_id; - }); - }); - it('should send an audio from id', function test() { - // Send the same audio as before - const audio = audioId; - return bot.sendAudio(USERID, audio).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.audio)); - }); - }); - it('should send an audio from fs.readStream', function test() { - const audio = fs.createReadStream(`${__dirname}/data/audio.mp3`); - return bot.sendAudio(USERID, audio).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.audio)); - }); - }); - it('should send an audio from request Stream', function test() { - const audio = request(`${staticUrl}/audio.mp3`); - return bot.sendAudio(USERID, audio).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.audio)); - }); - }); - it('should send an audio from a Buffer', function test() { - const audio = fs.readFileSync(`${__dirname}/data/audio.mp3`); - return bot.sendAudio(USERID, audio).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.audio)); - }); - }); - it('should send an audio file with thumbnail', function test() { - const audio = `${__dirname}/data/audio.mp3`; - const thumbImg = `attach://${__dirname}/data/sticker_thumb.png`; - - return bot.sendAudio(USERID, audio, { thumbnail: thumbImg }).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.audio)); - assert.ok(is.object(resp.audio.thumbnail)); - }); - }); - }); - - describe('#sendDocument', function sendDocumentSuite() { - let documentId; - this.timeout(timeout); - before(function before() { - utils.handleRatelimit(bot, 'sendDocument', this); - }); - it('should send a document from file', function test() { - const document = `${__dirname}/data/photo.gif`; - return bot.sendDocument(USERID, document).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.document)); - documentId = resp.document.file_id; - }); - }); - it('should send a document from id', function test() { - // Send the same document as before - const document = documentId; - return bot.sendDocument(USERID, document).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.document)); - }); - }); - it('should send a document from fs.readStream', function test() { - const document = fs.createReadStream(`${__dirname}/data/photo.gif`); - return bot.sendDocument(USERID, document).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.document)); - }); - }); - it('should send a document from request Stream', function test() { - const document = request(`${staticUrl}/photo.gif`); - return bot.sendDocument(USERID, document).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.document)); - }); - }); - it('should send a document from a Buffer', function test() { - const document = fs.readFileSync(`${__dirname}/data/photo.gif`); - return bot.sendDocument(USERID, document).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.document)); - }); - }); - }); - - describe('#sendVideo', function sendVideoSuite() { - let videoId; - this.timeout(timeout); - before(function before() { - utils.handleRatelimit(bot, 'sendVideo', this); - }); - it('should send a video from file', function test() { - const video = `${__dirname}/data/video.mp4`; - return bot.sendVideo(USERID, video).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.video)); - videoId = resp.video.file_id; - }); - }); - it('should send a video from id', function test() { - // Send the same video as before - return bot.sendVideo(USERID, videoId).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.video)); - }); - }); - it('should send a video from fs.readStream', function test() { - const video = fs.createReadStream(`${__dirname}/data/video.mp4`); - return bot.sendVideo(USERID, video).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.video)); - }); - }); - it('should send a video from request Stream', function test() { - const video = request(`${staticUrl}/video.mp4`); - return bot.sendVideo(USERID, video).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.video)); - }); - }); - it('should send a video from a Buffer', function test() { - const video = fs.readFileSync(`${__dirname}/data/video.mp4`); - return bot.sendVideo(USERID, video).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.video)); - }); - }); - }); - - describe('#sendAnimation', function sendAnimationSuite() { - before(function before() { - utils.handleRatelimit(bot, 'sendAnimation', this); - }); - it('should send a gif as an animation', function test() { - return bot.sendAnimation(USERID, `${__dirname}/data/photo.gif`).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.document)); - }); - }); - }); - - describe('#sendVoice', function sendVoiceSuite() { - let voiceId; - this.timeout(timeout); - before(function before() { - utils.handleRatelimit(bot, 'sendVoice', this); - }); - it('should send a voice from file', function test() { - const voice = `${__dirname}/data/voice.ogg`; - return bot.sendVoice(USERID, voice).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.voice)); - voiceId = resp.voice.file_id; - }); - }); - it('should send a voice from id', function test() { - // Send the same voice as before - return bot.sendVoice(USERID, voiceId).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.voice)); - }); - }); - it('should send a voice from fs.readStream', function test() { - const voice = fs.createReadStream(`${__dirname}/data/voice.ogg`); - return bot.sendVoice(USERID, voice).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.voice)); - }); - }); - it('should send a voice from request Stream', function test() { - const voice = request(`${staticUrl}/voice.ogg`); - return bot.sendVoice(USERID, voice).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.voice)); - }); - }); - it('should send a voice from a Buffer', function test() { - const voice = fs.readFileSync(`${__dirname}/data/voice.ogg`); - return bot.sendVoice(USERID, voice).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.voice)); - }); - }); - }); - - - describe('#sendVideoNote', function sendVideoNoteSuite() { - let videoNoteId; - this.timeout(timeout); - before(function before() { - utils.handleRatelimit(bot, 'sendVideoNote', this); - }); - it('should send a video from file', function test() { - const video = `${__dirname}/data/video.mp4`; - return bot.sendVideoNote(USERID, video, { length: 5 }).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.video)); - videoNoteId = resp.video.file_id; - }); - }); - it('should send a video from id', function test() { - // Send the same videonote as before - assert.ok(videoNoteId); - return bot.sendVideoNote(USERID, videoNoteId, { length: 5 }).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.video)); - }); - }); - it('should send a video from fs.readStream', function test() { - const video = fs.createReadStream(`${__dirname}/data/video.mp4`); - return bot.sendVideoNote(USERID, video, { length: 5 }).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.video)); - }); - }); - it('should send a video from a Buffer', function test() { - const video = fs.readFileSync(`${__dirname}/data/video.mp4`); - return bot.sendVideoNote(USERID, video, { length: 5 }).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.video)); - }); - }); - }); - - describe('#sendMediaGroup', function sendMediaGroupSuite() { - before(function before() { - utils.handleRatelimit(bot, 'sendMediaGroup', this); - }); - it('should send group of photos/videos as album', function test() { - return bot.sendMediaGroup(USERID, [ - { - type: 'photo', - media: `${__dirname}/data/photo.png`, - }, - { - type: 'video', - media: `${__dirname}/data/video.mp4`, - }, - { - type: 'photo', - media: FILE_ID, - }, - ], { - disable_notification: true, - }).then(resp => { - assert.ok(is.array(resp)); - assert.strictEqual(resp.length, 3); - }); - }); - }); - - describe('#sendLocation', function sendLocationSuite() { - before(function before() { - utils.handleRatelimit(bot, 'sendLocation', this); - }); - it('should send a location', function test() { - return bot.sendLocation(USERID, lat, long).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.location)); - assert.ok(is.number(resp.location.latitude)); - assert.ok(is.number(resp.location.longitude)); - }); - }); - }); - - describe('#editMessageLiveLocation', function editMessageLiveLocationSuite() { - let message; - before(function before() { - utils.handleRatelimit(bot, 'editMessageLiveLocation', this); - const opts = { live_period: 86400 }; - return bot.sendLocation(USERID, lat, long, opts).then(resp => { message = resp; }); - }); - it('edits live location', function test() { - const opts = { chat_id: USERID, message_id: message.message_id }; - return bot.editMessageLiveLocation(lat + 1, long + 1, opts).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.location)); - assert.ok(is.number(resp.location.latitude)); - assert.ok(is.number(resp.location.longitude)); - }); - }); - }); - - describe.skip('#stopMessageLiveLocation', function editMessageLiveLocationSuite() { - let message; - before(function before() { - utils.handleRatelimit(bot, 'stopMessageLiveLocation', this); - return bot.sendLocation(USERID, lat, long, { live_period: 86400 }) - .then((resp) => { - message = resp; - }); - }); - it('stops location updates', function test() { - const opts = { chat_id: USERID, message_id: message.message_id }; - return bot.stopMessageLiveLocation(opts).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.location)); - assert.ok(is.number(resp.location.latitude)); - assert.ok(is.number(resp.location.longitude)); - }); - }); - }); - - - describe('#sendVenue', function sendVenueSuite() { - before(function before() { - utils.handleRatelimit(bot, 'sendVenue', this); - }); - it('should send a venue', function test() { - const title = 'The Village Shopping Centre'; - const address = '430 Topsail Rd,St. John\'s, NL A1E 4N1, Canada'; - return bot.sendVenue(USERID, lat, long, title, address).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.venue)); - assert.ok(is.object(resp.venue.location)); - assert.ok(is.number(resp.venue.location.latitude)); - assert.ok(is.number(resp.venue.location.longitude)); - assert.ok(is.string(resp.venue.title)); - assert.ok(is.string(resp.venue.address)); - }); - }); - }); - - - // NOTE: We are skipping TelegramBot#sendContact() as the - // corresponding rate-limits enforced by the Telegram servers - // are too strict! During our initial tests, we were required - // to retry after ~72000 secs (1200 mins / 20 hrs). - // We surely can NOT wait for that much time during testing - // (or in most practical cases for that matter!) - describe.skip('#sendContact', function sendContactSuite() { - before(function before() { - utils.handleRatelimit(bot, 'sendContact', this); - }); - it('should send a contact', function test() { - const phoneNumber = '+1(000)000-000'; - const firstName = 'John Doe'; - return bot.sendContact(USERID, phoneNumber, firstName).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.contact)); - assert.ok(is.string(resp.contact.phone_number)); - assert.ok(is.string(resp.contact.first_name)); - }); - }); - }); - - describe('#sendPoll', function sendPollSuite() { - it('should send a Poll', function test() { - const question = '¿Are you okey?'; - const answers = ['Yes', 'No']; - const opts = { is_anonymous: true }; - return bot.sendPoll(GROUPID, question, answers, opts).then(resp => { - assert.ok(is.object(resp)); - }); - }); - it('should send a Quiz', function test() { - const question = '¿Are you okey?'; - const answers = ['Yes', 'No']; - const opts = { - is_anonymous: true, - type: 'quiz', - correct_option_id: 0 - }; - return bot.sendPoll(GROUPID, question, answers, opts).then(resp => { - assert.ok(is.object(resp)); - }); - }); - }); - - describe('#sendDice', function sendDiceSuite() { - it('should send a Dice', function test() { - return bot.sendDice(GROUPID).then(resp => { - assert.ok(is.object(resp)); - }); - }); - it('should send a Dart', function test() { - const opts = { emoji: '🎯' }; - return bot.sendDice(GROUPID, opts).then(resp => { - assert.ok(is.object(resp)); - }); - }); - }); - - describe('#sendMessageDraft', function sendMessageDraftSuite() { - // Only works in private chats - User <-> Bot - it('should send a message draft', function test() { - return bot.sendMessageDraft(USERID, 22, 'Draft text...').then(resp => { - assert.strictEqual(resp, true); - }); - }); - it('should update an existing draft with the same draft_id', function test() { - return bot.sendMessageDraft(USERID, 22, 'Updated draft text.').then(resp => { - assert.strictEqual(resp, true); - }); - }); - }); - - describe('#sendChatAction', function sendChatActionSuite() { - before(function before() { - utils.handleRatelimit(bot, 'sendChatAction', this); - }); - it('should send a chat action', function test() { - const action = 'typing'; - return bot.sendChatAction(USERID, action).then(resp => { - assert.strictEqual(resp, true); - }); - }); - }); - - describe('#getUserProfilePhotos', function getUserProfilePhotosSuite() { - const opts = { - offset: 0, - limit: 1, - }; - before(function before() { - utils.handleRatelimit(bot, 'getUserProfilePhotos', this); - }); - it('should get user profile photos', function test() { - return bot.getUserProfilePhotos(USERID, opts).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.number(resp.total_count)); - assert.ok(is.array(resp.photos)); - }); - }); - }); - - describe.skip('#getUserProfileAudios', function getUserProfileAudiosSuite() { }); - - describe('#getFile', function getFileSuite() { - this.timeout(timeout); - before(function before() { - utils.handleRatelimit(bot, 'getFile', this); - }); - it('should get a file', function test() { - return bot.getFile(FILE_ID) - .then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.string(resp.file_path)); - }); - }); - }); - - describe.skip('#banChatMember', function banChatMemberSuite() { }); - - describe.skip('#unbanChatMember', function unbanChatMemberSuite() { }); - - describe.skip('#restrictChatMember', function restrictChatMemberSuite() { }); - - describe.skip('#promoteChatMember', function promoteChatMemberSuite() { }); - - describe.skip('#setChatAdministratorCustomTitle', function setChatAdministratorCustomTitleSuite() { - it('should set chat permissions', function test() { - return bot.setChatAdministratorCustomTitle(GROUPID, USERID, 'Custom Name').then(resp => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe.skip('#setChatMemberTag', function setChatMemberTagSuite() { - before(function before() { - utils.handleRatelimit(bot, 'setChatMemberTag', this); - }); - - it('should set tag for a chat member', function test() { - // NOTE: This test requires the bot to be an administrator in the group with the "can_manage_chat" permission and one user that is a member of the group. - return bot.setChatMemberTag(GROUPID, USERID, { tag: 'nodebot' }).then(resp => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe.skip('#unpinAllGeneralForumTopicMessages', function unpinAllGeneralForumTopicMessagesSuite() { - before(function before() { - utils.handleRatelimit(bot, 'unpinAllGeneralForumTopicMessages', this); - }); - - it('should unpin all general forum topic messages', function test() { - return bot.unpinAllGeneralForumTopicMessages(GROUPID).then(resp => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe.skip('#banChatSenderChat', function banChatSenderChatSuite() { }); - - describe.skip('#unbanChatSenderChat', function banChatSenderChatSuite() { }); - - describe('#setChatPermissions ', function setChatPermissionsSuite() { - it('should set chat permissions', function test() { - const ChatPermissions = { - can_send_messages: true, - can_send_media_messages: true, - can_send_polls: false, - can_send_other_messages: false, - can_add_web_page_previews: true, - can_change_info: false, - can_invite_users: false, - can_pin_messages: true - }; - return bot.setChatPermissions(GROUPID, ChatPermissions).then(resp => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe('#exportChatInviteLink', function exportChatInviteLinkSuite() { - before(function before() { - utils.handleRatelimit(bot, 'exportChatInviteLink', this); - }); - it('should export the group invite link', function test() { - return bot.exportChatInviteLink(GROUPID).then(resp => { - assert(resp.match(/^https:\/\/t\.me\/.+$/i), 'is a telegram invite link'); - }); - }); - }); - - describe('#createChatInviteLink', function createChatInviteLinkSuite() { - let inviteLink; - before(function before() { - utils.handleRatelimit(bot, 'createChatInviteLink', this); - utils.handleRatelimit(bot, 'editChatInviteLink', this); - utils.handleRatelimit(bot, 'revokeChatInviteLink', this); - }); - it('should create a chat invite link', function test() { - return bot.createChatInviteLink(GROUPID).then(resp => { - assert(resp.invite_link.match(/^https:\/\/t\.me\/.+$/i), 'is a telegram invite link'); - inviteLink = resp.invite_link; - }); - }); - - it('should edit chat invite link', function test() { - return bot.editChatInviteLink(GROUPID, inviteLink, { member_limit: 3 }).then(resp => { - assert.strictEqual(resp.member_limit, 3); - }); - }); - - it('should revoke chat invite link', function test() { - return bot.revokeChatInviteLink(GROUPID, inviteLink).then(resp => { - assert.strictEqual(resp.is_revoked, true); - }); - }); - }); - - describe.skip('#approveChatJoinRequest', function approveChatJoinRequestSuite() { }); - - describe.skip('#declineChatJoinRequest', function declineChatJoinRequestSuite() { }); - - describe('#setChatPhoto', function setChatPhotoSuite() { - this.timeout(timeout); - before(function before() { - utils.handleRatelimit(bot, 'setChatPhoto', this); - }); - it('should set a chat photo from file', function test() { - const photo = `${__dirname}/data/chat_photo.png`; - return bot.setChatPhoto(GROUPID, photo).then(resp => { - assert.strictEqual(resp, true); - }); - }); - it('should set a chat photo from fs.readStream', function test() { - const photo = fs.createReadStream(`${__dirname}/data/chat_photo.png`); - return bot.setChatPhoto(GROUPID, photo).then(resp => { - assert.strictEqual(resp, true); - }); - }); - it('should set a chat photo from request Stream', function test() { - const photo = request(`${staticUrl}/chat_photo.png`); - return bot.setChatPhoto(GROUPID, photo).then(resp => { - assert.strictEqual(resp, true); - }); - }); - it('should set a chat photo from a Buffer', function test() { - const photo = fs.readFileSync(`${__dirname}/data/chat_photo.png`); - return bot.setChatPhoto(GROUPID, photo).then(resp => { - assert.strictEqual(resp, true); - }); - }); - }); - - describe('#deleteChatPhoto', function deleteChatPhotoSuite() { - before(function before() { - utils.handleRatelimit(bot, 'deleteChatPhoto', this); - }); - it('should delete the chat photo', function test() { - return bot.deleteChatPhoto(GROUPID).then(resp => { - assert.strictEqual(resp, true); - }); - }); - }); - - describe('#setChatTitle', function setChatTitleSuite() { - before(function before() { - utils.handleRatelimit(bot, 'setChatTitle', this); - }); - it('should set the chat title', function test() { - const random = Math.floor(Math.random() * 1000); - return bot.setChatTitle(GROUPID, `ntba test group (random: ${random})`).then(resp => { - assert.strictEqual(resp, true); - }); - }); - }); - - describe('#setChatDescription', function setChatDescriptionSuite() { - before(function before() { - utils.handleRatelimit(bot, 'setChatDescription', this); - }); - it('should set the chat description', function test() { - const random = Math.floor(Math.random() * 1000); - const description = `node-telegram-bot-api test group (random: ${random})`; - return bot.setChatDescription(GROUPID, description).then(resp => { - assert.strictEqual(resp, true); - }); - }); - }); - - describe('#pinChatMessage', function pinChatMessageSuite() { - let messageId; - before(function before() { - utils.handleRatelimit(bot, 'pinChatMessage', this); - return bot.sendMessage(GROUPID, 'To be pinned').then(resp => { - messageId = resp.message_id; - }); - }); - it('should pin chat message', function test() { - return bot.pinChatMessage(GROUPID, messageId).then(resp => { - assert.strictEqual(resp, true); - }); - }); - }); - - describe('#unpinChatMessage', function unpinChatMessageSuite() { - before(function before() { - utils.handleRatelimit(bot, 'unpinChatMessage', this); - }); - it('should unpin chat message', function test() { - return bot.unpinChatMessage(GROUPID).then(resp => { - assert.strictEqual(resp, true); - }); - }); - }); - - describe('#unpinAllChatMessages', function unpinAllChatMessagesSuite() { - before(function before() { - utils.handleRatelimit(bot, 'unpinAllChatMessages', this); - }); - it('should unpin all chats messages', function test() { - return bot.unpinAllChatMessages(GROUPID).then(resp => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe.skip('#leaveChat', function leaveChatSuite() { }); - - describe('#getChat', function getChatSuite() { - before(function before() { - utils.handleRatelimit(bot, 'getChat', this); - }); - it('should return a Chat object', function test() { - return bot.getChat(USERID).then(resp => { - assert.ok(is.object(resp)); - }); - }); - }); - - describe('#getChatAdministrators', function getChatAdministratorsSuite() { - before(function before() { - utils.handleRatelimit(bot, 'getChatAdministrators', this); - }); - it('should return an Array', function test() { - return bot.getChatAdministrators(GROUPID).then(resp => { - assert.ok(Array.isArray(resp)); - }); - }); - }); - - describe('#getChatMemberCount', function getChatMemberCountSuite() { - before(function before() { - utils.handleRatelimit(bot, 'getChatMemberCount', this); - }); - it('should return an Integer', function test() { - return bot.getChatMemberCount(GROUPID).then(resp => { - assert.ok(Number.isInteger(resp)); - }); - }); - }); - - describe('#getChatMember', function getChatMemberSuite() { - before(function before() { - utils.handleRatelimit(bot, 'getChatMember', this); - }); - it('should return a ChatMember', function test() { - return bot.getChatMember(GROUPID, USERID).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.user)); - assert.ok(is.string(resp.status)); - }); - }); - }); - - describe('#setChatStickerSet', function setChatStickerSetSuite() { - before(function before() { - utils.handleRatelimit(bot, 'setChatStickerSet', this); - // Check if the chat can set sticker sets - if (!CHAT_INFO.can_set_sticker_set) { - this.skip(); - } - }); - it('should return a Boolean', function test() { - return bot.setChatStickerSet(GROUPID, STICKER_SET_NAME).then(resp => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe('#deleteChatStickerSet', function deleteChatStickerSetSuite() { - before(function before() { - utils.handleRatelimit(bot, 'deleteChatStickerSet', this); - // Check if the chat can delete sticker sets - if (!CHAT_INFO.can_set_sticker_set) { - this.skip(); - } - }); - it('should return a Boolean', function test() { - return bot.deleteChatStickerSet(GROUPID).then(resp => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe.skip('#answerCallbackQuery', function answerCallbackQuerySuite() { }); - - describe('#savePreparedKeyboardButton', function savePreparedKeyboardButtonSuite() { - before(function before() { - utils.handleRatelimit(bot, 'savePreparedKeyboardButton', this); - }); - - it('should return a PreparedKeyboardButton object', function test() { - const button = { - text: 'Request users', - request_users: { - request_id: 1, - }, - }; - - return bot.savePreparedKeyboardButton(USERID, button).then(resp => { - assert.ok(is.object(resp)); - }); - }); - }); - - describe.skip('#getManagedBotToken', function getManagedBotTokenSuite() { - before(function before() { - utils.handleRatelimit(bot, 'getManagedBotToken', this); - }); - - it('should return a managed bot token', function test() { - return bot.getManagedBotToken(USERID).then(resp => { - assert.ok(is.string(resp)); - }); - }); - }); - - describe.skip('#replaceManagedBotToken', function replaceManagedBotTokenSuite() { - before(function before() { - utils.handleRatelimit(bot, 'replaceManagedBotToken', this); - }); - - it('should replace and return a new managed bot token', function test() { - return bot.replaceManagedBotToken(USERID).then(resp => { - assert.ok(is.string(resp)); - }); - }); - }); - - describe('#setMyCommands', function setMyCommandsSuite() { - it('should set bot commands', function test() { - const opts = [ - { command: 'eat', description: 'Command for eat' }, - { command: 'run', description: 'Command for run' } - ]; - return bot.setMyCommands(opts).then(resp => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe('#deleteMyCommands', function deleteMyCommandsSuite() { - it('should delete bot commands', function test() { - return bot.deleteMyCommands().then(resp => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe('#setMyDescription', function getMyCommandsSuite() { - it('should set bot description for users with a specific lang code', function test() { - return bot.setMyDescription({ description: 'Bot description' }).then(resp => { - assert.ok(is.boolean(resp)); - }); - }); - it('should set bot description for Spanish users', function test() { - return bot.setMyDescription({ description: 'Spanish bot description', language_code: 'es' }).then(resp => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe('#setMyName', function setMyNameSuite() { - it('should set bot name for Spanish users', function test() { - return bot.setMyName({ name: 'Spanish Bot', language_code: 'es' }).then(resp => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe('#getMyName', function setMyNameSuite() { - it('should get bot name for Spanish users', function test() { - return bot.getMyName({ language_code: 'es' }).then(resp => { - assert.ok(is.equal(resp.name, 'Spanish Bot')); - }); - }); - }); - - describe('#getMyDescription', function getMyDescriptionSuite() { - it('should get bot description for a user without lang code', function test() { - return bot.getMyDescription().then(resp => { - assert.ok(is.equal(resp.description, 'Bot description')); - }); - }); - it('should get bot description for Spanish users', function test() { - return bot.getMyDescription({ language_code: 'es' }).then(resp => { - assert.ok(is.equal(resp.description, 'Spanish bot description')); - }); - }); - }); - - describe('#setMyShortDescription', function setMyShortDescriptionSuite() { - it('should set sort bot description for a user without lang code', function test() { - return bot.setMyShortDescription({ short_description: 'Bot sort description' }).then(resp => { - assert.ok(is.boolean(resp)); - }); - }); - it('should set sort description for Spanish users', function test() { - return bot.setMyShortDescription({ short_description: 'Spanish bot sort description', language_code: 'es' }).then(resp => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe('#getMyShortDescription', function getMyShortDescriptionSuite() { - it('should get bot sort description for a user without lang code', function test() { - return bot.getMyShortDescription().then(resp => { - assert.ok(is.equal(resp.short_description, 'Bot sort description')); - }); - }); - it('should get bot sort description for Spanish users', function test() { - return bot.getMyShortDescription({ language_code: 'es' }).then(resp => { - assert.ok(is.equal(resp.short_description, 'Spanish bot sort description')); - }); - }); - }); - - describe('#setMyProfilePhoto', function setMyProfilePhotoSuite() { - this.timeout(timeout); - before(function before() { - utils.handleRatelimit(bot, 'setMyProfilePhoto', this); - }); - - it('should set bot profile photo from file', function test() { - const photo = `attach://${__dirname}/data/chat_photo.png`; - - return bot.setMyProfilePhoto({ photo, type: 'static' }).then(resp => { - assert.strictEqual(resp, true); - }); - }); - }); - - describe('#removeMyProfilePhoto', function removeMyProfilePhotoSuite() { - before(function before() { - utils.handleRatelimit(bot, 'removeMyProfilePhoto', this); - }); - - it('should remove bot profile photo', function test() { - return bot.removeMyProfilePhoto().then(resp => { - assert.strictEqual(resp, true); - }); - }); - }); - - describe('#getMyCommands', function getMyCommandsSuite() { - it('should get bot commands', function test() { - return bot.getMyCommands().then(resp => { - assert.ok(is.array(resp)); - }); - }); - }); - - describe('#setChatMenuButton', function setChatMenuButtonSuite() { - it('should set chat menu button', function test() { - return bot.setChatMenuButton({ - chat_id: USERID, - menu_button: JSON.stringify({ type: 'web_app', text: 'Hello', web_app: { url: 'https://webappcontent.telegram.org/cafe' } }), - }) - .then(resp => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe('#getChatMenuButton', function getChatMenuButtonSuite() { - it('should get chat menu button', function test() { - return bot.getChatMenuButton({ chat_id: USERID }).then(resp => { - assert.ok(is.equal(resp, { - type: 'web_app', - text: 'Hello', - web_app: { url: 'https://webappcontent.telegram.org/cafe' } - })); - }); - }); - }); - - describe('#setMyDefaultAdministratorRights', function setMyDefaultAdministratorRightsSuite() { - it('should set default administrator rights', function test() { - return bot.setMyDefaultAdministratorRights({ - rights: JSON.stringify({ - can_manage_chat: true, - can_change_info: true, - can_delete_messages: false, - can_invite_users: true, - can_restrict_members: false, - can_pin_messages: true, - can_promote_members: false, - can_manage_video_chats: false, - is_anonymous: false - }), - for_channels: false - }).then(resp => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe('#getMyDefaultAdministratorRights', function getMyDefaultAdministratorRightsSuite() { - it('should get my default administrator rights', function test() { - return bot.getMyDefaultAdministratorRights({ - for_channels: false - }).then(resp => { - assert.ok(is.equal(resp, { - can_manage_chat: true, - can_change_info: true, - can_delete_messages: false, - can_invite_users: true, - can_restrict_members: false, - can_pin_messages: true, - can_manage_topics: false, - can_promote_members: false, - can_manage_video_chats: false, - can_post_stories: false, - can_edit_stories: false, - can_delete_stories: false, - can_manage_tags: false, - is_anonymous: false - })); - }); - }); - }); - - describe('#editMessageText', function editMessageTextSuite() { - before(function before() { - utils.handleRatelimit(bot, 'sendMessage', this); - utils.handleRatelimit(bot, 'editMessageText', this); - }); - it('should edit a message sent by the bot', function test() { - return bot.sendMessage(USERID, 'test').then(resp => { - assert.strictEqual(resp.text, 'test'); - const opts = { - chat_id: USERID, - message_id: resp.message_id - }; - return bot.editMessageText('edit test', opts).then(msg => { - assert.strictEqual(msg.text, 'edit test'); - }); - }); - }); - }); - - describe('#editMessageCaption', function editMessageCaptionSuite() { - this.timeout(timeout); - before(function before() { - utils.handleRatelimit(bot, 'sendPhoto', this); - utils.handleRatelimit(bot, 'editMessageCaption', this); - }); - it('should edit a caption sent by the bot', function test() { - const photo = `${__dirname}/data/photo.png`; - const options = { caption: 'test caption' }; - return bot.sendPhoto(USERID, photo, options).then(resp => { - assert.strictEqual(resp.caption, 'test caption'); - const opts = { - chat_id: USERID, - message_id: resp.message_id - }; - return bot.editMessageCaption('new test caption', opts).then(msg => { - assert.strictEqual(msg.caption, 'new test caption'); - }); - }); - }); - }); - - describe('#editMessageMedia', function editMessageMediaSuite() { - let photoId; - let messageID; - before(function before() { - utils.handleRatelimit(bot, 'editMessageMedia', this); - const photo = `${__dirname}/data/photo.png`; - return bot.sendPhoto(USERID, photo).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.array(resp.photo)); - photoId = resp.photo[0].file_id; - messageID = resp.message_id; - }); - }); - it('should edit a media message', function nextTest() { - return bot.editMessageMedia({ type: 'photo', media: photoId, caption: 'edited' }, { chat_id: USERID, message_id: messageID }).then(editedResp => { - assert.ok(is.object(editedResp)); - assert.ok(is.string(editedResp.caption)); - }); - }); - }); - - - describe('#editMessageReplyMarkup', function editMessageReplyMarkupSuite() { - before(function before() { - utils.handleRatelimit(bot, 'sendMessage', this); - utils.handleRatelimit(bot, 'editMessageReplyMarkup', this); - }); - it('should edit previously-set reply markup', function test() { - return bot.sendMessage(USERID, 'test').then(resp => { - const replyMarkup = JSON.stringify({ - inline_keyboard: [[{ - text: 'Test button', - callback_data: 'test' - }]] - }); - const opts = { - chat_id: USERID, - message_id: resp.message_id - }; - return bot.editMessageReplyMarkup(replyMarkup, opts).then(msg => { - // Keyboard markup is not returned, do a simple object check - assert.ok(is.object(msg)); - }); - }); - }); - }); - - describe('#stopPoll', function stopPollSuite() { - let msg; - before(function before() { - utils.handleRatelimit(bot, 'stopPoll', this); - return bot.sendPoll(GROUPID, '¿Poll for stop before?', ['Yes', 'No']).then(resp => { - msg = resp; - }); - }); - it('should stop a Poll', function test() { - return bot.stopPoll(GROUPID, msg.message_id).then(resp => { - assert.ok(is.boolean(resp.is_closed) && resp.is_closed === true); - }); - } - ); - }); - - describe.skip('#approveSuggestedPost', function approveSuggestedPostSuite() { }); - - describe.skip('#declineSuggestedPost', function declineSuggestedPostSuite() { }); - - describe('#deleteMessage', function deleteMessageSuite() { - let messageId; - before(function before() { - utils.handleRatelimit(bot, 'deleteMessage', this); - return bot.sendMessage(USERID, 'To be deleted').then(resp => { - messageId = resp.message_id; - }); - }); - it('should delete message', function test() { - return bot.deleteMessage(USERID, messageId).then(resp => { - assert.strictEqual(resp, true); - }); - }); - }); - - describe('#sendSticker', function sendStickerSuite() { - let stickerId; - this.timeout(timeout); - before(function before() { - utils.handleRatelimit(bot, 'sendSticker', this); - }); - it('should send a sticker from file', function test() { - const sticker = `${__dirname}/data/sticker.webp`; - return bot.sendSticker(USERID, sticker).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.sticker)); - stickerId = resp.sticker.file_id; - }); - }); - it('should send a sticker from id', function test() { - // Send the same photo as before - return bot.sendSticker(USERID, stickerId).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.sticker)); - }); - }); - it('should send a sticker from fs.readStream', function test() { - const sticker = fs.createReadStream(`${__dirname}/data/sticker.webp`); - return bot.sendSticker(USERID, sticker).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.sticker)); - }); - }); - it('should send a sticker from request Stream', function test() { - const sticker = request(`${staticUrl}/sticker.webp`); - return bot.sendSticker(USERID, sticker).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.sticker)); - }); - }); - it('should send a sticker from a Buffer', function test() { - const sticker = fs.readFileSync(`${__dirname}/data/sticker.webp`); - return bot.sendSticker(USERID, sticker).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.sticker)); - }); - }); - }); - - describe('#uploadStickerFile', function sendPhotoSuite() { - before(function before() { - utils.handleRatelimit(bot, 'uploadStickerFile', this); - }); - it('should upload a sticker from file', function test() { - const sticker = `${__dirname}/data/sticker.png`; - - bot.uploadStickerFile(USERID, sticker).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.string(resp.file_id)); - }); - }); - // Other tests (eg. Buffer, URL) are skipped, because they rely on the same features as sendPhoto. - }); - - describe('#createNewStickerSet', function createNewStickerSetSuite() { - before(function before() { - utils.handleRatelimit(bot, 'createNewStickerSet', this); - }); - - it('should create a new sticker set', function test(done) { - const sticker = `${__dirname}/data/sticker.png`; - const stickerPackName = `s${CURRENT_TIMESTAMP}_by_${BOT_USERNAME}`; - - bot.createNewStickerSet(USERID, stickerPackName, 'Sticker Pack Title', sticker, '😍').then((resp) => { - assert.ok(is.boolean(resp)); - }); - setTimeout(() => done(), 2000); - }); - }); - - describe('#getStickerSet', function getStickerSetSuite() { - before(function before() { - utils.handleRatelimit(bot, 'getStickerSet', this); - }); - it('should get the sticker set given the name of the set', function test() { - return bot.getStickerSet(STICKER_SET_NAME).then(resp => { - assert.ok(is.object(resp)); - assert.strictEqual(resp.name.toLowerCase(), STICKER_SET_NAME); - assert.ok(is.string(resp.title)); - assert.ok(is.string(resp.sticker_type)); - assert.ok(is.array(resp.stickers)); - }); - }); - // This test depends on the previous test createNewStickerSet - it('should get the recent sticker set created given the name of the set', function test() { - const stickerPackName = `s${CURRENT_TIMESTAMP}_by_${BOT_USERNAME}`; - return bot.getStickerSet(stickerPackName).then(resp => { - STICKER_FILE_ID_FROM_SET = resp.stickers[0].file_id; - assert.ok(is.object(resp)); - assert.strictEqual(resp.name.toLowerCase(), stickerPackName.toLowerCase()); - assert.ok(is.string(resp.title)); - assert.ok(is.string(resp.sticker_type)); - assert.ok(is.array(resp.stickers)); - }); - }); - }); - - describe('#getCustomEmojiStickers', function getCustomEmojiStickersSuite() { - const CHERRY_EMOJI_STICKERS_ID = ['5380109565226391871', '5431711346724968789']; - const STICKER_EMOJI_SET_NAME = 'CherryEmoji'; - - it('should get the custom emoji stickers', function test() { - return bot.getCustomEmojiStickers([CHERRY_EMOJI_STICKERS_ID[0]]).then(resp => { - assert.ok(is.array(resp)); - assert.ok(is.object(resp[0])); - assert.ok(is.string(resp[0].set_name) && resp[0].set_name === STICKER_EMOJI_SET_NAME); - assert.ok(resp[0].custom_emoji_id === CHERRY_EMOJI_STICKERS_ID[0]); - }); - }); - it('should get 2 custom emoji stickers', function test() { - return bot.getCustomEmojiStickers(CHERRY_EMOJI_STICKERS_ID).then(resp => { - assert.ok(is.array(resp) && resp.length === 2); - assert.ok(is.object(resp[1])); - assert.ok(is.string(resp[1].set_name) && resp[1].set_name === STICKER_EMOJI_SET_NAME); - assert.ok(resp[1].custom_emoji_id === CHERRY_EMOJI_STICKERS_ID[1]); - }); - }); - }); - - - describe('#addStickerToSet', function addStickerToSetSuite() { - before(function before() { - utils.handleRatelimit(bot, 'addStickerToSet', this); - }); - - it('should add a sticker to a set', function test() { - const sticker = `${__dirname}/data/sticker.png`; - const stickerPackName = `s${CURRENT_TIMESTAMP}_by_${BOT_USERNAME}`; - - bot.addStickerToSet(USERID, stickerPackName, sticker, '😊😍🤔', 'png_sticker').then((resp) => { - assert.ok(is.boolean(resp)); - }); - }); - it('should add a sticker to a set using the file Id', function test(done) { - const stickerPackName = `s${CURRENT_TIMESTAMP}_by_${BOT_USERNAME}`; - - bot.addStickerToSet(USERID, stickerPackName, STICKER_FILE_ID_FROM_SET, '😊🤔', 'png_sticker').then((resp) => { - assert.ok(is.boolean(resp)); - }); - setTimeout(() => done(), 2000); - }); - }); - - describe('#setStickerPositionInSet', function setStickerPositionInSet() { - before(function before() { - utils.handleRatelimit(bot, 'setStickerPositionInSet', this); - }); - it('should set the position of a sticker in a set', function test() { - bot.setStickerPositionInSet(STICKER_FILE_ID_FROM_SET, 0).then((resp) => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe('#deleteStickerFromSet', function deleteStickerFromSetSuite() { - before(function before() { - utils.handleRatelimit(bot, 'deleteStickerFromSet', this); - }); - it('should delete a sticker from a set', function test() { - bot.deleteStickerFromSet(STICKER_FILE_ID_FROM_SET).then((resp) => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe('#setStickerEmojiList', function setStickerEmojiListSuite() { - before(function before() { - utils.handleRatelimit(bot, 'setStickerEmojiList', this); - }); - - it('should get the list for the given sticker of the bot sticker pack', function test(done) { - const stickerPackName = `s${CURRENT_TIMESTAMP}_by_${BOT_USERNAME}`; - - bot.getStickerSet(stickerPackName).then(resp => { - STICKERS_FROM_BOT_SET = resp.stickers; - assert.ok(is.array(STICKERS_FROM_BOT_SET)); - }); - - setTimeout(() => done(), 2000); - }); - - it('should set a emoji list for the given sticker', function test() { - assert.ok(is.equal(STICKERS_FROM_BOT_SET[0].type, 'regular')); - - bot.setStickerEmojiList(STICKERS_FROM_BOT_SET[0].file_id, ['🥳', '😀', '😇']).then((resp) => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe('#setStickerKeywords', function setStickerKeywordsSuite() { - before(function before() { - utils.handleRatelimit(bot, 'setStickerKeywords', this); - }); - it('should set a keywords list for the given sticker', function test() { - assert.ok(is.equal(STICKERS_FROM_BOT_SET[0].type, 'regular')); - bot.setStickerKeywords(STICKERS_FROM_BOT_SET[0].file_id, { keywords: ['house', 'cat'] }).then((resp) => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe.skip('#setStickerMaskPosition', function setStickerKeywordsSuite() { - before(function before() { - utils.handleRatelimit(bot, 'setStickerMaskPosition', this); - }); - it('should delete a sticker from a set', function test() { - bot.setStickerMaskPosition(STICKER_FILE_ID_FROM_SET, { point: 'eyes', scale: 2, x_shift: 1, y_shift: 1 }).then((resp) => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe('#setStickerSetTitle', function setStickerSetTitleSuite() { - before(function before() { - utils.handleRatelimit(bot, 'setStickerSetTitle', this); - }); - it('should set a new sticker set title', function test() { - const stickerPackName = `s${CURRENT_TIMESTAMP}_by_${BOT_USERNAME}`; - - bot.setStickerSetTitle(stickerPackName, 'New title').then((resp) => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe('#setStickerSetThumbnail', function setStickerSetThumbnailSuite() { - before(function before() { - utils.handleRatelimit(bot, 'setStickerSetThumbnail', this); - }); - - it('should set a sticker set thumbnail', function test() { - const stickerThumb = `${__dirname}/data/sticker_thumb.png`; - const stickerPackName = `s${CURRENT_TIMESTAMP}_by_${BOT_USERNAME}`; - - bot.setStickerSetThumbnail(USERID, stickerPackName, stickerThumb).then((resp) => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe.skip('#setCustomEmojiStickerSetThumbnail', function setCustomEmojiStickerSetThumbnailSuite() { - before(function before() { - utils.handleRatelimit(bot, 'setCustomEmojiStickerSetThumbnail', this); - }); - - it('should set a custom emoji sticjer set as thumbnail', function test() { - const stickerPackName = `s${CURRENT_TIMESTAMP}_by_${BOT_USERNAME}`; - - bot.setCustomEmojiStickerSetThumbnail(stickerPackName, { custom_emoji_id: null }).then((resp) => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe.skip('#deleteStickerSet', function deleteStickerSetSuite() { - before(function before() { - utils.handleRatelimit(bot, 'deleteStickerSet', this); - }); - - it('should delete sticker set', function test() { - const stickerPackName = `s${CURRENT_TIMESTAMP}_by_${BOT_USERNAME}`; - - bot.deleteStickerSet(stickerPackName).then((resp) => { - assert.ok(is.boolean(resp)); - }); - }); - }); - - describe.skip('#answerInlineQuery', function answerInlineQuerySuite() { }); - - describe.skip('#answerWebAppQuery', function answerCallbackQuerySuite() { }); - - describe('#sendInvoice', function sendInvoiceSuite() { - before(function before() { - utils.handleRatelimit(bot, 'sendInvoice', this); - }); - it('should send an invoice', function test() { - if (isCI) { - this.skip(); // Skip test for now - } - const title = 'Demo product'; - const description = 'our test product'; - const payload = 'sku-p001'; - const providerToken = PROVIDER_TOKEN; - const currency = 'USD'; - const prices = [{ label: 'product', amount: 11000 }, { label: 'tax', amount: 11000 }]; - return bot.sendInvoice(USERID, title, description, payload, providerToken, currency, prices).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.invoice)); - assert.ok(is.number(resp.invoice.total_amount)); - }); - }); - }); - - describe('#createInvoiceLink', function createInvoiceLinkSuite() { - before(function before() { - utils.handleRatelimit(bot, 'createInvoiceLink', this); - }); - it('should create an invoice link', function test() { - if (isCI) { - this.skip(); // Skip test for now - } - const title = 'Invoice link product'; - const description = 'Our test invoice link product'; - const payload = 'sku-p002'; - const providerToken = PROVIDER_TOKEN; - const currency = 'EUR'; - const prices = [{ label: 'NTBA API', amount: 12000 }, { label: 'tax', amount: 10000 }]; - return bot.createInvoiceLink(title, description, payload, providerToken, currency, prices).then(resp => { - assert.ok(is.string(resp)); - }); - }); - }); - - describe.skip('#answerShippingQuery', function answerShippingQuerySuite() { }); - - - describe.skip('#answerPreCheckoutQuery', function answerPreCheckoutQuerySuite() { }); - - describe('#sendGame', function sendGameSuite() { - before(function before() { - utils.handleRatelimit(bot, 'sendGame', this); - }); - it('should send a Game', function test() { - return bot.sendGame(USERID, GAME_SHORT_NAME).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.game)); - }); - }); - }); - - describe('#setGameScore', function setGameScoreSuite() { - before(function before() { - utils.handleRatelimit(bot, 'setGameScore', this); - }); - it('should set GameScore', function test() { - const score = Math.floor(Math.random() * 1000); - const opts = { - chat_id: GAME_CHAT_ID, - message_id: GAME_MSG_ID, - force: true - }; - return bot.setGameScore(USERID, score, opts).then(resp => { - assert.ok(is.object(resp) || is.boolean(resp)); - }); - }); - }); - - describe('#getGameHighScores', function getGameHighScoresSuite() { - before(function before() { - utils.handleRatelimit(bot, 'getGameHighScores', this); - }); - it('should get GameHighScores', function test() { - const opts = { - chat_id: GAME_CHAT_ID, - message_id: GAME_MSG_ID, - }; - return bot.getGameHighScores(USERID, opts).then(resp => { - assert.ok(is.array(resp)); - }); - }); - }); - - describe('#setMessageReaction', function setMessageReactionSuite() { - let messageId; - const Reactions = [{ type: 'emoji', emoji: '👍' }]; - before(function before() { - utils.handleRatelimit(bot, 'setMessageReaction', this); - return bot.sendMessage(USERID, 'To be reacted').then(resp => { - messageId = resp.message_id; - }); - }); - it('should add reactions to message', function test() { - return bot.setMessageReaction(USERID, messageId, { reaction: Reactions, is_big: true }).then(resp => { - assert.strictEqual(resp, true); - }); - }); - }); - - describe('#deleteMessages', function setMessageReactionSuite() { - let messageId; - before(function before() { - utils.handleRatelimit(bot, 'deleteMessages', this); - return bot.sendMessage(USERID, 'To be deleted').then(resp => { - messageId = resp.message_id; - }); - }); - it('should delete message from array', function test() { - return bot.deleteMessages(USERID, [messageId]).then(resp => { - assert.strictEqual(resp, true); - }); - }); - }); - - describe('#copyMessages', function setMessageReactionSuite() { - let messageId; - before(function before() { - utils.handleRatelimit(bot, 'copyMessages', this); - return bot.sendMessage(GROUPID, 'To be copyed').then(resp => { - messageId = resp.message_id; - }); - }); - it('should copy messages from array', function test() { - return bot.copyMessages(USERID, GROUPID, [messageId]).then(resp => { - assert.ok(is.array(resp)); - assert.ok(resp && resp.length === 1); - }); - }); - }); - - describe('#getUserGifts', function getUserGiftsSuite() { - before(function before() { - utils.handleRatelimit(bot, 'getUserGifts', this); - }); - it('should return an OwnedGifts object', function test() { - return bot.getUserGifts(USERID).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.array(resp.gifts)); - assert.ok(is.number(resp.total_count)); - }); - }); - it('should support pagination options', function test() { - return bot.getUserGifts(USERID, { limit: 10, offset: '' }).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.array(resp.gifts)); - }); - }); - }); - - describe.skip('#getChatGifts', function getChatGiftsSuite() { - // Requires can_view_gifts_and_stars administrator right for channels - it('should return an OwnedGifts object for a channel', function test() { - return bot.getChatGifts(GROUPID).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.array(resp.gifts)); - assert.ok(is.number(resp.total_count)); - }); - }); - }); - - describe.skip('#repostStory', function repostStorySuite() { - // Requires two managed business accounts and a story posted by the bot - it('should repost a story to another business account', function test() { - const businessConnectionId = process.env.TEST_BUSINESS_CONNECTION_ID; - const fromChatId = process.env.TEST_STORY_CHAT_ID; - const fromStoryId = parseInt(process.env.TEST_STORY_ID, 10); - const activePeriod = 86400; - return bot.repostStory(businessConnectionId, fromChatId, fromStoryId, activePeriod).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.number(resp.id)); - }); - }); - }); -}); // End Telegram diff --git a/test/test.format-send-data.js b/test/test.format-send-data.js deleted file mode 100644 index 97828427..00000000 --- a/test/test.format-send-data.js +++ /dev/null @@ -1,139 +0,0 @@ -const assert = require('assert'); -const fs = require('fs'); -const path = require('path'); -const TelegramBot = require('..'); - -const paths = { - audio: path.join(__dirname, 'data/audio.mp3'), -}; - - -describe('#_formatSendData', function sendfileSuite() { - const bot = new TelegramBot('token'); - const type = 'file'; - - before(function beforeSuite() { - process.env.NTBA_FIX_350 = 1; - }); - after(function afterSuite() { - delete process.env.NTBA_FIX_350; - }); - - describe('using fileOptions', function sendfileOptionsSuite() { - const stream = fs.createReadStream(paths.audio); - const nonPathStream = fs.createReadStream(paths.audio); - const buffer = fs.readFileSync(paths.audio); - const nonDetectableBuffer = fs.readFileSync(__filename); - const filepath = paths.audio; - const files = [stream, nonPathStream, buffer, nonDetectableBuffer, filepath]; - - delete nonPathStream.path; - - describe('filename', function filenameSuite() { - it('(1) fileOptions.filename', function test() { - const filename = 'custom-filename'; - files.forEach((file) => { - const [{ [type]: data }] = bot._formatSendData(type, file, { filename }); - assert.equal(data.options.filename, filename); - }); - }); - - it('(2) Stream#path', function test() { - if (!stream.path) { - this.skip('Stream#path unsupported'); - return; - } - const [{ [type]: data }] = bot._formatSendData(type, stream); - assert.equal(data.options.filename, path.basename(paths.audio)); - }); - - it('(3) filepath', function test() { - const [{ [type]: data }] = bot._formatSendData(type, filepath); - assert.equal(data.options.filename, path.basename(paths.audio)); - }); - - it('(4) final default', function test() { - [nonPathStream, buffer, nonDetectableBuffer].forEach((file) => { - const [{ [type]: data }] = bot._formatSendData(type, file); - assert.equal(data.options.filename, 'filename'); - }); - }); - }); - - describe('contentType', function contentTypeSuite() { - it('(1) fileOpts.contentType', function test() { - const contentType = 'application/custom-type'; - files.forEach((file) => { - const [{ [type]: data }] = bot._formatSendData(type, file, { contentType }); - assert.equal(data.options.contentType, contentType); - }); - }); - - it('(2) Stream#path', function test() { - if (!stream.path) { - this.skip('Stream#path unsupported'); - return; - } - const [{ [type]: data }] = bot._formatSendData(type, stream); - assert.equal(data.options.contentType, 'audio/mpeg'); - }); - - it('(3) Buffer file-type', function test() { - const [{ [type]: data }] = bot._formatSendData(type, buffer); - assert.equal(data.options.contentType, 'audio/mpeg'); - }); - - it('(4) filepath', function test() { - const [{ [type]: data }] = bot._formatSendData(type, filepath); - assert.equal(data.options.contentType, 'audio/mpeg'); - }); - - it('(5) fileOptions.filename', function test() { - [nonPathStream, nonDetectableBuffer].forEach((file) => { - const [{ [type]: data }] = bot._formatSendData(type, file, { - filename: 'image.gif', - }); - assert.equal(data.options.contentType, 'image/gif'); - }); - }); - - it('(6) Final default', function test() { - [nonPathStream, nonDetectableBuffer].forEach((file) => { - const [{ [type]: data }] = bot._formatSendData(type, file); - assert.equal(data.options.contentType, 'application/octet-stream'); - }); - }); - }); - }); - - it('should handle buffer path from fs.readStream', function test() { - let file; - try { - file = fs.createReadStream(Buffer.from(paths.audio)); - } catch (ex) { - // Older Node.js versions do not support passing a Buffer - // representation of the path to fs.createReadStream() - if (ex instanceof TypeError) { - Promise.resolve(); - return; - } - } - const [{ [type]: data }] = bot._formatSendData('file', file); - assert.equal(data.options.filename, path.basename(paths.audio)); - }); - - it('should not accept file-paths if disallowed with constructor option', function test() { - const tgbot = new TelegramBot('token', { filepath: false }); - const [formData, fileId] = tgbot._formatSendData('file', paths.audio); - assert.ok(fileId); - assert.ok(!formData); - }); - - it('should allow stream.path that can not be parsed', function test() { - const stream = fs.createReadStream(paths.audio); - stream.path = '/?id=123'; // for example, 'http://example.com/?id=666' - assert.doesNotThrow(function assertDoesNotThrow() { - bot._formatSendData('file', stream); - }); - }); -}); diff --git a/test/unit/errors.test.ts b/test/unit/errors.test.ts new file mode 100644 index 00000000..c68423d0 --- /dev/null +++ b/test/unit/errors.test.ts @@ -0,0 +1,76 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { + BaseError, + FatalError, + ParseError, + TelegramError, + errors, +} from "../../src/errors.js"; + +describe("errors", () => { + describe("BaseError", () => { + it("formats message with code prefix", () => { + const err = new BaseError("EX", "boom"); + assert.equal(err.code, "EX"); + assert.equal(err.message, "EX: boom"); + }); + + it("serializes to JSON via toJSON()", () => { + const err = new BaseError("EX", "boom"); + assert.deepEqual(err.toJSON(), { code: "EX", message: "EX: boom" }); + }); + + it("preserves prototype chain so instanceof works after re-throw", () => { + const err = new BaseError("EX", "boom"); + assert.ok(err instanceof BaseError); + assert.ok(err instanceof Error); + }); + }); + + describe("FatalError", () => { + it("accepts a string message", () => { + const err = new FatalError("network down"); + assert.equal(err.code, "EFATAL"); + assert.equal(err.message, "EFATAL: network down"); + assert.equal(err.cause, undefined); + }); + + it("captures the cause when constructed from an Error", () => { + const root = new Error("disconnected"); + const err = new FatalError(root); + assert.equal(err.code, "EFATAL"); + assert.equal(err.message, "EFATAL: disconnected"); + assert.equal(err.cause, root); + assert.equal(err.stack, root.stack); + }); + }); + + describe("ParseError", () => { + it("attaches the response object", () => { + const response = { status: 500, body: "" }; + const err = new ParseError("bad json", response); + assert.equal(err.code, "EPARSE"); + assert.deepEqual(err.response, response); + }); + }); + + describe("TelegramError", () => { + it("attaches the response object", () => { + const response = { status: 400, body: { ok: false, description: "x" } }; + const err = new TelegramError("400 Bad Request", response); + assert.equal(err.code, "ETELEGRAM"); + assert.deepEqual(err.response, response); + }); + }); + + it("re-exports the full error registry", () => { + assert.deepEqual(Object.keys(errors).sort(), [ + "BaseError", + "FatalError", + "ParseError", + "TelegramError", + ]); + }); +}); diff --git a/test/unit/format-send-data.test.ts b/test/unit/format-send-data.test.ts new file mode 100644 index 00000000..b358378a --- /dev/null +++ b/test/unit/format-send-data.test.ts @@ -0,0 +1,130 @@ +/** + * Replaces the legacy `test/test.format-send-data.js` suite. The behaviour + * being verified is now spread across `prepareFile()` and the body-builder + * inside `HttpClient`. This test focuses on the format pipeline through + * `HttpClient` by stubbing `globalThis.fetch`. + */ + +import { afterEach, beforeEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { HttpClient } from "../../src/http.js"; +import { prepareFile } from "../../src/utils.js"; + +interface CapturedRequest { + url: string; + init: RequestInit; +} + +const originalFetch = globalThis.fetch; +let captured: CapturedRequest | null = null; + +function stubFetch(responseBody: unknown, status = 200) { + globalThis.fetch = (async (url: RequestInfo | URL, init: RequestInit = {}) => { + captured = { url: String(url), init }; + return new Response(JSON.stringify(responseBody), { + status, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; +} + +describe("format-send-data (via HttpClient)", () => { + beforeEach(() => { + captured = null; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("encodes form-only requests as application/x-www-form-urlencoded", async () => { + stubFetch({ ok: true, result: { user: 1 } }); + const client = new HttpClient("TEST_TOKEN"); + await client.request("getMe", { form: { foo: "bar", n: 42, b: true } }); + assert.ok(captured); + assert.equal( + (captured!.init.headers as Record)["content-type"], + "application/x-www-form-urlencoded", + ); + const body = String(captured!.init.body); + const params = new URLSearchParams(body); + assert.equal(params.get("foo"), "bar"); + assert.equal(params.get("n"), "42"); + assert.equal(params.get("b"), "true"); + }); + + it("uses multipart/form-data when a file is attached", async () => { + stubFetch({ ok: true, result: { message_id: 1 } }); + const client = new HttpClient("TEST_TOKEN"); + const buffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const { file } = await prepareFile(buffer); + assert.ok(file); + + await client.request("sendPhoto", { + qs: { chat_id: 99 }, + formData: { photo: file! }, + }); + + assert.ok(captured); + // fetch sets the multipart content-type on its own based on the FormData body. + assert.ok(captured!.init.body instanceof FormData); + const fd = captured!.init.body as FormData; + assert.equal(fd.get("chat_id"), "99"); + assert.ok(fd.get("photo")); + }); + + it("constructs the canonical Telegram URL", async () => { + stubFetch({ ok: true, result: { id: 1, is_bot: true, first_name: "TestBot" } }); + const client = new HttpClient("ABCDEF"); + await client.request("getMe"); + assert.equal(captured!.url, "https://api.telegram.org/botABCDEF/getMe"); + }); + + it("appends /test segment when testEnvironment is set", async () => { + stubFetch({ ok: true, result: {} }); + const client = new HttpClient("ABC", { testEnvironment: true }); + await client.request("getMe"); + assert.equal(captured!.url, "https://api.telegram.org/botABC/test/getMe"); + }); + + it("respects baseApiUrl override", async () => { + stubFetch({ ok: true, result: {} }); + const client = new HttpClient("ABC", { baseApiUrl: "http://127.0.0.1:9000" }); + await client.request("getMe"); + assert.equal(captured!.url, "http://127.0.0.1:9000/botABC/getMe"); + }); + + it("throws TelegramError for ok=false responses", async () => { + stubFetch({ ok: false, error_code: 400, description: "Bad Request: chat not found" }); + const client = new HttpClient("ABC"); + await assert.rejects(client.request("sendMessage"), /ETELEGRAM/); + }); + + it("throws ParseError for non-JSON responses", async () => { + globalThis.fetch = (async () => + new Response("not json", { status: 200, headers: { "content-type": "text/plain" } })) as typeof fetch; + const client = new HttpClient("ABC"); + await assert.rejects(client.request("getMe"), /EPARSE/); + }); + + it("skips undefined/null values in form bodies", async () => { + stubFetch({ ok: true, result: {} }); + const client = new HttpClient("ABC"); + await client.request("getMe", { form: { a: 1, b: undefined, c: null, d: "x" } }); + const params = new URLSearchParams(String(captured!.init.body)); + assert.equal(params.get("a"), "1"); + assert.equal(params.get("b"), null); + assert.equal(params.get("c"), null); + assert.equal(params.get("d"), "x"); + }); + + it("JSON-serializes object values in form bodies", async () => { + stubFetch({ ok: true, result: {} }); + const client = new HttpClient("ABC"); + await client.request("getMe", { form: { keyboard: [[1, 2]], obj: { a: 1 } } }); + const params = new URLSearchParams(String(captured!.init.body)); + assert.equal(params.get("keyboard"), "[[1,2]]"); + assert.equal(params.get("obj"), '{"a":1}'); + }); +}); diff --git a/test/unit/schemas.test.ts b/test/unit/schemas.test.ts new file mode 100644 index 00000000..6712ca43 --- /dev/null +++ b/test/unit/schemas.test.ts @@ -0,0 +1,140 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { + UpdateSchema, + MessageSchema, + UserSchema, + ChatSchema, + CallbackQuerySchema, + ReactionTypeSchema, + TelegramApiResponseSchema, + MESSAGE_TYPES, +} from "../../src/types/schemas.js"; + +describe("zod schemas", () => { + describe("UserSchema", () => { + it("accepts a minimal valid User", () => { + const u = UserSchema.parse({ id: 42, is_bot: false, first_name: "Alice" }); + assert.equal(u.id, 42); + assert.equal(u.first_name, "Alice"); + }); + + it("rejects when required fields are missing", () => { + assert.throws(() => UserSchema.parse({ id: 1 })); + }); + + it("preserves unknown fields via passthrough", () => { + const u = UserSchema.parse({ + id: 1, + is_bot: true, + first_name: "Bot", + // Hypothetical new field added by Telegram in the future + future_flag: true, + }); + assert.equal((u as Record).future_flag, true); + }); + }); + + describe("ChatSchema", () => { + it("validates the chat type enum", () => { + ChatSchema.parse({ id: -1, type: "supergroup" }); + assert.throws(() => ChatSchema.parse({ id: 1, type: "unknown" })); + }); + }); + + describe("MessageSchema", () => { + it("recursively validates pinned/replied messages", () => { + const msg = MessageSchema.parse({ + message_id: 1, + date: 1700000000, + chat: { id: 1, type: "private" }, + text: "hi", + reply_to_message: { + message_id: 0, + date: 1699999999, + chat: { id: 1, type: "private" }, + text: "older", + }, + }); + assert.equal(msg.reply_to_message?.text, "older"); + }); + }); + + describe("UpdateSchema", () => { + it("validates a polling getUpdates result entry", () => { + const update = UpdateSchema.parse({ + update_id: 1234, + message: { + message_id: 1, + date: 1700000000, + chat: { id: 7, type: "private" }, + text: "hello", + }, + }); + assert.equal(update.update_id, 1234); + assert.equal(update.message?.text, "hello"); + }); + + it("validates a callback_query update", () => { + const update = UpdateSchema.parse({ + update_id: 1, + callback_query: { + id: "abc", + from: { id: 1, is_bot: false, first_name: "X" }, + chat_instance: "abc", + data: "ping", + }, + }); + assert.equal(update.callback_query?.data, "ping"); + }); + }); + + describe("CallbackQuerySchema", () => { + it("requires id, from and chat_instance", () => { + assert.throws(() => + CallbackQuerySchema.parse({ + id: "1", + from: { id: 1, is_bot: false, first_name: "X" }, + }), + ); + }); + }); + + describe("ReactionTypeSchema", () => { + it("discriminates by `type`", () => { + const emoji = ReactionTypeSchema.parse({ type: "emoji", emoji: "👍" }); + assert.equal(emoji.type, "emoji"); + const custom = ReactionTypeSchema.parse({ type: "custom_emoji", custom_emoji_id: "1" }); + assert.equal(custom.type, "custom_emoji"); + const paid = ReactionTypeSchema.parse({ type: "paid" }); + assert.equal(paid.type, "paid"); + }); + }); + + describe("TelegramApiResponseSchema", () => { + it("accepts ok=true with arbitrary result", () => { + const r = TelegramApiResponseSchema.parse({ ok: true, result: { id: 1 } }); + assert.equal(r.ok, true); + }); + + it("accepts ok=false with description and error_code", () => { + const r = TelegramApiResponseSchema.parse({ + ok: false, + error_code: 400, + description: "Bad Request: chat not found", + }); + assert.equal(r.error_code, 400); + }); + }); + + describe("MESSAGE_TYPES", () => { + it("includes the legacy + 2024-2025 message events", () => { + assert.ok(MESSAGE_TYPES.includes("text")); + assert.ok(MESSAGE_TYPES.includes("photo")); + assert.ok(MESSAGE_TYPES.includes("video_chat_started")); + assert.ok(MESSAGE_TYPES.includes("message_reaction")); + assert.ok(MESSAGE_TYPES.includes("web_app_data")); + }); + }); +}); diff --git a/test/unit/telegram.test.ts b/test/unit/telegram.test.ts new file mode 100644 index 00000000..5f940601 --- /dev/null +++ b/test/unit/telegram.test.ts @@ -0,0 +1,340 @@ +/** + * Unit tests for the TelegramBot class. These do NOT hit the real Bot API — + * `globalThis.fetch` is stubbed to capture outgoing calls. + */ + +import { afterEach, beforeEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { TelegramBot } from "../../src/telegram.js"; + +interface CapturedRequest { + url: string; + init: RequestInit; +} + +const originalFetch = globalThis.fetch; +let captured: CapturedRequest[] = []; + +function stubFetch(handler: (url: string) => unknown) { + globalThis.fetch = (async (url: RequestInfo | URL, init: RequestInit = {}) => { + captured.push({ url: String(url), init }); + return new Response(JSON.stringify(handler(String(url))), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; +} + +describe("TelegramBot (unit)", () => { + beforeEach(() => { + captured = []; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("static errors property exposes the error classes", () => { + assert.ok(TelegramBot.errors); + assert.ok(TelegramBot.errors.FatalError); + assert.ok(TelegramBot.errors.TelegramError); + assert.ok(TelegramBot.errors.ParseError); + }); + + it("static messageTypes is an immutable list of known events", () => { + assert.ok(Array.isArray(TelegramBot.messageTypes)); + assert.ok(TelegramBot.messageTypes.includes("text")); + assert.ok(TelegramBot.messageTypes.includes("photo")); + }); + + describe("sendMessage()", () => { + it("posts to /sendMessage with chat_id and text", async () => { + stubFetch(() => ({ ok: true, result: { message_id: 1, date: 0, chat: { id: 1, type: "private" } } })); + const bot = new TelegramBot("TOKEN"); + await bot.sendMessage(123, "hello"); + const last = captured.at(-1)!; + assert.equal(last.url, "https://api.telegram.org/botTOKEN/sendMessage"); + const params = new URLSearchParams(String(last.init.body)); + assert.equal(params.get("chat_id"), "123"); + assert.equal(params.get("text"), "hello"); + }); + + it("JSON-serializes structured reply_markup", async () => { + stubFetch(() => ({ ok: true, result: { message_id: 1, date: 0, chat: { id: 1, type: "private" } } })); + const bot = new TelegramBot("TOKEN"); + await bot.sendMessage(1, "hi", { + reply_markup: { inline_keyboard: [[{ text: "ok", callback_data: "ok" }]] }, + }); + const params = new URLSearchParams(String(captured[0]!.init.body)); + const replyMarkup = params.get("reply_markup"); + assert.ok(replyMarkup); + const parsed = JSON.parse(replyMarkup!); + assert.equal(parsed.inline_keyboard[0][0].text, "ok"); + }); + }); + + describe("getMe()", () => { + it("returns the parsed result on ok", async () => { + stubFetch(() => ({ + ok: true, + result: { id: 7, is_bot: true, first_name: "Alfred" }, + })); + const bot = new TelegramBot("TOKEN"); + const me = await bot.getMe(); + assert.equal(me.id, 7); + assert.equal(me.first_name, "Alfred"); + assert.equal(me.is_bot, true); + }); + }); + + describe("forwardMessage()", () => { + it("attaches chat_id, from_chat_id, message_id", async () => { + stubFetch(() => ({ ok: true, result: { message_id: 1, date: 0, chat: { id: 1, type: "private" } } })); + const bot = new TelegramBot("TOKEN"); + await bot.forwardMessage(2, 1, 99); + const params = new URLSearchParams(String(captured[0]!.init.body)); + assert.equal(params.get("chat_id"), "2"); + assert.equal(params.get("from_chat_id"), "1"); + assert.equal(params.get("message_id"), "99"); + }); + }); + + describe("processUpdate() event dispatch", () => { + it("emits 'message' and the matching content-type sub-event", () => { + const bot = new TelegramBot("TOKEN"); + const seen: string[] = []; + bot.on("message", () => seen.push("message")); + bot.on("text", () => seen.push("text")); + + bot.processUpdate({ + update_id: 1, + message: { + message_id: 1, + date: 0, + chat: { id: 1, type: "private" }, + text: "hi", + }, + }); + assert.deepEqual(seen, ["message", "text"]); + }); + + it("dispatches callback_query updates", () => { + const bot = new TelegramBot("TOKEN"); + let cbq: unknown = null; + bot.on("callback_query", (q) => { + cbq = q; + }); + bot.processUpdate({ + update_id: 1, + callback_query: { + id: "abc", + from: { id: 1, is_bot: false, first_name: "X" }, + chat_instance: "x", + data: "ping", + }, + }); + assert.ok(cbq); + assert.equal((cbq as Record).data, "ping"); + }); + + it("invokes onText() callbacks for matching regex", () => { + const bot = new TelegramBot("TOKEN"); + let matched: string | null = null; + bot.onText(/^\/start (.+)/, (msg, match) => { + matched = match![1] ?? null; + }); + bot.processUpdate({ + update_id: 1, + message: { + message_id: 2, + date: 0, + chat: { id: 1, type: "private" }, + text: "/start arg1", + }, + }); + assert.equal(matched, "arg1"); + }); + + it("onText() compiles a string pattern into a RegExp at registration", () => { + const bot = new TelegramBot("TOKEN"); + let matched: string | null = null; + bot.onText("^/echo (.+)", (msg, match) => { + matched = match![1] ?? null; + }); + bot.processUpdate({ + update_id: 1, + message: { + message_id: 2, + date: 0, + chat: { id: 1, type: "private" }, + text: "/echo hello", + }, + }); + assert.equal(matched, "hello"); + }); + + it("invokes reply listeners", () => { + const bot = new TelegramBot("TOKEN"); + let replied = false; + bot.onReplyToMessage(1, 100, () => { + replied = true; + }); + bot.processUpdate({ + update_id: 1, + message: { + message_id: 200, + date: 0, + chat: { id: 1, type: "private" }, + text: "ok", + reply_to_message: { + message_id: 100, + date: 0, + chat: { id: 1, type: "private" }, + text: "what?", + }, + }, + }); + assert.equal(replied, true); + }); + }); + + describe("sendLivePhoto()", () => { + it("posts both live_photo and photo as fileIds when strings are passed", async () => { + stubFetch(() => ({ ok: true, result: { message_id: 1, date: 0, chat: { id: 1, type: "private" } } })); + const bot = new TelegramBot("TOKEN", { filepath: false }); + await bot.sendLivePhoto(42, "live-id-123", "photo-id-456", { caption: "hi" }); + const last = captured.at(-1)!; + assert.equal(last.url, "https://api.telegram.org/botTOKEN/sendLivePhoto"); + const params = new URLSearchParams(String(last.init.body)); + assert.equal(params.get("chat_id"), "42"); + assert.equal(params.get("caption"), "hi"); + assert.equal(params.get("live_photo"), "live-id-123"); + assert.equal(params.get("photo"), "photo-id-456"); + }); + }); + + describe("getUserPersonalChatMessages()", () => { + it("posts user_id and limit", async () => { + stubFetch(() => ({ ok: true, result: [] })); + const bot = new TelegramBot("TOKEN"); + await bot.getUserPersonalChatMessages(99, 10); + const params = new URLSearchParams(String(captured[0]!.init.body)); + assert.equal(params.get("user_id"), "99"); + assert.equal(params.get("limit"), "10"); + }); + }); + + describe("answerGuestQuery()", () => { + it("posts guest_query_id and JSON-serialized result", async () => { + stubFetch(() => ({ ok: true, result: { inline_message_id: "im_1" } })); + const bot = new TelegramBot("TOKEN"); + const out = await bot.answerGuestQuery("gq_1", { type: "article", id: "1", title: "t", input_message_content: { message_text: "x" } }); + const params = new URLSearchParams(String(captured[0]!.init.body)); + assert.equal(params.get("guest_query_id"), "gq_1"); + const result = JSON.parse(params.get("result")!); + assert.equal(result.type, "article"); + assert.equal(out.inline_message_id, "im_1"); + }); + }); + + describe("getManagedBotAccessSettings()", () => { + it("posts user_id and parses settings", async () => { + stubFetch(() => ({ ok: true, result: { is_access_restricted: true, added_users: [] } })); + const bot = new TelegramBot("TOKEN"); + const settings = await bot.getManagedBotAccessSettings(7); + const params = new URLSearchParams(String(captured[0]!.init.body)); + assert.equal(params.get("user_id"), "7"); + assert.equal(settings.is_access_restricted, true); + }); + }); + + describe("setManagedBotAccessSettings()", () => { + it("posts user_id, is_access_restricted and JSON-serialized added_user_ids", async () => { + stubFetch(() => ({ ok: true, result: true })); + const bot = new TelegramBot("TOKEN"); + await bot.setManagedBotAccessSettings(7, true, { added_user_ids: [1, 2, 3] }); + const params = new URLSearchParams(String(captured[0]!.init.body)); + assert.equal(params.get("user_id"), "7"); + assert.equal(params.get("is_access_restricted"), "true"); + assert.deepEqual(JSON.parse(params.get("added_user_ids")!), [1, 2, 3]); + }); + }); + + describe("JSON-serialization of structured params", () => { + it("JSON-serializes suggested_post_parameters via sendMessage", async () => { + stubFetch(() => ({ ok: true, result: { message_id: 1, date: 0, chat: { id: 1, type: "private" } } })); + const bot = new TelegramBot("TOKEN"); + await bot.sendMessage(1, "hi", { + suggested_post_parameters: { send_date: 1715000000, price: { currency: "XTR", amount: 100 } }, + }); + const params = new URLSearchParams(String(captured[0]!.init.body)); + const raw = params.get("suggested_post_parameters"); + assert.ok(raw); + const parsed = JSON.parse(raw!); + assert.equal(parsed.send_date, 1715000000); + assert.equal(parsed.price.currency, "XTR"); + }); + + it("JSON-serializes link_preview_options via sendMessage", async () => { + stubFetch(() => ({ ok: true, result: { message_id: 1, date: 0, chat: { id: 1, type: "private" } } })); + const bot = new TelegramBot("TOKEN"); + await bot.sendMessage(1, "hi", { + link_preview_options: { is_disabled: true, url: "https://example.com" }, + }); + const params = new URLSearchParams(String(captured[0]!.init.body)); + const raw = params.get("link_preview_options"); + assert.ok(raw); + const parsed = JSON.parse(raw!); + assert.equal(parsed.is_disabled, true); + assert.equal(parsed.url, "https://example.com"); + }); + + it("JSON-serializes link_preview_options via editMessageText", async () => { + stubFetch(() => ({ ok: true, result: { message_id: 1, date: 0, chat: { id: 1, type: "private" } } })); + const bot = new TelegramBot("TOKEN"); + await bot.editMessageText("new text", { + chat_id: 1, + message_id: 42, + link_preview_options: { prefer_small_media: true }, + }); + const params = new URLSearchParams(String(captured[0]!.init.body)); + const raw = params.get("link_preview_options"); + assert.ok(raw); + const parsed = JSON.parse(raw!); + assert.equal(parsed.prefer_small_media, true); + }); + + it("JSON-serializes text_entities via sendGift", async () => { + stubFetch(() => ({ ok: true, result: true })); + const bot = new TelegramBot("TOKEN"); + await bot.sendGift("gift_1", { user_id: 1, text: "hello", text_entities: [{ type: "bold", offset: 0, length: 5 }] }); + const params = new URLSearchParams(String(captured[0]!.init.body)); + const raw = params.get("text_entities"); + assert.ok(raw); + const parsed = JSON.parse(raw!); + assert.equal(parsed[0].type, "bold"); + }); + + it("leaves already-stringified params untouched", async () => { + stubFetch(() => ({ ok: true, result: { message_id: 1, date: 0, chat: { id: 1, type: "private" } } })); + const bot = new TelegramBot("TOKEN"); + const preSerialized = JSON.stringify({ send_date: 1715000000 }); + await bot.sendMessage(1, "hi", { + suggested_post_parameters: preSerialized as unknown as Record, + }); + const params = new URLSearchParams(String(captured[0]!.init.body)); + assert.equal(params.get("suggested_post_parameters"), preSerialized); + }); + }); + + describe("polling vs webhook safety", () => { + it("rejects startPolling() while a webhook is open", async () => { + const bot = new TelegramBot("TOKEN"); + // Stub _webHook to look open + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (bot as any)._webHook = { isOpen: () => true, open: async () => {}, close: async () => {} }; + await assert.rejects(bot.startPolling(), /mutually exclusive/); + }); + }); +}); diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts new file mode 100644 index 00000000..86fd12b2 --- /dev/null +++ b/test/unit/utils.test.ts @@ -0,0 +1,88 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { Readable } from "node:stream"; + +import { prepareFile, prepareFiles, stringify, streamToBuffer } from "../../src/utils.js"; + +describe("utils", () => { + describe("stringify()", () => { + it("returns strings unchanged", () => { + assert.equal(stringify("hello"), "hello"); + }); + + it("JSON-encodes non-strings", () => { + assert.equal(stringify(42), "42"); + assert.equal(stringify(true), "true"); + assert.equal(stringify({ a: 1 }), '{"a":1}'); + assert.equal(stringify([1, 2, 3]), "[1,2,3]"); + }); + }); + + describe("prepareFile()", () => { + it("returns null/null for nullish data", async () => { + assert.deepEqual(await prepareFile(null), { file: null, fileId: null }); + assert.deepEqual(await prepareFile(undefined), { file: null, fileId: null }); + }); + + it("treats unknown strings as fileId or URL when filepath lookup is off", async () => { + const result = await prepareFile("AgACABCD", {}, false); + assert.equal(result.file, null); + assert.equal(result.fileId, "AgACABCD"); + }); + + it("detects PNG via magic bytes from a buffer", async () => { + // 8-byte PNG signature + const buf = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]); + const { file } = await prepareFile(buf); + assert.ok(file, "expected file"); + assert.equal(file!.contentType, "image/png"); + assert.match(file!.filename, /\.png$/); + }); + + it("detects JPEG via magic bytes from a buffer", async () => { + const buf = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00]); + const { file } = await prepareFile(buf); + assert.equal(file?.contentType, "image/jpeg"); + }); + + it("falls back to octet-stream + given filename for unknown buffers", async () => { + const buf = Buffer.from([0x00, 0x01, 0x02, 0x03]); + const { file } = await prepareFile(buf, { filename: "blob.bin" }); + assert.equal(file?.contentType, "application/octet-stream"); + assert.equal(file?.filename, "blob.bin"); + }); + + it("preserves user-provided contentType", async () => { + const { file } = await prepareFile(Buffer.from([0x00]), { contentType: "application/x-custom" }); + assert.equal(file?.contentType, "application/x-custom"); + }); + + it("accepts Readable streams", async () => { + const stream = Readable.from(["hello"]); + const { file } = await prepareFile(stream, { filename: "greeting.txt" }); + assert.ok(file); + assert.equal(file!.filename, "greeting.txt"); + assert.equal(file!.contentType, "text/plain"); + }); + }); + + describe("prepareFiles()", () => { + it("groups buffers under the attach key with index suffix", async () => { + const inputs = [ + { media: Buffer.from([0x89, 0x50, 0x4e, 0x47]) }, + { media: "BAACABCD" }, + ]; + const { formData, fileIds } = await prepareFiles("media", inputs, {}, false); + assert.deepEqual(Object.keys(formData), ["media_0"]); + assert.equal(fileIds[1], "BAACABCD"); + }); + }); + + describe("streamToBuffer()", () => { + it("collects an async iterable into a buffer", async () => { + const stream = Readable.from([Buffer.from("foo"), Buffer.from("bar")]); + const buf = await streamToBuffer(stream); + assert.equal(buf.toString("utf8"), "foobar"); + }); + }); +}); diff --git a/test/utils.js b/test/utils.js deleted file mode 100644 index 78dec279..00000000 --- a/test/utils.js +++ /dev/null @@ -1,230 +0,0 @@ -/* eslint-disable no-use-before-define */ -exports = module.exports = { - /** - * Clear polling check, so that 'isPollingMockServer()' returns false - * if the bot stopped polling the mock server. - * @param {Number} port - */ - clearPollingCheck, - /** - * Redefine a bot method to allow us to ignore 429 (rate-limit) errors - * @param {TelegramBot} bot - * @param {String} methodName - * @param {Suite} suite From mocha - * @return {TelegramBot} - */ - handleRatelimit, - /** - * Return true if a webhook has been opened at the specified port. - * Otherwise throw an error. - * @param {Number} port - * @param {Boolean} [reverse] Throw error when it should have returned true (and vice versa) - * @return {Promise} - */ - hasOpenWebHook, - /** - * Return true if the mock server is being polled by a bot. - * Otherwise throw an error. - * @param {Number} port - * @param {Boolean} [reverse] Throw error when it should have returned true (and vice versa) - * @return {Promise} - */ - isPollingMockServer, - /** - * Return true if the string is a URI to a file - * on Telegram servers. - * @param {String} uri - * @return {Boolean} - */ - isTelegramFileURI, - /** - * Send a message to the webhook at the specified port and path. - * @param {Number} port - * @param {String} path - * @param {Object} [options] - * @param {String} [options.method=POST] Method to use - * @param {Object} [options.update] Update object to send. - * @param {Object} [options.message] Message to send. Default to a generic text message - * @param {Boolean} [options.https=false] Use https - * @return {Promise} - */ - sendWebHookRequest, - /** - * Send a message to the webhook at the specified port. - * @param {Number} port - * @param {String} token - * @param {Object} [options] - * @param {String} [options.method=POST] Method to use - * @param {Object} [options.update] Update object to send. - * @param {Object} [options.message] Message to send. Default to a generic text message - * @param {Boolean} [options.https=false] Use https - * @return {Promise} - */ - sendWebHookMessage, - /** - * Start a mock server at the specified port. - * @param {Number} port - * @param {Object} [options] - * @param {Boolean} [options.bad=false] Bad Mock Server; responding with - * unparseable messages - * @return {Promise} - */ - startMockServer, - /** - * Start the static server, serving files in './data' - * @param {Number} port - */ - startStaticServer, -}; -/* eslint-enable no-use-before-define */ - - -const assert = require('assert'); -const http = require('http'); -const request = require('@cypress/request-promise'); -const statics = require('node-static'); - -const servers = {}; - - -function startMockServer(port, options = {}) { - assert.ok(port); - const server = http.Server((req, res) => { - servers[port].polling = true; - if (options.bad) { - return res.end('can not be parsed with JSON.parse()'); - } - return res.end(JSON.stringify({ - ok: true, - result: [{ - update_id: 0, - message: { text: 'test' }, - }], - })); - }); - return new Promise((resolve, reject) => { - servers[port] = { server, polling: false }; - server.on('error', reject).listen(port, resolve); - }); -} - - -function startStaticServer(port) { - const fileServer = new statics.Server(`${__dirname}/data`); - http.Server((req, res) => { - req.addListener('end', () => { - fileServer.serve(req, res); - }).resume(); - }).listen(port); -} - - -function isPollingMockServer(port, reverse) { - assert.ok(port); - return new Promise((resolve, reject) => { - // process.nextTick() does not wait until a poll request - // is complete! - setTimeout(() => { - let polling = servers[port] && servers[port].polling; - if (reverse) polling = !polling; - if (polling) return resolve(true); - return reject(new Error('polling-check failed')); - }, 1000); - }); -} - - -function clearPollingCheck(port) { - assert.ok(port); - if (servers[port]) servers[port].polling = false; -} - - -function hasOpenWebHook(port, reverse) { - assert.ok(port); - const error = new Error('open-webhook-check failed'); - let connected = false; - return request.get(`http://127.0.0.1:${port}`) - .then(() => { - connected = true; - }).catch(e => { - if (e.statusCode < 500) connected = true; - }).finally(() => { - if (reverse) { - if (connected) throw error; - return; - } - if (!connected) throw error; - }); -} - - -function sendWebHookRequest(port, path, options = {}) { - assert.ok(port); - assert.ok(path); - const protocol = options.https ? 'https' : 'http'; - const url = `${protocol}://127.0.0.1:${port}${path}`; - return request({ - url, - method: options.method || 'POST', - body: options.update || { - update_id: 1, - message: options.message || { text: 'test' } - }, - json: (typeof options.json === 'undefined') ? true : options.json, - }); -} - - -function sendWebHookMessage(port, token, options = {}) { - assert.ok(port); - assert.ok(token); - const path = `/bot${token}`; - return sendWebHookRequest(port, path, options); -} - - -function handleRatelimit(bot, methodName, suite) { - const backupMethodName = `__${methodName}`; - if (!bot[backupMethodName]) bot[backupMethodName] = bot[methodName]; - - const maxRetries = 3; - const addSecs = 5; - const method = bot[backupMethodName]; - assert.equal(typeof method, 'function'); - - bot[methodName] = (...args) => { - let retry = 0; - function exec() { - return method.call(bot, ...args) - .catch(error => { - if (!error.response || error.response.statusCode !== 429) { - throw error; - } - retry++; - if (retry > maxRetries) { - throw error; - } - if (typeof error.response.body === 'string') { - error.response.body = JSON.parse(error.response.body); - } - const retrySecs = error.response.body.parameters.retry_after; - const timeout = (1000 * retrySecs) + (1000 * addSecs); - console.error('tests: Handling rate-limit error. Retrying after %d secs', timeout / 1000); // eslint-disable-line no-console - suite.timeout(timeout * 2); - return new Promise(function timeoutPromise(resolve, reject) { - setTimeout(function execTimeout() { - return exec().then(resolve).catch(reject); - }, timeout); - }); - }); - } - return exec(); - }; - return bot; -} - - -function isTelegramFileURI(uri) { - return /https?:\/\/.*\/file\/bot.*\/.*/.test(uri); -} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 00000000..290a4d3a --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "noEmit": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..4a6d263c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "outDir": "./dist", + "rootDir": ".", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": false, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": false, + "allowJs": false + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules", "dist", "lib"] +}