diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..239f5ba
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,101 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+#
+# ******** NOTE ********
+# We have attempted to detect the languages in your repository. Please check
+# the `language` matrix defined below to confirm you have the correct set of
+# supported CodeQL languages.
+#
+name: "CodeQL Advanced"
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+ schedule:
+ - cron: '39 13 * * 3'
+
+jobs:
+ analyze:
+ name: Analyze (${{ matrix.language }})
+ # Runner size impacts CodeQL analysis time. To learn more, please see:
+ # - https://gh.io/recommended-hardware-resources-for-running-codeql
+ # - https://gh.io/supported-runners-and-hardware-resources
+ # - https://gh.io/using-larger-runners (GitHub.com only)
+ # Consider using larger runners or machines with greater resources for possible analysis time improvements.
+ runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
+ permissions:
+ # required for all workflows
+ security-events: write
+
+ # required to fetch internal or private CodeQL packs
+ packages: read
+
+ # only required for workflows in private repositories
+ actions: read
+ contents: read
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - language: actions
+ build-mode: none
+ - language: javascript-typescript
+ build-mode: none
+ # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
+ # Use `c-cpp` to analyze code written in C, C++ or both
+ # Use 'java-kotlin' to analyze code written in Java, Kotlin or both
+ # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
+ # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
+ # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
+ # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
+ # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v7
+
+ # Add any setup steps before running the `github/codeql-action/init` action.
+ # This includes steps like installing compilers or runtimes (`actions/setup-node`
+ # or others). This is typically only required for manual builds.
+ # - name: Setup runtime (example)
+ # uses: actions/setup-example@v1
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v4
+ with:
+ languages: ${{ matrix.language }}
+ build-mode: ${{ matrix.build-mode }}
+ # If you wish to specify custom queries, you can do so here or in a config file.
+ # By default, queries listed here will override any specified in a config file.
+ # Prefix the list here with "+" to use these queries and those in the config file.
+
+ # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
+ # queries: security-extended,security-and-quality
+
+ # If the analyze step fails for one of the languages you are analyzing with
+ # "We were unable to automatically build your code", modify the matrix above
+ # to set the build mode to "manual" for that language. Then modify this step
+ # to build your code.
+ # ℹ️ Command-line programs to run using the OS shell.
+ # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
+ - name: Run manual build steps
+ if: matrix.build-mode == 'manual'
+ shell: bash
+ run: |
+ echo 'If you are using a "manual" build mode for one or more of the' \
+ 'languages you are analyzing, replace this with the commands to build' \
+ 'your code, for example:'
+ echo ' make bootstrap'
+ echo ' make release'
+ exit 1
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v4
+ with:
+ category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml
new file mode 100644
index 0000000..9256486
--- /dev/null
+++ b/.github/workflows/docker-image.yml
@@ -0,0 +1,21 @@
+name: Docker Image CI
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+permissions:
+ contents: read
+
+jobs:
+
+ build:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Build the Docker image
+ run: docker build . --file Dockerfile --tag my-image-name:$(date +%s)
diff --git a/.github/workflows/hadolint.yml b/.github/workflows/hadolint.yml
new file mode 100644
index 0000000..e0c212f
--- /dev/null
+++ b/.github/workflows/hadolint.yml
@@ -0,0 +1,47 @@
+# This workflow uses actions that are not certified by GitHub.
+# They are provided by a third-party and are governed by
+# separate terms of service, privacy policy, and support
+# documentation.
+# hadoint is a Dockerfile linter written in Haskell
+# that helps you build best practice Docker images.
+# More details at https://github.com/hadolint/hadolint
+
+name: Hadolint
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ # The branches below must be a subset of the branches above
+ branches: [ "main" ]
+ schedule:
+ - cron: '35 17 * * 1'
+
+permissions:
+ contents: read
+
+jobs:
+ hadolint:
+ name: Run hadolint scanning
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read # for actions/checkout to fetch code
+ security-events: write # for github/codeql-action/upload-sarif to upload SARIF results
+ actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Run hadolint
+ uses: hadolint/hadolint-action@f988afea3da57ee48710a9795b6bb677cc901183
+ with:
+ dockerfile: ./Dockerfile
+ format: sarif
+ output-file: hadolint-results.sarif
+ no-fail: true
+
+ - name: Upload analysis results to GitHub
+ uses: github/codeql-action/upload-sarif@v3
+ with:
+ sarif_file: hadolint-results.sarif
+ wait-for-processing: true
diff --git a/.github/workflows/jekyll-gh-pages.yml b/.github/workflows/jekyll-gh-pages.yml
new file mode 100644
index 0000000..67be9b0
--- /dev/null
+++ b/.github/workflows/jekyll-gh-pages.yml
@@ -0,0 +1,51 @@
+# Sample workflow for building and deploying a Jekyll site to GitHub Pages
+name: Deploy Jekyll with GitHub Pages dependencies preinstalled
+
+on:
+ # Runs on pushes targeting the default branch
+ push:
+ branches: ["main"]
+
+ # Allows you to run this workflow manually from the Actions tab
+ workflow_dispatch:
+
+# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
+# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
+concurrency:
+ group: "pages"
+ cancel-in-progress: false
+
+jobs:
+ # Build job
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Setup Pages
+ uses: actions/configure-pages@v5
+ - name: Build with Jekyll
+ uses: actions/jekyll-build-pages@v1
+ with:
+ source: ./
+ destination: ./_site
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+
+ # Deployment job
+ deploy:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ needs: build
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v5
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 34d311f..51d4251 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -10,6 +10,9 @@ on:
pull_request:
workflow_dispatch:
+permissions:
+ contents: read
+
jobs:
entrypoint:
runs-on: ubuntu-latest
diff --git a/example-bot/telegram.md b/example-bot/telegram.md
new file mode 100644
index 0000000..3485430
--- /dev/null
+++ b/example-bot/telegram.md
@@ -0,0 +1,33 @@
+
+
+
+
+
+ 404 | GramIO
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/local.md b/local.md
new file mode 100644
index 0000000..4671621
--- /dev/null
+++ b/local.md
@@ -0,0 +1,280 @@
+---
+url: 'https://gramio.dev/bot-api/local.md'
+---
+
+# Local Bot API Server
+
+By default GramIO talks to Telegram's cloud Bot API at `https://api.telegram.org`. You can instead run **your own** [Telegram Bot API server](https://github.com/tdlib/telegram-bot-api) and point GramIO at it.
+
+GramIO publishes a ready-to-use image: **`ghcr.io/gramiojs/telegram-bot-api`** (also on Docker Hub as `gramiojs/telegram-bot-api`) — multi-arch, non-root, healthchecked, signed, and rebuilt automatically when upstream updates.
+
+> \[!TIP]
+> Need **user mode** (drive a real account as a userbot), message search, `getChats`, or message scheduling? Those live in the [tdlight fork](/bot-api/tdlight) — use the typed [`@gramio/tdlight`](/bot-api/tdlight) layer. For a normal bot with bigger file limits, the official image on this page is the simpler, battle-tested choice.
+
+## Why self-host
+
+| | Cloud API | Local server (`--local`) |
+|---|---|---|
+| Upload | 50 MB | **2 GB** |
+| Download | 20 MB | **unlimited** |
+| Upload from disk | — | **`file://` local path** |
+| `getFile` result | download URL | **absolute path on disk** |
+| Webhooks | HTTPS, fixed ports | **HTTP, any IP/port** |
+
+If you only need small files and standard limits, the cloud API is simpler — stick with it.
+
+## Prerequisites
+
+You need **two different credentials** — don't mix them up:
+
+| Credential | Identifies | Where to get it |
+|---|---|---|
+| `BOT_TOKEN` | your **bot** | [@BotFather](https://t.me/BotFather) → `/newbot` (you already have this) |
+| `api_id` + `api_hash` | your **application** (required to run a local server) | [my.telegram.org](https://my.telegram.org) — steps below |
+
+**Getting `api_id` / `api_hash` (one-time, ~1 min):**
+
+1. Open [my.telegram.org](https://my.telegram.org) and log in with **your Telegram account's phone number** (your own account, *not* the bot — a login code arrives in your Telegram).
+2. Click **API development tools**.
+3. Fill in any **App title** and **Short name** (platform: *Other*) → **Create application**.
+4. Copy **`api_id`** (a number) and **`api_hash`** (a long string). Keep the hash secret.
+
+Then **log out of the cloud API first** — a bot token can't be used on the cloud and a local server at the same time. Call [`logOut`](/telegram/methods/logOut) once, then start your local server. Cloud login stays unavailable for ~10 minutes afterwards.
+
+```ts twoslash
+import { Bot } from "gramio";
+
+const cloudBot = new Bot(process.env.BOT_TOKEN as string);
+// ---cut---
+// Run once on the cloud API, before switching to your local server
+await cloudBot.api.logOut();
+```
+
+## Run the server
+
+::: code-group
+
+```sh [docker run]
+docker run -d --name telegram-bot-api \
+ -e TELEGRAM_API_ID=123456 \
+ -e TELEGRAM_API_HASH=your_api_hash \
+ -p 8081:8081 \
+ -v telegram-bot-api-data:/var/lib/telegram-bot-api \
+ ghcr.io/gramiojs/telegram-bot-api:latest
+```
+
+```yaml [docker-compose.yml]
+services:
+ telegram-bot-api:
+ image: ghcr.io/gramiojs/telegram-bot-api:latest
+ restart: unless-stopped
+ environment:
+ TELEGRAM_API_ID: ${TELEGRAM_API_ID}
+ TELEGRAM_API_HASH: ${TELEGRAM_API_HASH}
+ volumes:
+ - telegram-bot-api-data:/var/lib/telegram-bot-api
+ ports:
+ - "8081:8081"
+
+volumes:
+ telegram-bot-api-data:
+```
+
+:::
+
+The image runs with `--local` enabled by default. Set `TELEGRAM_LOCAL=0` to keep the cloud-style URL download flow (see [Downloading files](#downloading-files)).
+
+The image is a small (~50 MB) Alpine build, multi-arch (`amd64` + `arm64`), non-root, and signed.
+
+## Connect GramIO
+
+Point `api.baseURL` at your server. **Keep the `/bot` suffix** — GramIO appends the token to it.
+
+```ts twoslash
+import { Bot } from "gramio";
+// ---cut---
+const bot = new Bot(process.env.BOT_TOKEN as string, {
+ api: {
+ baseURL: "http://localhost:8081/bot",
+ },
+});
+```
+
+In Docker, use the service name instead of `localhost`, e.g. `http://telegram-bot-api:8081/bot`.
+
+## Downloading files
+
+This is the part that trips people up. In `--local` mode, [`getFile`](/telegram/methods/getFile) returns an **absolute path on the server's filesystem** (e.g. `/var/lib/telegram-bot-api//documents/file_5.jpg`) — **not** a download URL.
+
+If your bot runs in a **separate container/host from the server** (the common case), it can't read that path off disk, and `ctx.download()` / `bot.downloadFile()` — which build a `…/file/bot/` URL — won't work against `--local`.
+
+### Easiest: the bundled file server (`FILE_SERVER=1`)
+
+The image can serve files itself — no sidecar, no extra service. Set `FILE_SERVER=1` and the container also runs an nginx that serves the working dir over HTTP at **path-based, token-less** URLs (`http://host:8080//documents/file.jpg`). It's **off by default**; one env var turns it on. This is the simplest option on single-image platforms (Coolify, Dokploy, Railway, …):
+
+```sh
+docker run -d --name telegram-bot-api \
+ -e TELEGRAM_API_ID=123456 \
+ -e TELEGRAM_API_HASH=your_api_hash \
+ -e FILE_SERVER=1 \
+ -p 8081:8081 -p 8080:8080 \
+ -v telegram-bot-api-data:/var/lib/telegram-bot-api \
+ ghcr.io/gramiojs/telegram-bot-api:latest
+```
+
+Then turn the absolute `file_path` into a download URL (prefix swap), pointing at the file-server port:
+
+```ts
+const file = await bot.api.getFile({ file_id: ctx.document!.fileId });
+const rel = file.file_path!.replace("/var/lib/telegram-bot-api/", "");
+const url = `http://telegram-bot-api:8080/${rel}`; // token-less
+```
+
+`FILE_SERVER_PORT` (default `8080`) changes the port. Want one process per container instead? Use the nginx **sidecar** below.
+
+### Alternative: a separate nginx sidecar
+
+Run an `nginx` sidecar that shares the server's working-dir volume **read-only** and serves it over HTTP. The URLs are path-based, so the **bot token never appears in them**.
+
+`nginx/telegram-files.conf`:
+
+```nginx
+server {
+ listen 80;
+ location / {
+ root /var/lib/telegram-bot-api; # mounted read-only
+ autoindex off;
+ }
+}
+```
+
+Compose overlay:
+
+```yaml
+services:
+ nginx:
+ image: nginx:alpine
+ depends_on:
+ telegram-bot-api:
+ condition: service_healthy
+ volumes:
+ - telegram-bot-api-data:/var/lib/telegram-bot-api:ro
+ - ./nginx/telegram-files.conf:/etc/nginx/conf.d/default.conf:ro
+ ports:
+ - "8080:80"
+```
+
+Then turn the absolute `file_path` into a download URL by swapping the working-dir prefix for the nginx base URL:
+
+```ts
+import { Bot } from "gramio";
+
+const FILES_BASE_URL = "http://localhost:8080";
+const WORK_DIR = "/var/lib/telegram-bot-api";
+
+const bot = new Bot(process.env.BOT_TOKEN as string, {
+ api: { baseURL: "http://telegram-bot-api:8081/bot" },
+});
+
+bot.on("message", async (ctx) => {
+ const fileId = ctx.document?.fileId;
+ if (!fileId) return;
+
+ const file = await bot.api.getFile({ file_id: fileId });
+ if (!file.file_path) return;
+
+ // /var/lib/telegram-bot-api/123/documents/x.pdf -> http://localhost:8080/123/documents/x.pdf
+ const rel = file.file_path.replace(`${WORK_DIR}/`, "");
+ const url = `${FILES_BASE_URL}/${rel}`;
+
+ await ctx.reply(`Download: ${url}`); // no token in this link
+});
+```
+
+nginx serves [range requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests) natively, so multi-GB downloads resume correctly. Want access control? Add HTTP basic auth, an IP allow-list, or nginx `secure_link` — all optional and off by default.
+
+### Alternatives
+
+* **Bot shares the volume.** If the bot container mounts the same volume, just read `file.file_path` off disk with `fs`/`Bun.file` — no nginx needed.
+* **Disable `--local`.** With `TELEGRAM_LOCAL=0` the server downloads files itself and serves them at the familiar `…/file/bot/` URL, so `ctx.download()` keeps working — but you lose 2 GB uploads and unlimited downloads.
+
+## Uploading large files
+
+A local server raises the upload limit to **2 GB** (the cloud API caps documents at 50 MB — no way around that without a local server). Against a local server a **normal upload works up to 2 GB** — nothing special required:
+
+```ts
+import { Bot, MediaUpload } from "gramio";
+
+const bot = new Bot(process.env.BOT_TOKEN as string, {
+ api: { baseURL: "http://localhost:8081/bot" },
+});
+
+bot.on("message", (ctx) =>
+ // streamed over HTTP to your local server — up to 2 GB
+ ctx.sendDocument(MediaUpload.path("./big-archive.zip")),
+);
+```
+
+If the file already lives **on the server's own disk** (bot co-located, or a shared volume), `MediaUpload.localPath()` is an **optimization** — the server reads it directly via the `file://` scheme, so the bytes never travel over HTTP:
+
+```ts
+ctx.sendDocument(MediaUpload.localPath("/var/data/big-archive.zip"));
+```
+
+See the [media upload guide](/files/media-upload) and [`sendDocument`](/telegram/methods/sendDocument).
+
+> \[!TIP]
+> For very large files prefer `MediaUpload.stream(...)` over `MediaUpload.path(...)` — the latter currently reads the whole file into memory.
+
+## Webhooks
+
+A local server accepts **HTTP** webhooks on any IP and port (the cloud API requires HTTPS on a fixed set of ports). See [Webhook](/updates/webhook).
+
+## Environment variables
+
+Every `telegram-bot-api` option is exposed as an environment variable — the rule is `--some-option` → `TELEGRAM_SOME_OPTION`.
+
+| Variable | Flag | Default | Description |
+|---|---|---|---|
+| `TELEGRAM_API_ID` *(required)* | `--api-id` | — | application identifier from [my.telegram.org](https://my.telegram.org) |
+| `TELEGRAM_API_HASH` *(required)* | `--api-hash` | — | application hash from [my.telegram.org](https://my.telegram.org) |
+| `TELEGRAM_LOCAL` | `--local` | `1` | enable local mode (set `0` to disable) |
+| `FILE_SERVER` | *(bundled nginx)* | `0` | serve the working dir over HTTP (`1` to enable) |
+| `FILE_SERVER_PORT` | *(bundled nginx)* | `8080` | port for the bundled file server |
+| `TELEGRAM_WORK_DIR` | `--dir` | `/var/lib/telegram-bot-api` | server working directory |
+| `TELEGRAM_TEMP_DIR` | `--temp-dir` | `/tmp/telegram-bot-api` | directory for temporary files |
+| `TELEGRAM_HTTP_PORT` | `--http-port` | `8081` | HTTP listening port |
+| `TELEGRAM_STAT_PORT` | `--http-stat-port` | `8082` | HTTP statistics port (the healthcheck uses it) |
+| `TELEGRAM_HTTP_IP_ADDRESS` | `--http-ip-address` | — | local IP to accept HTTP connections on |
+| `TELEGRAM_HTTP_STAT_IP_ADDRESS` | `--http-stat-ip-address` | — | local IP to accept statistics connections on |
+| `TELEGRAM_FILTER` | `--filter` | — | `/` — shard bots across servers |
+| `TELEGRAM_MAX_WEBHOOK_CONNECTIONS` | `--max-webhook-connections` | — | default max webhook connections per bot |
+| `TELEGRAM_MAX_CONNECTIONS` | `--max-connections` | — | maximum number of open file descriptors |
+| `TELEGRAM_PROXY` | `--proxy` | — | HTTP proxy for outgoing webhook requests (`http://host:port`) |
+| `TELEGRAM_LOG_FILE` | `--log` | — | path to the log file |
+| `TELEGRAM_LOG_MAX_FILE_SIZE` | `--log-max-file-size` | `2000000000` | max log file size in bytes before rotation |
+| `TELEGRAM_VERBOSITY` | `--verbosity` | — | log verbosity level |
+| `TELEGRAM_MEMORY_VERBOSITY` | `--memory-verbosity` | `3` | in-memory log verbosity level |
+| `TELEGRAM_USERNAME` | `--username` | — | effective user name to switch to |
+| `TELEGRAM_GROUPNAME` | `--groupname` | — | effective group name to switch to |
+| `TELEGRAM_CPU_AFFINITY` | `--cpu-affinity` | — | CPU affinity as a 64-bit mask |
+| `TELEGRAM_MAIN_THREAD_AFFINITY` | `--main-thread-affinity` | — | CPU affinity of the main thread |
+
+`TELEGRAM_API_ID` / `TELEGRAM_API_HASH` also accept a `_FILE` suffix to read the value from a file (Docker/Kubernetes secrets). Any extra arguments passed to the container are appended to `telegram-bot-api` verbatim.
+
+## Verify it works
+
+```sh
+# stats endpoint responds
+curl http://localhost:8082/
+
+# container is healthy
+docker inspect --format '{{.State.Health.Status}}' telegram-bot-api
+```
+
+## See also
+
+* [Download files](/files/download) — GramIO download helpers
+* [`logOut`](/telegram/methods/logOut) · [`close`](/telegram/methods/close) — migrate off the cloud API
+* [`getFile`](/telegram/methods/getFile) · [`sendDocument`](/telegram/methods/sendDocument)
+* [tdlight Bot API Server](/bot-api/tdlight) — fork with user mode, extra methods, and unlimited file size
diff --git a/tdlight (1).md b/tdlight (1).md
new file mode 100644
index 0000000..dbaedd3
--- /dev/null
+++ b/tdlight (1).md
@@ -0,0 +1,397 @@
+---
+url: 'https://gramio.dev/bot-api/tdlight.md'
+---
+
+# tdlight Bot API Server
+
+[tdlight-telegram-bot-api](https://github.com/tdlight-team/tdlight-telegram-bot-api) is a community fork of Telegram's open-source [Bot API server](https://github.com/tdlib/telegram-bot-api), built on the lightweight **TDLight** library. It is a drop-in replacement: it speaks the standard Bot API **plus** a set of extra methods, extra fields on existing objects, and — its headline feature — an experimental **user mode** that lets you drive a real user account (a *userbot*), not just a bot.
+
+GramIO talks to it the same way it talks to any self-hosted server: through [`api.baseURL`](/bot-class). What was missing was **types** — calling `searchMessages` or reading `message.views` was untyped. That's what [`@gramio/tdlight`](https://github.com/gramiojs/tdlight) adds.
+
+> \[!IMPORTANT]
+> `@gramio/tdlight` is a **types-only** package. It augments [`@gramio/types`](/types) via TypeScript declaration merging so `bot.api.*` and the response objects gain tdlight's surface. There is no runtime — you still point GramIO at a tdlight server with `api.baseURL`. New to self-hosting? Read the [Local Bot API Server](/bot-api/local) guide first; everything there (the `/bot` suffix, file-download gotcha, 2 GB uploads) applies to tdlight too.
+
+## Why tdlight
+
+The standard Bot API is deliberately narrow: a bot can only see chats it was added to, can't search, can't list a group's full membership, can't schedule messages, and can never act as a user. tdlight lifts those limits.
+
+| Capability | Cloud API | Official local (`--local`) | **tdlight** |
+|---|---|---|---|
+| 2 GB uploads, unlimited downloads, `file://` paths | — | ✅ | ✅ |
+| Unlimited file **size** (`--no-file-limit`) | — | — | ✅ |
+| **User mode** — act as a real account (userbot) | — | — | ✅ |
+| Global message search ([`searchMessages`](#messages-scheduling)) | — | — | ✅ |
+| List all your chats ([`getChats`](#chats)) | — | — | ✅ |
+| Full member list ([`getChatMembers`](#members-info)) | partial | partial | ✅ (up to 200/page) |
+| Message scheduling ([`send_at`](#scheduling-messages)) | — | — | ✅ |
+| MTProto [proxies](#proxies) | — | — | ✅ |
+| `views` / `forwards` on messages | — | — | ✅ |
+| `is_scam` / `is_fake` / `is_verified` flags | — | — | ✅ |
+
+If you only need a normal bot with bigger file limits, the [official local server](/bot-api/local) is simpler and battle-tested — use that. Reach for tdlight when you specifically need user-mode or the extra read methods.
+
+> \[!WARNING]
+> **User mode is experimental and risky.** Logging a real account into a third-party server and automating it can get that account **limited or banned** by Telegram, especially for bulk actions (mass joining, adding members, scraping). Use a throwaway/secondary account, keep request rates low, and never run user mode against an account you can't afford to lose. tdlight itself labels user support as experimental.
+
+## Install
+
+::: pm-add @gramio/tdlight
+:::
+
+`@gramio/tdlight` declares [`@gramio/types`](/types) as a peer dependency — you already have it transitively through `gramio`.
+
+## Entry points — pick the smallest surface
+
+Declaration merging is **global per import**, so the package is split by mode. Import only what you need, and your `bot.api` won't be polluted with methods that can't run on your token.
+
+| Import | Adds to `bot.api` / objects | Use when |
+|---|---|---|
+| `@gramio/tdlight` | object fields + **bot-capable** methods (`ping`, `getChatMembers`, `getMessageInfo`, proxies…), scheduling params, `deleteMessages` range | A normal **bot token** against a tdlight server — the safe default |
+| `@gramio/tdlight/all` | everything above **plus** user-only methods (`searchMessages`, `votePoll`, `getChats`, the auth flow…) | A **userbot** (`/user` token), which uses both surfaces |
+| `@gramio/tdlight/user` | object fields + **only** the user-only methods | You want just the user-only delta |
+
+The import is a one-time side-effect — do it once (e.g. in your entry file) and the types apply everywhere.
+
+```ts
+// types apply globally for the rest of your app
+import "@gramio/tdlight";
+```
+
+## Quick start (bot mode)
+
+A tdlight server in bot mode behaves like the official local server, with extra methods bolted on.
+
+```ts
+import { Bot } from "gramio";
+import "@gramio/tdlight"; // augments bot.api with tdlight's bot-mode methods
+
+const bot = new Bot(process.env.BOT_TOKEN as string, {
+ api: {
+ baseURL: "http://localhost:8081/bot", // your tdlight server — keep the /bot suffix
+ },
+});
+
+bot.command("ping", async (ctx) => {
+ const seconds = await bot.api.ping(); // number — MTProto round-trip
+ await ctx.reply(`pong in ${seconds.toFixed(3)}s`);
+});
+
+bot.command("admins", async (ctx) => {
+ const admins = await bot.api.getChatMembers({
+ chat_id: ctx.chatId,
+ filter: "admins",
+ });
+ await ctx.reply(`${admins.length} admins`);
+});
+
+bot.start();
+```
+
+> The `/bot` suffix matters: GramIO builds the request URL as `${baseURL}${token}/${method}`. `"http://localhost:8081/bot"` ✅ · `"http://localhost:8081"` ❌ (the token glues to the host). See [Local Bot API Server → Connect GramIO](/bot-api/local#connect-gramio).
+
+## Run the tdlight server (Docker)
+
+tdlight publishes a multi-arch image to Docker Hub (`tdlight/tdlightbotapi`) and GHCR (`ghcr.io/tdlight-team/tdlightbotapi`). Like the official image, an entrypoint script maps `TELEGRAM_*` environment variables to CLI flags.
+
+You need an `api_id` / `api_hash` from [my.telegram.org](https://my.telegram.org) (identifies your *application*, not the bot) — exactly as for the [official local server](/bot-api/local#prerequisites). And as there, a bot token can't be used on the cloud and a local server at once: call [`logOut`](/telegram/methods/logOut) on the cloud API once before switching a bot over.
+
+::: code-group
+
+```sh [docker run]
+docker run -d --name tdlight \
+ -e TELEGRAM_API_ID=123456 \
+ -e TELEGRAM_API_HASH=your_api_hash \
+ -e TELEGRAM_LOCAL=1 \
+ -p 8081:8081 \
+ -v tdlight-data:/var/lib/telegram-bot-api \
+ tdlight/tdlightbotapi:latest
+```
+
+```yaml [docker-compose.yml]
+services:
+ tdlight:
+ image: tdlight/tdlightbotapi:latest
+ restart: unless-stopped
+ environment:
+ TELEGRAM_API_ID: ${TELEGRAM_API_ID}
+ TELEGRAM_API_HASH: ${TELEGRAM_API_HASH}
+ TELEGRAM_LOCAL: 1 # local mode: big files, on-disk file_path
+ TELEGRAM_ALLOW_USERS: 1 # enable user mode (omit for bot-only)
+ # TELEGRAM_ALLOW_USERS_REGISTRATION: 1 # allow registerUser (new accounts)
+ volumes:
+ - tdlight-data:/var/lib/telegram-bot-api
+ ports:
+ - "8081:8081"
+
+volumes:
+ tdlight-data:
+```
+
+:::
+
+> \[!WARNING]
+> **tdlight's env-var names are its own — don't copy them from the official image.** They overlap but differ: tdlight uses `TELEGRAM_STAT` (presence-only, enables the 8082 stats port), `TELEGRAM_MAX_BATCH` (→ `--max-batch-operations`), `TELEGRAM_STAT_HIDE_SENSIBLE_DATA`, `TELEGRAM_NO_FILE_LIMIT`, etc. The HTTP port is **hardcoded to 8081** in the entrypoint — there is no `TELEGRAM_HTTP_PORT`. To change it you must pass a full command (any positional arg makes the entrypoint exec it verbatim, skipping env processing).
+
+Build it yourself from the repo's `Dockerfile` (Alpine, non-root) if you prefer pinning to a commit. The data dir is `/var/lib/telegram-bot-api` (same as the official image), so the [file-download nginx pattern](/bot-api/local#downloading-files) carries over unchanged.
+
+## User mode (userbots)
+
+User mode lets `@gramio/tdlight` drive a **real account**. Requests go to `/user{token}/…` instead of `/bot{token}/…`, and many extra methods (`searchMessages`, `getChats`, `votePoll`, …) only work here. Enable it on the server with `TELEGRAM_ALLOW_USERS=1` (and `TELEGRAM_ALLOW_USERS_REGISTRATION=1` only if you need to register brand-new accounts).
+
+### The login flow
+
+A user token doesn't exist up front — you obtain it by logging in. This is **not** a normal method call: login starts with an empty-method `POST` to `/user{token}/` carrying the phone number, then proceeds through code → 2FA password → (optional) registration. Each step returns an [`AuthorizationState`](#new-objects).
+
+Because the first step has no method name, it can't go through `bot.api.*` — you make it with a plain `fetch` (a runtime login helper is planned for a future release). Once you hold a user token, the follow-up steps **are** typed methods:
+
+```ts
+import { Bot } from "gramio";
+import "@gramio/tdlight/all";
+
+const SERVER = "http://localhost:8081";
+
+// Step 1 — start login (empty method) and get a user token + state
+const res = await fetch(`${SERVER}/userlogin`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ phone_number: "+1555..." }),
+});
+const { result } = (await res.json()) as {
+ result: { token: string; authorization_state: string };
+};
+const USER_TOKEN = result.token;
+
+// Step 2+ — now use a typed Bot pointed at /user{token}
+const userbot = new Bot(USER_TOKEN, {
+ api: { baseURL: `${SERVER}/user` }, // note: /user, not /bot
+});
+
+// submit the login code the account just received (returns the next AuthorizationState)
+const afterCode = await userbot.api.authCode({ code: 12345 });
+
+// if the account has 2FA enabled:
+if (afterCode.authorization_state === "wait_password") {
+ await userbot.api.authPassword({ password: process.env.TWO_FA as string });
+}
+
+// the account is now logged in — drive it as a userbot
+const hits = await userbot.api.searchMessages({ query: "invoice", limit: 50 });
+```
+
+> \[!NOTE]
+> Some standard methods are **unavailable in user mode** (a user has no `answerCallbackQuery`, `setMyCommands`, sticker-set or payments methods, and can't attach `reply_markup`). And because command messages aren't created in chats without bots, your `bot.command(...)` handlers may never fire for a userbot — drive it imperatively or with `bot.on("message", …)` instead.
+
+## Extra methods
+
+All method names below are the ones the tdlight server **actually routes** (it lowercases the incoming name). Several names in tdlight's OpenAPI spec are wrong and would `404` — `@gramio/tdlight` types the real routes. See [Method-name traps](#method-name-traps).
+
+### Members & info
+
+```ts
+import "@gramio/tdlight";
+
+// full member list with filter + paging (bot-capable)
+const banned = await bot.api.getChatMembers({
+ chat_id: -1001234567890,
+ filter: "banned",
+ offset: 0,
+ limit: 200,
+});
+
+// `getParticipants` is an alias of `getChatMembers` (same handler)
+const all = await bot.api.getParticipants({ chat_id: -1001234567890 });
+
+// full info for a single message
+const msg = await bot.api.getMessageInfo({ chat_id: -1001234567890, message_id: 42 });
+```
+
+### Chats
+
+User-mode (`@gramio/tdlight/all` or `/user`):
+
+```ts
+import "@gramio/tdlight/all";
+
+const chats = await userbot.api.getChats(); // all your chats
+const common = await userbot.api.getCommonChats({ user_id: 777 }); // chats in common with a user
+const found = await userbot.api.searchPublicChats({ query: "gramio" });
+await userbot.api.joinChat({ invite_link: "https://t.me/+abc123" });
+const created = await userbot.api.createChat({
+ title: "Project X",
+ type: "supergroup",
+ description: "secret plans",
+});
+```
+
+### Messages & scheduling
+
+```ts
+import "@gramio/tdlight/all";
+
+const results = await userbot.api.searchMessages({ query: "deadline", limit: 100 });
+const inChat = await userbot.api.searchChatMessages({
+ chat_id: -1001234567890,
+ query: "release",
+ from_user_id: 777,
+});
+const scheduled = await userbot.api.getScheduledMessages({ chat_id: 777 });
+```
+
+### Interaction
+
+```ts
+import "@gramio/tdlight/all";
+
+// vote in a poll ⚠ the route is `votePoll`, NOT `setPollAnswer`
+await userbot.api.votePoll({ chat_id: -100..., message_id: 42, option_ids: [0, 2] });
+
+// trigger a callback button and read what the originating bot would have answered
+const answer = await userbot.api.getCallbackQueryAnswer({
+ chat_id: -100...,
+ message_id: 42,
+ callback_data: "buy:item-7",
+});
+```
+
+### Proxies
+
+Manage the MTProto proxies the server connects through (bot-capable):
+
+```ts
+import "@gramio/tdlight";
+
+const proxy = await bot.api.addProxy({
+ server: "1.2.3.4",
+ port: 443,
+ type: "mtproto",
+ secret: "ee...",
+});
+await bot.api.enableProxy({ proxy_id: proxy.id });
+const proxies = await bot.api.getProxies(); // TdlightProxy[]
+```
+
+## Scheduling messages
+
+tdlight adds `send_at` (and `repeat_period`) to the whole send/copy/forward family. Pass a Unix timestamp (≤ 365 days out) or the string `"online"` to send when the recipient is next online. Scheduled messages get a **negative** `message_id`.
+
+```ts
+import "@gramio/tdlight";
+
+// send when the recipient comes online
+await bot.api.sendMessage({ chat_id: 777, text: "ping", send_at: "online" });
+
+// send at a specific time
+await bot.api.sendDocument({
+ chat_id: 777,
+ document: "BQACAgI...",
+ send_at: Math.floor(Date.now() / 1000) + 3600, // in 1 hour
+});
+
+// reschedule (or send-now) an existing scheduled message
+await bot.api.editMessageScheduling({ chat_id: 777, message_id: -5, send_at: "online" });
+```
+
+## Deleting message ranges
+
+tdlight extends [`deleteMessages`](/telegram/methods/deleteMessages) with a `start`/`end` **range** form (supergroups only, `start < end`, bounded by `--max-batch-operations`, default 10000). `@gramio/tdlight` types `start?` and `end?` on the params.
+
+```ts
+import "@gramio/tdlight";
+
+// standard form (unchanged)
+await bot.api.deleteMessages({ chat_id: -100..., message_ids: [10, 11, 12] });
+
+// tdlight range form — delete everything from id 100 to 500
+await bot.api.deleteMessages({ chat_id: -100..., message_ids: [], start: 100, end: 500 });
+```
+
+> \[!NOTE]
+> **Known limitation:** `@gramio/types` types `message_ids` as **required**, and TypeScript declaration merging can't relax a required field. So the range form still type-requires `message_ids` — pass `[]`. The clean fix belongs upstream (making `message_ids` optional in `@gramio/types`).
+
+## Extra object fields
+
+The augmentation adds tdlight's extra fields (all optional) to the objects you already read off the context, so they're typed wherever a `User` / `Chat` / `Message` / chat member appears:
+
+```ts
+import "@gramio/tdlight";
+
+bot.on("message", (ctx) => {
+ // User extras
+ if (ctx.from?.is_scam) return; // is_scam / is_fake / is_verified / is_deleted
+ const seen = ctx.from?.user_status; // "online" | "offline" | "recently" | ...
+
+ // Message extras (channels)
+ const views = ctx.views; // number | undefined
+ const forwards = ctx.forwards; // number | undefined
+});
+```
+
+| Object | tdlight fields |
+|---|---|
+| `User` | `is_verified` · `is_scam` · `is_fake` · `is_deleted` · `user_status` · `last_seen` |
+| `Chat` | `is_verified` · `is_scam` · `is_fake` · `distance` |
+| `Message` | `views` · `forwards` · `is_scheduled` · `scheduled_at` |
+| chat members | `joined_date` · `inviter` |
+
+### New objects
+
+`@gramio/tdlight` also exports three new object types (from any entry point):
+
+* **`AuthorizationState`** — returned by the [login flow](#the-login-flow).
+* **`CallbackQueryAnswer`** — returned by `getCallbackQueryAnswer`.
+* **`TdlightProxy`** — returned by `getProxies` / `addProxy`.
+
+```ts
+import type { AuthorizationState, TdlightProxy } from "@gramio/tdlight";
+```
+
+## Method-name traps
+
+tdlight's published OpenAPI spec documents several method names the server doesn't actually route. `@gramio/tdlight` types the **real** routes (verified against the server's C++ source), so you don't hit silent `404`s:
+
+| Use this ✅ | Not this ❌ (404s) |
+|---|---|
+| `votePoll` | `setPollAnswer` |
+| `getMemoryStats` | `optimizeMemory` |
+| `addChatMembers` (plural) | `addChatMember` |
+| `getParticipants` (alias of `getChatMembers`) | — |
+| login via empty-method `POST` | `userLogin` (no such method) |
+
+> \[!NOTE]
+> `getMemoryStats`, `toggleGroupInvites`, and `reportChat` are **accepted but no-ops / unimplemented** on current tdlight builds — they're typed (with a JSDoc note) for completeness, but don't rely on their effects.
+
+## Downloading files
+
+File handling is identical to the [official local server](/bot-api/local#downloading-files): in `--local` mode, [`getFile`](/telegram/methods/getFile) returns an **absolute path on disk**, not a URL, so `ctx.download()` won't work against a split deployment. Serve files with the [nginx sidecar pattern](/bot-api/local#recommended-serve-files-with-nginx-no-bot-token-in-the-url) — it works unchanged because tdlight uses the same `/var/lib/telegram-bot-api` work dir.
+
+## Environment variables (tdlight-specific)
+
+The `--some-option` → `TELEGRAM_SOME_OPTION` rule mostly holds, but **tdlight has its own names** for several — don't copy them from the official image.
+
+| Variable | Flag | Notes |
+|---|---|---|
+| `TELEGRAM_API_ID` *(required)* | `--api-id` | from [my.telegram.org](https://my.telegram.org) |
+| `TELEGRAM_API_HASH` *(required)* | `--api-hash` | from [my.telegram.org](https://my.telegram.org) |
+| `TELEGRAM_LOCAL` | `--local` | presence-only — big files, on-disk `file_path` |
+| `TELEGRAM_ALLOW_USERS` | `--allow-users` | set `1` to enable user mode |
+| `TELEGRAM_ALLOW_USERS_REGISTRATION` | `--allow-users-registration` | set `1` to allow `registerUser` |
+| `TELEGRAM_NO_FILE_LIMIT` | `--no-file-limit` | presence-only — remove file-size cap |
+| `TELEGRAM_MAX_BATCH` | `--max-batch-operations` | range-delete cap (default 10000) |
+| `TELEGRAM_HTTP_IDLE_TIMEOUT` | `--http-idle-timeout` | seconds; default 500 |
+| `TELEGRAM_STAT` | `--http-stat-port=8082` | presence-only — enables the stats port |
+| `TELEGRAM_STAT_HIDE_SENSIBLE_DATA` | `--stats-hide-sensible-data` | hide token/webhook on the stats page |
+| `TELEGRAM_INSECURE` | `--insecure` | allow HTTP in non-local mode |
+| `TELEGRAM_RELATIVE` | `--relative` | allow only relative file paths in local mode |
+| `TELEGRAM_VERBOSITY` | `--verbosity` | log level (0–4, 1024) |
+| `TELEGRAM_PROXY` | `--proxy` | outgoing webhook proxy |
+| `TELEGRAM_WORK_DIR` | `--dir` | default `/var/lib/telegram-bot-api` |
+| `TELEGRAM_TEMP_DIR` | `--temp-dir` | default `/tmp/telegram-bot-api` |
+
+The HTTP API port is **hardcoded to 8081** in the entrypoint (no env var). Pass `TELEGRAM_API_ID`/`TELEGRAM_API_HASH` directly — the binary reads them from the environment.
+
+## See also
+
+* [Local Bot API Server](/bot-api/local) — the official server; prerequisites, file downloads, and 2 GB uploads all apply to tdlight too
+* [@gramio/types](/types) — the package `@gramio/tdlight` augments
+* [Bot configuration](/bot-class) — `api.baseURL` and other options
+* [`logOut`](/telegram/methods/logOut) · [`getFile`](/telegram/methods/getFile) · [`deleteMessages`](/telegram/methods/deleteMessages)