# Command line and Python API

This page lists every `telegram-archive` command with its flags, output and exit codes. It also covers the other entry points, the scripts shipped in the backup image and the supported Python API.

## Ways to run it

`telegram-archive` and `python -m telegram_archive` are the same command line. Every command reads its configuration from environment variables, listed in [Environment variables](<https://geiserx.github.io/Telegram-Archive/reference/environment-variables/index.md>).

```
telegram-archive --data-dir ./data list-chats
python -m telegram_archive --data-dir ./data list-chats
```

The images do not install the `telegram-archive` console script. Use `python -m telegram_archive` in the backup image. The viewer image carries no command line.

```
# Commands that talk to Telegram or write the database
docker compose run --rm telegram-backup python -m telegram_archive <command>

# Read-only commands, inside the running container
docker compose exec telegram-backup python -m telegram_archive stats
```

The container's root filesystem is read-only. Write any output file under `/data`, which is the `./data` folder on the host.

### Global option

| Option | Argument | Meaning |
| --- | --- | --- |
| `--data-dir` | `PATH` | Sets `BACKUP_PATH` to `PATH/backups` and `SESSION_DIR` to `PATH/session`, and creates both directories. |

`--data-dir` must come before the subcommand. It does not override `DATABASE_URL`, `DATABASE_PATH`, `DATABASE_DIR` or `DB_PATH` when one of them is set. Without it, `BACKUP_PATH` defaults to `/data/backups` and the session directory to `/data/session`.

Running `telegram-archive` with no arguments prints help and exits 0. Running it with `--data-dir` and no subcommand creates the directories, prints help and exits 0. A missing required flag or an unknown option prints the usage and exits 2.

### Which commands need Telegram

| Needs an authorized Telegram session | Database only, no Telegram credentials |
| --- | --- |
| `auth`, `backup`, `schedule`, `fill-gaps`, `backfill-topics`, `reclassify-round-videos`, `backfill-details` | `migrate`, `export`, `export-chat`, `stats`, `status`, `check-media`, `list-chats`, `import`, `merge` |

> [!WARNING]
>
> **One client per session**
>
> Stop the backup service before any command that connects to Telegram. See [One client per session](<https://geiserx.github.io/Telegram-Archive/getting-started/telegram-login/#one-client-per-session>).

> [!NOTE]
>
> **With pip, run migrate by hand**
>
> The backup image migrates the database each time it starts. A pip install does not. Run [`migrate`](<#migrate>) before any other command, and again after every upgrade. See [Install from PyPI](<https://geiserx.github.io/Telegram-Archive/getting-started/pip/index.md>).

## auth

```
telegram-archive [--data-dir PATH] auth [--qr]
```

| Flag | What it does |
| --- | --- |
| `--qr` | Asks for no code. It prints a QR code to scan from a Telegram app that is already logged in, under Settings \> Devices \> Link Desktop Device. A new code replaces the old one about every 30 seconds, and the command gives up after ten. When two-step verification is on, it asks for the password without showing it. The phone number check and the exit codes are the same as without the flag. |

Logs in to Telegram interactively and saves the session file. It walks every configured account in turn and skips accounts whose session is already authorized. For each login it prompts `Enter verification code (or qr):`. Typing `qr` there switches to the QR login. When two-step verification is on, it also prompts `Enter your 2FA password:`. It then checks that the logged-in phone number matches the configured one and fails if they differ. A failure stops the walk, so the accounts after it are not tried. Fix that account and run `auth` again. It does not touch the database. See [Log in to Telegram](<https://geiserx.github.io/Telegram-Archive/getting-started/telegram-login/index.md>) and [The code never arrives](<https://geiserx.github.io/Telegram-Archive/getting-started/telegram-login/#the-code-never-arrives>).

It prints progress lines, then a blank line and one of two endings. On success it prints `✓ Setup completed successfully!` followed by a short "Next steps" block. On failure it prints `✗ Setup failed. Please check the errors above.` On a permission error it prints how to give the container's user, uid 1000, write access to the data directory.

It exits 0 when every account is authorized and 1 on any failure.

## backup

```
telegram-archive [--data-dir PATH] backup
```

Takes no flags.

Backs up every configured account once, then exits. It first moves the files in `media/_shared` into subfolders named after the first two characters of each file's SHA-256 hash. This happens once and does nothing on later runs. It runs no gap-fill and writes no heartbeat. See [Your first backup](<https://geiserx.github.io/Telegram-Archive/getting-started/first-backup/index.md>) and [Schedule and backup tuning](<https://geiserx.github.io/Telegram-Archive/configuration/schedule/index.md>).

It prints log lines only.

It exits 0 on success. A configuration error, a session that is not authorized or any other error ends the command with a traceback and a non-zero exit code. With several accounts, the command logs a failing account and continues with the others. It fails only when every account failed.

## schedule

```
telegram-archive [--data-dir PATH] schedule
```

Takes no flags.

Runs the scheduler until stopped. The stock compose file runs this command. It does the same one-time media move as `backup`, starts the real-time listeners (on by default, `ENABLE_LISTENER`), and runs one backup straight away. After that it runs a full pass on the `SCHEDULE` cron expression: `0 3 * * *` by default, or `0 */6 * * *` with `ENABLE_LISTENER=false`. It logs one `Capture mode:` line at startup that says which of the two it runs. With `FILL_GAPS=true` it runs gap-fill after each backup. With `TRANSCRIPTION_URL` set it also runs a transcription drain every `TRANSCRIPTION_DRAIN_INTERVAL_MINUTES`, 15 by default. See [When files are sent](<https://geiserx.github.io/Telegram-Archive/configuration/transcription/#when-files-are-sent>). It writes a heartbeat file every 30 seconds for the container health check. See [Schedule and backup tuning](<https://geiserx.github.io/Telegram-Archive/configuration/schedule/index.md>).

It prints log lines only. It runs until stopped. A configuration error or a fatal error exits 1.

## migrate

```
telegram-archive [--data-dir PATH] migrate
```

Takes no flags.

Runs `alembic upgrade head` with the Alembic configuration bundled in the package. It finds the database through the same variables as every other command, and creates the SQLite directory when it is missing. Unlike the image's entrypoint, it does not stamp a database that has tables but no migration history. See [SQLite and PostgreSQL](<https://geiserx.github.io/Telegram-Archive/configuration/database/#migrations>).

On success it prints `Database schema is up to date.` and exits 0. On failure it prints `Migration failed: <error>` on stderr and exits 1.

## export

```
telegram-archive [--data-dir PATH] export -o FILE [-c CHAT_ID] [-s YYYY-MM-DD] [-e YYYY-MM-DD]
                                   [--changed-since TIMESTAMP]
```

| Short | Long | Argument | Required | Meaning |
| --- | --- | --- | --- | --- |
| `-o` | `--output` | `FILE` | yes | Output JSON file. Its parent directory is created. |
| `-c` | `--chat-id` | `CHAT_ID` | no | Export only this chat's messages. |
| `-s` | `--start-date` | `YYYY-MM-DD` | no | Keep messages dated on or after midnight UTC of this day. |
| `-e` | `--end-date` | `YYYY-MM-DD` | no | Keep messages dated on or before midnight UTC of this day. |
|  | `--changed-since` | `TIMESTAMP` | no | Keep only messages whose archive record changed at or after this time. See [Exporting only what changed](<#export-changed-since>). |

Writes messages from the archive to one JSON file. It lists each message's media but exports no media files and no file paths. For a full copy of the archive, see [Backing up the archive](<https://geiserx.github.io/Telegram-Archive/operations/backup-and-restore/index.md>).

> [!WARNING]
>
> **The end date is exclusive in practice**
>
> Both dates are compared as midnight UTC. `-e 2024-12-31` keeps messages up to 00:00 on 31 December and leaves out the rest of that day. To include the whole of 2024, use `-s 2024-01-01 -e 2025-01-01`.

The command writes the file with an indent of 2 and keeps non-ASCII text as UTF-8. It writes dates and other values JSON cannot hold as strings. The top-level keys are:

| Key | Content |
| --- | --- |
| `export_date` | The time of the export, UTC, ISO 8601. |
| `filters` | `chat_id`, `start_date` and `end_date` as given, or `null`, and `changed_since` as the UTC time it was read as (ISO 8601, no offset), or `null`. |
| `statistics` | `total_messages`, `total_chats`, `total_message_versions` (the entries of every message's `versions`) `total_transcripts` and `total_message_snapshots` (the entries of every message's `snapshots`). |
| `chats` | Every chat in the archive, even with `-c`. |
| `messages` | The selected messages, ordered by date, oldest first. |

Before 9.0 the file also held a flat `message_versions` list, and `total_message_versions` counted it. The list is gone: every version is under its message. See [Upgrading to 9.0](<https://geiserx.github.io/Telegram-Archive/operations/upgrading/#upgrading-to-90>).

Each message has `id`, `chat_id`, `sender_id`, `sender_name`, `date`, `text`, `reply_to_msg_id`, `reply_to_top_id`, `reply_to_text`, `forward_from_id`, `edit_date`, `edit_hide`, `raw_data`, `created_at`, `is_outgoing`, `is_pinned`, `is_deleted`, `deleted_at`, `account_id`, `media`, `versions`, `reaction_history` and `snapshots`. Messages deleted in soft mode are included, with `is_deleted` set to 1 and `deleted_at` set to when the archive noticed the deletion. `media` lists the message's current media and `versions` every earlier version the archive kept of it, oldest first, whatever its date. Both have the fields of the viewer's [Export](<https://geiserx.github.io/Telegram-Archive/reference/api/#export>): each version has `text`, `date` (when that text was current in Telegram), `captured_at` (when the archive saw it replaced), `source`, `entities` and `rich_message` as in [Message versions](<https://geiserx.github.io/Telegram-Archive/reference/api/#message-versions>), and `media`, the earlier media it was shown with, and `media_only` on an entry that holds earlier media and no text. The dates are written in the same format as the message's own dates. `reaction_history` lists every state of the message's reactions the archive kept, oldest first: `emoji`, `count` (0 when taken back), `previous_count`, `observed_at` and `source`, as in [the messages list](<https://geiserx.github.io/Telegram-Archive/reference/api/#messages>). `snapshots` lists every later state of the message's poll or link preview the archive kept, oldest first: `kind` (`poll` or `preview`), `payload` (the whole state, in the shape of `raw_data.poll` or `raw_data.webpage`), `observed_at` and `source` (`listener`, `sync` or `backup`). `raw_data` keeps the first capture. See [Poll and link preview snapshots](<https://geiserx.github.io/Telegram-Archive/reference/api/#poll-and-link-preview-snapshots>). Every transcript in the file names a media listed on its message or under one of its versions. Everything is read from one snapshot of the archive, so a backup running meanwhile cannot make a message disagree with its versions, its media or its transcripts. `edit_hide` is 1 when Telegram says the edit at `edit_date` is not to be shown, as it does when only the reactions changed: such a message was not edited unless `versions` holds an earlier text of it. It is null when the source did not report the flag: a message archived before the archive kept it, or one from a Telegram export import. A null flag counts as shown. A message with voice or media transcripts also has a `transcripts` list.

It exits 0 on success. On any failure, a date in the wrong format included, it prints `Export failed: <error>` on stderr and exits 1.

### Exporting only what changed

`-s` and `-e` select by the date Telegram gave the message. An old message that was edited, deleted or reacted to last night keeps its old date, so a date window never picks it up again. `--changed-since` selects by what the archive did instead. It keeps a message when, at or after the given time, the archive:

- stored the message for the first time (`created_at`),
- saw it deleted in Telegram (`deleted_at`),
- kept an earlier text of it (`versions[].captured_at`),
- kept an earlier media of it, replaced by an edit (`media_versions.captured_at`),
- kept a later state of its poll or link preview (`snapshots[].observed_at`), or
- kept a new state of its reactions (`reaction_history[].observed_at`).

Each kept message is written with its whole history, as without the flag: versions, reaction states and snapshots from before the given time stay in it. The flag narrows `-c`, `-s` and `-e`; it never widens them. It works the same on SQLite and PostgreSQL, and reads only the rows that changed, so a nightly run costs about the same on a small archive as on a large one.

Some changes are not on that list, so they never select a message: the ones the archive writes into the message row itself, with no history row. A pin or unpin, a media download that finishes later, a transcript that finishes later, and a `sender_name` or `raw_data` filled in later all leave the message out of the next incremental file. With `DELETION_MODE=hard` a deleted message is removed from the archive, so no export, incremental or full, holds it any more. Run a full export now and then to catch the first kind.

The time is ISO 8601. A `T` or a space can separate the date and the time. `Z` or an offset such as `+02:00` is converted to UTC; a time with neither is read as UTC. A bare date means its midnight UTC. Anything else, an empty value included, fails the export with `Export failed: Invalid --changed-since timestamp`.

To export on a schedule and miss nothing:

1. Note the time, in UTC, just before each run starts, and keep it with the file.
2. Start the next run from that time minus a few seconds: `--changed-since 2026-06-01T03:59:55Z` for a run that started at 04:00:00. A change written while the previous run was reading then lands in both files rather than in neither.
3. When you merge the files, treat `account_id`, `chat_id` and `id` together as the key of a message, and keep the copy from the newest file. The overlap and a message changed twice both give you the same message in more than one file.

The first run exports without the flag. Each later run, with GNU `date`:

```
# This run's start time, minus a 10-second overlap.
next=$(date -u -d '10 seconds ago' +%Y-%m-%dT%H:%M:%SZ)
telegram-archive export -o "changes-$next.json" --changed-since "$(cat last-export-start)" \
  && echo "$next" > last-export-start
```

The stored time only moves when the export succeeds, so a failed run is covered by the next one. If `last-export-start` is missing or empty, the export fails rather than writing the whole archive under a name that says it holds only changes.

## export-chat

```
telegram-archive [--data-dir PATH] export-chat --chat CHAT [--account N] [--format json|html]
                                   [--media KINDS] [--max-file-mb N] [--no-players]
                                   [--from DATE] [--to DATE] [--changed-since TIMESTAMP]
                                   [-o PATH] [--force]
```

| Short | Long | Argument | Required | Meaning |
| --- | --- | --- | --- | --- |
|  | `--chat` | `CHAT` | yes | The chat: its id in marked format (for example `-1001234567890`), its ref from the viewer's address bar, or `@username`. Write `--chat=REF` for a ref that starts with `-`. |
|  | `--account` | `N` | when more than one account holds the chat | The account id that holds the chat. |
|  | `--format` | `json` or `html` | no | `json` (the default) or `html`. |
|  | `--media` | `KINDS` | no | HTML only. The media types to copy, separated by commas: `photo`, `video`, `voice`, `video_note`, `gif`, `sticker`, `file`. Without it the pages hold text only. |
|  | `--max-file-mb` | `N` | no | HTML only. Leave out any file larger than N MB, from 1 to 3900. The default is 50. |
|  | `--no-players` |  | no | HTML only. Show videos, GIFs, voice messages and audio as file cards instead of players. |
|  | `--from` | `DATE` | no | Keep messages from this day or time on, ISO 8601. |
|  | `--to` | `DATE` | no | Keep messages up to this day or time, ISO 8601. A bare date includes that whole day. |
|  | `--changed-since` | `TIMESTAMP` | no | Keep only messages whose archive record changed at or after this time, as for [`export`](<#export-changed-since>). |
| `-o` | `--output` | `PATH` | no | The file to write. The default is `<title>_export.json` or `<title>_export.zip` in the current directory. `-` writes JSON to standard output. |
|  | `--force` |  | no | Replace the output file when it exists. |

Exports one chat to the same file the viewer's **Export chat** downloads, without the viewer running. JSON is the viewer's JSON export byte for byte, with the fields listed in [Export](<https://geiserx.github.io/Telegram-Archive/reference/api/#export>). HTML is the viewer's ZIP of pages that open offline, described in [Export as HTML](<https://geiserx.github.io/Telegram-Archive/viewer/using-the-viewer/#export-as-html>). Media files are read from the archive's media folder, `BACKUP_PATH/media`, and the timezone of the pages is `VIEWER_TIMEZONE`. The options and their limits are the viewer's, and a refused option gets the same message.

```
telegram-archive export-chat --chat @example_channel --format html --media photo,video,voice
```

In Docker, write the file under `/data`, for example `-o /data/chat.zip`, because the container's root filesystem is read-only.

It prints `Exported to <path>` on stderr and exits 0. An existing file is kept: the command prints `Export refused: <path> exists; pass --force to replace it.` and exits 2. A refused option prints `Export refused: <reason>` and exits 2, and so does a chat id or username that more than one account holds, until `--account` names one. A username that two chats of the same account carry is refused too; pass that chat's id or ref instead. A chat the archive does not hold prints `Chat not found.` and exits 1. Any other failure prints `Export failed: <error type>` on stderr and exits 1. The file is written under a hidden `.<name>.….part` name in the same folder and moved into place only when it is complete, so a failed run never leaves a partial file under the output name and never removes the file `--force` was going to replace. A run killed by a signal can leave the `.part` file behind; delete it. It needs no Telegram session and no web viewer.

## stats

```
telegram-archive [--data-dir PATH] stats
```

Takes no flags.

Prints the archive's totals. It reads the figures cached in the database. A completed backup, a gap-fill that recovered messages, the viewer's first start when nothing is cached yet, the viewer's daily job or a `POST /api/stats/refresh` call from a master login recalculates them. Until one of those has run, every figure is 0.

It prints:

```
============================================================
Backup Statistics
============================================================
Total chats:        <n>
Total messages:     <n>
Media files:        <n>
Total storage:      <n> MB
============================================================
```

It exits 0 on success. On failure it prints `Stats failed: <error>` on stderr and exits 1.

## status

```
telegram-archive [--data-dir PATH] status [--json]
```

| Short | Long | Argument | Required | Meaning |
| --- | --- | --- | --- | --- |
|  | `--json` |  | no | Print the status as JSON instead of text. |

Says whether the archive is healthy, for a cron job or a monitoring check. It reads the database directly, so the viewer does not need to run and no viewer login is needed. It reports what the master login's [Archive status](<https://geiserx.github.io/Telegram-Archive/viewer/using-the-viewer/#archive-status>) page shows, except transcription: the last backup run, the listener of each Telegram account, the media counts, when the statistics were last calculated, and the database backend and size. It prints counts and times only, never chat ids, titles or text.

The archive is unhealthy when one of these holds:

- No backup run has ever started.
- The last run did not finish. It is not running, and the statistics a run writes after its message sweep are older than its start.
- `SCHEDULE` has fired twice since the last run started. One missed tick is allowed, because a tick that arrives while a run is still going is skipped. A run still going after two ticks counts as missed, so the first backup of a large archive reads `UNHEALTHY` until it completes.
- `ENABLE_LISTENER` is on and fewer listeners are running than accounts are configured. A listener counts as running while the listener stamps its heartbeat, every 30 seconds, so a stopped listener, a stopped backup and a killed container all show here within about three minutes. With the daily default schedule, the schedule check alone would take about two days. The account is named when the database holds no account beyond the configured ones; otherwise the count is given.

The schedule check uses the local time of the command, as the scheduler does. Run it with the same `TZ` as the backup service. Inside the backup container this is already the case.

The schedule check reads `SCHEDULE` even when runs are started another way, for example the one-shot [`backup`](<#backup>) command from a host cron. Set `SCHEDULE` to the real cadence, or the check reports missed runs. Such a setup runs no listener, so set `ENABLE_LISTENER=false` for the command too, or it reports the missing listener.

A database that does not exist yet is created empty and reads as `no backup has run yet`. Before you trust that verdict, check that `DATABASE_URL` or `BACKUP_PATH` points at the archive.

> [!NOTE]
>
> **What it cannot see**
>
> A run writes its statistics after the message sweep and before the media retries, media verification, transcription and gap-fill. The command does not report a failure in those later steps. Read the logs for them.
>
> The viewer also recalculates the statistics: on its first start when nothing is cached yet, in its daily job, and on a `POST /api/stats/refresh` call from the master login. A recalculation after a failed run hides that failure until the next run starts. With several Telegram accounts, the start time and the statistics are shared. An account that failed before another one completed is not reported. The logs name the failed account.

With `--json`, each entry of `listeners` also carries these fields. See [Checking that it runs](<https://geiserx.github.io/Telegram-Archive/configuration/listener/#checking-that-it-runs>) for how to alert on them.

- `last_update_at`: when the listener last handled any Telegram update for the account, UTC.
- `deletions_blocked`: deletions the rate limiter refused since the listener last started.
- `last_rate_limit_at`: when the rate limiter last tripped for the account, UTC.
- `active_since`: when the listener started, in the backup's local time with no UTC offset.

By default it prints:

```
Archive status: healthy
  Last backup started:  <time>
  Statistics updated:   <time>
  Listener, account <n>: active since <time>
  Media files:          <n> downloaded, <n> pending, <n> exhausted, <n> skipped
  Database:             <sqlite|postgresql>, <size>
```

`(running now)` follows the start time while a run is going. A time that was never recorded reads `never`. A listener that is off reads `not running`. When the archive is unhealthy, the first line reads `Archive status: UNHEALTHY` and a `Problems:` list with one line per reason ends the output.

With `--json` it prints the JSON that [`GET /api/status`](<https://geiserx.github.io/Telegram-Archive/reference/api/#health-and-status>) returns, with two more keys: `healthy`, true or false, and `problems`, the list of reasons. Log lines go to stderr, so stdout holds only the JSON.

It exits 0 when the archive is healthy and 1 when it is unhealthy. When the configuration is invalid, the database cannot be reached or read, or `SCHEDULE` is not a valid cron expression, it prints `Status failed: <error>` on stderr and exits 1.

## check-media

```
telegram-archive [--data-dir PATH] check-media [--repair] [-c CHAT_ID]
```

| Short | Long | Argument | Required | Meaning |
| --- | --- | --- | --- | --- |
|  | `--repair` |  | no | Restore files from copies on disk, mark the rest to download again, and store the size of photos stored without one. Without it nothing is changed. |
| `-c` | `--chat-id` | `CHAT_ID` | no | Only this chat, by its marked id. Default: every chat. |

Checks every downloaded media row of every account: is its file where the row says? It reads the database and stats each row's path, a few calls per row, and never walks the media folder. It prints counts only, never ids, paths or names.

A row whose path holds no file is a broken link (a link into `media/_shared` whose shared file is gone) or a missing file (nothing at the path). For each one it looks for a copy on disk, as described in [A missing shared file](<https://geiserx.github.io/Telegram-Archive/configuration/media/#a-missing-shared-file>). With `--repair`, a copy found is put back where the row points, never replacing anything, and a row with no copy is marked not downloaded, so the next backup run downloads it from Telegram. A row is marked only when the folder its path points into exists under the media folder; otherwise the file is not provably gone, and the row is counted as not marked and left as it is. A row marked not downloaded earlier whose own file is at its path again (the content hash matches, or without one the size is within 1% and the file is not empty) is counted, and `--repair` marks it downloaded again. That is the row a past outage of the media volume left behind after its download attempts ran out. An entry this process cannot follow, such as a link into a git-annex store, is left alone and counted. A location, contact, poll or other metadata-only row has no file: it is counted as a placeholder, never as broken, and never marked to download again. Older releases gave some of these rows a `.bin` path and a link into `_shared`; that path is a leftover and stays as it is. A location's map picture is not checked either; when it is gone, the viewer shows the card without it.

A file can also be in place and cut short. A release from late 2025 stored some downloads that stopped early as complete files, and the row was written from the short file, so its size and hash match it. The check opens every file whose name ends in `.mp4`, `.m4v`, `.m4a`, `.mov` or `.3gp` and reads the header of each top-level box, never the media data and never through a decoder. A file whose boxes run past its end, or that has no `moov` box (the index a player needs), has no playable end. When its size is also a multiple of 128 KiB, the size a stopped download leaves, it is counted as cut short, and `--repair` marks it not downloaded, keeping its path. The next backup run downloads it again and replaces the short file only once its bytes prove to be the start of the new ones (see [A file cut short](<https://geiserx.github.io/Telegram-Archive/configuration/media/#a-file-cut-short>)). A file with no index at any other size is counted as possibly damaged and never marked. The extension decides, so a `.mp4` sent as a file is checked too. Matroska, WebM and Ogg files are not checked, and a file that does not start with an `ftyp` box is not judged. The cost is one open and a few small reads per such file.

A photo in place can also lack its size. Releases before 7.32.0 stored no width or height for photos from the full pass, and the viewer sizes a picture from them. For a photo row with neither value, the check reads the size from the file's header, never decoding the picture, and turns it when the picture's EXIF orientation says it is drawn turned. The orientation is read from a JPEG, or from any file that carries EXIF in its header; for a PNG without it, finding the orientation would mean decoding the picture, so its size is used as the header gives it, unturned. With `--repair` it stores that size on the row. It writes only a row with neither value, so a size Telegram reported is never replaced, and the file is opened read-only. A file whose header cannot be read, or one larger than the image library's pixel limit, is counted and left as it is. The cost is one open and a few small reads per such photo.

```
Media check (dry run, nothing changed; run with --repair to fix):
  Rows checked:              <n>
  Files in place:            <n>
  Broken links:              <n>  (a link into _shared whose file is gone)
  Missing files:             <n>  (nothing at the row's path)
  Copy found on disk:        <n>  (--repair puts it back)
  No copy on disk:           <n>  (--repair marks them to download again)
  Cut short:                 <n>  (a video or audio file whose download stopped early; --repair marks them to download again)
  Possibly damaged:          <n>  (no index, but the size does not match a download that stopped early; not marked)
  Photos without size:       <n>  (--repair reads it from the file header)
  Size not readable:         <n>  (left as it is)
```

The last four lines appear only when their count is not zero. With `--repair`, `Photos without size` becomes `Photo sizes filled`, followed by `Could not store a size` when a row could not be written. With `--repair`, a file cut short and marked is also counted in `Marked to download again`.

When the media folder is missing, unreadable or empty, it prints `Media check: the media folder is not visible here (missing, unreadable or empty).`, checks and changes nothing, and exits 1. A dry run exits 1 when it finds a broken link, a missing file, a file back at its path or a file cut short, and 0 otherwise. A possibly damaged file and the photo size lines do not change the exit code. A repair exits 0, or 1 when a copy could not be put back or a row could not be marked; the log says why. A row marked to download again keeps its path, so the download fills the target of the link it names, even when the current Telegram file name differs. When the configuration is invalid or the database cannot be reached, it prints `Media check failed: <error type>` on stderr and exits 1. It needs no Telegram session, so the backup service can keep running.

## list-chats

```
telegram-archive [--data-dir PATH] list-chats
```

Takes no flags.

Prints every chat in the database as a table with the columns `ID`, `Type`, `Name` and `Last Updated`, followed by `Total: N chats`. The name is the chat title, or the first and last name for a private chat. `Last Updated` is `N/A` when the chat has no update time.

It exits 0 on success. On failure it prints `List chats failed: <error>` on stderr and exits 1.

## import

```
telegram-archive [--data-dir PATH] import -p DIR [-c CHAT_ID] [--dry-run] [--skip-media] [--merge]
```

| Short | Long | Argument | Required | Meaning |
| --- | --- | --- | --- | --- |
| `-p` | `--path` | `DIR` | yes | Telegram Desktop export folder, holding `result.json` or `messages.html`. |
| `-c` | `--chat-id` | `CHAT_ID` | for HTML exports | Chat id in marked format, for example `-1001234567890`. |
|  | `--dry-run` |  | no | Parse and validate without writing to the database or copying media. |
|  | `--skip-media` |  | no | Import messages and metadata only. |
|  | `--merge` |  | no | Allow importing into a chat that already has messages. |

Imports a Telegram Desktop export into the archive. Formats, resuming and the other details are in [Import and maintenance tasks](<https://geiserx.github.io/Telegram-Archive/operations/maintenance/index.md>).

It prints `Import complete:` with the number of chats, messages and media files, then one line per chat. With `--dry-run` the heading starts with `[DRY RUN]`.

It exits 0 on success. On failure it prints `Import failed: <error>` on stderr and exits 1.

## merge

```
telegram-archive [--data-dir PATH] merge --source SOURCE [--source-media DIR] [--account LABEL_OR_ID] [--add-missing-parents] [--dry-run]
```

| Short | Long | Argument | Required | Meaning |
| --- | --- | --- | --- | --- |
|  | `--source` | `SOURCE` | yes | The other archive: a SQLite file path or a database URL. |
|  | `--source-media` | `DIR` | no | The other archive's media folder. Defaults to `media` beside a SQLite source file. |
|  | `--account` | `LABEL_OR_ID` | no | Merge only this source account, by label or account id. A label wins when a value could be both. Without it, every account. |
|  | `--add-missing-parents` |  | no | Add an empty placeholder chat, message, folder or user for each source row whose parent row the source lacks. Needed to merge such a SQLite source into PostgreSQL. |
|  | `--dry-run` |  | no | Run every check and print the counts and the media size without writing. |

Copies every Telegram account of another archive, the source, into this archive under new account ids, with the rows each account owns and their media files. Both archives must be at the same, current schema revision. Stop both installs first.

The source is only read. Nothing already in the target is changed or deleted. Viewer accounts, viewer sessions, share links and push subscriptions are not merged. A SQLite source whose `-wal` file still holds changes needs a writable folder, because SQLite writes a `-shm` file beside it to read them. What it copies, what it refuses and a worked example are in [Merge two archives](<https://geiserx.github.io/Telegram-Archive/operations/maintenance/#merge-two-archives>).

It prints `Merge complete:`, then one `Source account <n> -> target account <n>` line per account and the rows added per table. When `--add-missing-parents` added placeholder rows, it lists them per table. When `--account` left out the transcript that a copied transcript points at, a `Transcript copy links left empty` line gives the count. Then come the media files copied, `_shared` files copied, links created, files already in the target, files missing in the source folder, avatar files and the size in MB. Without a source media folder, those media lines are replaced by `Media files: not copied (no source media folder; pass --source-media)`. With `--dry-run` the heading is `[DRY RUN] Merge plan, nothing written:`.

It exits 0 on success. When a check fails, it prints `Merge refused: <reason>` on stderr and exits 1. On any other error it prints `Merge failed: <error type>. Nothing was committed to the target database.` on stderr and exits 1.

## fill-gaps

```
telegram-archive [--data-dir PATH] fill-gaps [-c CHAT_ID] [-t THRESHOLD]
```

| Short | Long | Argument | Required | Meaning |
| --- | --- | --- | --- | --- |
| `-c` | `--chat-id` | `CHAT_ID` | no | Scan only this chat. |
| `-t` | `--threshold` | `THRESHOLD` | no | Minimum gap size to investigate. Overrides `GAP_THRESHOLD`, which defaults to 50. |

Scans archived chats for holes in their message id sequences and fetches the missing messages from Telegram. A hole before a chat's earliest archived message is reported but never filled. When messages were recovered, the cached statistics are recalculated. See [Schedule and backup tuning](<https://geiserx.github.io/Telegram-Archive/configuration/schedule/index.md>).

It prints:

```
Gap-fill complete:
  Chats scanned: <n>
  Chats with gaps: <n>
  Total gaps found: <n>
  Messages recovered: <n>
  Chats with history missing before their earliest archived message: <n> (reported only - ...)
  - <chat name> (ID <id>): <n> gaps, <n> recovered[, ~<n> ids missing before id <id>]
```

The line about missing earlier history appears only when there is such a chat. With several accounts it is not printed at all. A chat's line ends with `~<n> ids missing before id <id>` when the archive lacks that chat's earlier history.

It exits 0 on success. On failure it prints `Gap-fill failed: <error>` on stderr and exits 1.

## backfill-topics

```
telegram-archive [--data-dir PATH] backfill-topics -c CHAT_ID
```

| Short | Long | Argument | Required | Meaning |
| --- | --- | --- | --- | --- |
| `-c` | `--chat-id` | `CHAT_ID` | yes | The forum chat to sweep again. |

Imported forum messages carry no topic, so they land in the General topic. This command gives them their topics back. It resets the chat's [position](<https://geiserx.github.io/Telegram-Archive/reference/glossary/#position>) to zero for every account. Then it backs up that chat once, on its own. Media downloads, edit and deletion sync, and media verification are off for this run. The sweep rewrites each message's topic in place. See [Import and maintenance tasks](<https://geiserx.github.io/Telegram-Archive/operations/maintenance/index.md>).

> [!WARNING]
>
> **Per-account whitelists win**
>
> An account with its own `TG_ACCOUNT_<N>_CHAT_IDS` keeps that whitelist. For that account the command sweeps the account's own chats, not the requested one.

When the chat is not in the archive, it prints one line saying the chat is not in the archive yet and to import or back it up first, and exits 1. When the position reset fails, it prints `Topic backfill failed: <error>` on stderr and exits 1. Otherwise it prints log lines and exits as `backup` does.

## reclassify-round-videos

```
telegram-archive [--data-dir PATH] reclassify-round-videos [-c CHAT_ID] [--dry-run]
```

| Short | Long | Argument | Required | Meaning |
| --- | --- | --- | --- | --- |
| `-c` | `--chat-id` | `CHAT_ID` | no | Only this chat. Without it, every chat with videos. |
|  | `--dry-run` |  | no | Report what would change without writing. |

Archives captured before 8.5.0 stored round video messages as ordinary videos. This command asks Telegram which archived videos are round, with one filtered search per chat, and changes the type of those rows in place. Nothing is downloaded, renamed or deleted.

Run it while nobody has the viewer open. An open tab shows `missing from the archive disk` for a changed video until you reload the tab.

It prints:

```
Round-video reclassification complete:
  Chats scanned:      <n>
  Round videos found: <n>
  Rows re-typed:      <n>
```

A `Chats with errors:` line follows when some chats failed. With several accounts, an account that failed altogether counts as one error there. With one account, that failure ends the command. With `--dry-run` the heading starts with `[DRY RUN]`.

It exits 0 on success. On failure it prints `Reclassification failed: <error>` on stderr and exits 1.

## backfill-details

```
telegram-archive [--data-dir PATH] backfill-details [-c CHAT_ID] [--apply]
```

| Short | Long | Argument | Required | Meaning |
| --- | --- | --- | --- | --- |
| `-c` | `--chat-id` | `CHAT_ID` | no | Only this chat. Without it, every chat. |
|  | `--apply` |  | no | Write the changes. Without it, the command reads and counts but writes nothing. |

It reads old messages from Telegram again and fills in two things older releases did not keep:

- **Locations, venues, live locations, contacts and polls.** Messages archived before the archive kept them have a media row of the kind and no details, so the viewer shows their card with `Details not archived`. The command adds only the missing details under `raw_data`.
- **The hidden-edit flag.** Telegram moves a message's edit time when only its reactions change, and flags that edit as one not to show. Messages archived before 9.0 have the edit time and no flag, so a reaction shows as a pencil. The command reads the messages with an edit time, no flag, no kept earlier version and no deletion, and stores Telegram's flag in `edit_hide`. It writes the flag only when Telegram returns the edit time the archive holds. A later edit time means a new edit, which the next backup records with its own flag, so that message is counted and left alone. A message with a kept earlier version is not read: it was really edited and keeps its pencil either way.

It never replaces text, dates, reactions, an edit time, a flag or any detail already stored.

It also fetches the map picture of a location, a venue or a live location that has a point and no picture yet, the picture the backup keeps for new messages (see [Locations and contacts](<https://geiserx.github.io/Telegram-Archive/viewer/using-the-viewer/#locations-and-contacts>)). It reads those messages in the same requests and asks Telegram's own servers for each picture once, one request each with the same pause, at most 500 per run. The rest are counted as deferred and wait for the next run; a message listed only for its picture is not read until then. Pictures follow `DOWNLOAD_MEDIA` and `SKIP_MEDIA_CHAT_IDS`, and none is fetched when the media folder is missing or empty where the command runs; those pictures are counted as deferred too. A location Telegram serves no picture for (it refuses the point, no longer returns the message, or sends no point it can draw) is marked on its row and not asked for again. A picture is stored as the row's file and takes the place of an old placeholder path. A dry run fetches none. A FloodWait on a picture longer than `MEDIA_FLOOD_SLEEP_THRESHOLD` stops the run, like a long FloodWait on a read.

Last, it collects the custom emoji the account's reactions hold, and those in the text of its messages and their earlier versions (one chat with `-c`). An emoji with no record gets one, and an emoji whose file is missing because Telegram left it out of three answers or three downloads failed is marked to be fetched again. Nothing is deleted. With `--apply` it then fetches the missing files the way the backup does: 100 ids per request, one second between requests, at most 500 files per run. The rest are counted as deferred. When the media folder is missing or empty where the command runs, or a FloodWait stopped the run, every missing file is counted as deferred. See [Custom emoji](<https://geiserx.github.io/Telegram-Archive/configuration/media/#custom-emoji>).

A message on both lists is asked for once. It reads up to 100 messages per request and pauses one second between requests, so a chat with N messages to read costs one request to find the chat and one per 100 of them. It waits out a FloodWait and retries on short network errors. A FloodWait longer than `MAX_FLOOD_WAIT_SECONDS` stops the run, since Telegram would refuse every further request. A chat Telegram no longer serves is skipped. So is a message Telegram no longer returns, or one that now holds another kind of media. All are counted.

It also clears the leftover placeholder path that releases up to 7.28.0 left on location, contact and poll rows. A path is cleared when the details are stored, or when the file is missing, empty or a broken link. For a contact whose details Telegram no longer serves, a vCard file at that path is read into the contact's details first. A file that holds something else keeps its path. Paths are only cleared where the media folder is there: when it is missing or empty where the command runs, as on a host without the media volume, every path is kept. A file counts as missing only in a folder that exists inside the media folder. A message Telegram did not answer because of an error keeps its path until a later run reads it. Clearing keeps the row and sets `file_path`, `file_name` and `download_date` to empty and `downloaded` to 0. No file on disk is changed or deleted.

The messages still missing their details or their flag are the work list, so there is nothing to store between runs. An interrupted run resumes when you run it again, and a second run fills nothing new. Messages Telegram no longer serves are asked for again on each run, at one request per 100 of them, and so are edits with a later edit time until a backup records them. No text, location, name or phone number is written to the logs.

It prints:

```
Details backfill complete:
  Kind        Filled  Already there  Not served
  contact        <n>            <n>         <n>
  geo            <n>            <n>         <n>
  geo_live       <n>            <n>         <n>
  poll           <n>            <n>         <n>
  venue          <n>            <n>         <n>
  Edit flags filled, hidden:       <n>
  Edit flags filled, shown:        <n>
  Edits with a later edit time:    <n>
  Edit flags filled meanwhile:     <n>
  Edits not served:                <n>
  Chats scanned:                   <n>
  Chats Telegram no longer serves: <n>
  Leftover paths cleared:          <n>
  Leftover paths kept:             <n>
  Contacts read from vCard files:  <n>
  Map pictures saved:              <n>
  Map pictures not served:         <n>
  Map pictures with no point:      <n>
  Map pictures deferred:           <n>
  Custom emoji collected:          <n>
  Custom emoji saved:              <n>
  Custom emoji unavailable:        <n>
  Custom emoji deferred:           <n>
```

Without `--apply` the heading starts with `[DRY RUN]`, the counts say what a run with `--apply` would do, and a last line says nothing was written. `Already there` counts rows listed only for their leftover path. `Edit flags filled, hidden` counts the reactions whose pencil goes away, and `shown` the real edits, which keep it. `Edit flags filled meanwhile` counts flags a backup or the listener stored between the read and the write. `Map pictures saved` counts, in a dry run, the pictures a run with `--apply` would fetch. `Map pictures with no point` counts locations Telegram sent without a point it can draw. `Custom emoji collected` counts the custom emoji the reactions and texts hold, `saved` the files fetched or found already on disk (in a dry run, the files a run with `--apply` would fetch), and `unavailable` the emoji Telegram left out for the third time this run. An `Errors (run again to retry):` line follows when a request failed for another reason, and a `Map picture errors (run again):` line when a picture could not be fetched or stored. When a long FloodWait stopped the run, a line says `Stopped after a FloodWait of <n> s` and the rest stays on the work list for a later run. With several accounts, an account that failed altogether counts as one error there. With one account, that failure ends the command.

It exits 0 on success. When a FloodWait stops the run before the end, it prints the summary and exits 1. On failure it prints `Details backfill failed: <error type>` on stderr and exits 1.

## Other entry points

| Command | What it does |
| --- | --- |
| `python -m telegram_archive.setup_auth` | The same login as `auth`. |
| `python -m telegram_archive.export_backup COMMAND` | A separate command line for three read commands: `export`, `list-chats` and `stats`. It takes the same `export` flags. It has no `--data-dir`. On failure it logs `Export failed: <error>` and exits 1. |
| `python -m telegram_archive.listener` | Starts the real-time listener on its own, with its own Telegram client. See [Real-time listener](<https://geiserx.github.io/Telegram-Archive/configuration/listener/index.md>) and [One client per session](<https://geiserx.github.io/Telegram-Archive/getting-started/telegram-login/#one-client-per-session>). |
| `python -m telegram_archive.telegram_backup` | The same as `backup`, without `--data-dir`. A failure ends in a traceback. |
| `python -m telegram_archive.scheduler` | The same as `schedule`, without `--data-dir`. |
| `python -m telegram_archive.config` | Builds the configuration and logs a short self-check: the API id, whether a phone number is set, the schedule and the chat types. It never prints the phone number. On an invalid value it prints `Configuration error: <error>` and still exits 0, so read the output. Like every command except `migrate`, it creates the data directories. |
| `uvicorn telegram_archive.web.main:app --host 127.0.0.1 --port 8000` | Runs the viewer without Docker. See [Install from PyPI](<https://geiserx.github.io/Telegram-Archive/getting-started/pip/index.md>). |
| `alembic -c telegram_archive/alembic.ini upgrade head` | Runs the migrations from a repository checkout. Inside the backup image a bare `alembic`, such as `alembic current`, works because the image sets `ALEMBIC_CONFIG`. |
| `./telegram-archive` | A script at the repository root that runs the command line from a checkout without installing the package. The dependencies must already be installed in the Python it runs with. See [Install from PyPI](<https://geiserx.github.io/Telegram-Archive/getting-started/pip/#from-a-git-checkout>). |

### Health checks

The images run these as their Docker health checks. Both exit 0 when healthy and 1 when not.

| Script | Checks | Variables |
| --- | --- | --- |
| `/app/scripts/healthcheck_backup.py` | The heartbeat file that `schedule` rewrites every 30 seconds is younger than the maximum age. A missing file is unhealthy. | `HEARTBEAT_FILE`, default `/tmp/telegram-archive.heartbeat`. `HEARTBEAT_MAX_AGE_SECONDS`, default 180. |
| `/app/scripts/healthcheck_viewer.py` | `GET` on the viewer's health URL, with a 5 second timeout, answers 200 with `status` set to `ok`. | `HEALTHCHECK_URL`, default `http://127.0.0.1:8000/api/health`. |

The backup check does not prove that backups succeed. See [Monitoring and troubleshooting](<https://geiserx.github.io/Telegram-Archive/operations/troubleshooting/index.md>).

## Repository scripts

These scripts ship in the backup image under `/app/scripts` and in the repository. They are not in the PyPI package. Run them in the backup container:

```
docker compose run --rm telegram-backup python scripts/<name>.py [flags]
```

Most of them find the database through the same variables as the application. `migrate_media_paths.py` does not. It tries `DATABASE_URL` first, then `DB_TYPE=postgresql` with the `POSTGRES_*` variables, then `$BACKUP_PATH/telegram_backup.db`. It ignores `DATABASE_PATH`, `DATABASE_DIR` and `DB_PATH`. Pass `--db-url` when the database lives elsewhere. `deduplicate_media.py` and `cleanup_legacy_avatars.py` only touch files.

| Script | Purpose | Flags |
| --- | --- | --- |
| `auth_noninteractive.py` | Logs in without a terminal, in two steps. `send` requests the code and stores its hash beside the session file. `verify` signs in with the code, and the 2FA password when one is set. It covers the single legacy account only, from `TELEGRAM_*` variables. See [Log in to Telegram](<https://geiserx.github.io/Telegram-Archive/getting-started/telegram-login/#log-in-without-a-terminal>). | `send`, then `verify CODE [2FA_PASSWORD]`. `TELEGRAM_PHONE_CODE_HASH` can replace the stored hash. |
| `migrate-sqlite-to-postgres.py` | Copies a SQLite archive into an empty PostgreSQL database and checks the row counts. Stop the backup container first. See [SQLite and PostgreSQL](<https://geiserx.github.io/Telegram-Archive/configuration/database/#move-an-existing-sqlite-archive-to-postgresql>). | `-s`/`--sqlite PATH`, `-p`/`--postgres URL`, `-b`/`--batch-size N` (default 1000), `-v`/`--verify-only`, `-n`/`--dry-run` |
| `restore_chat.py` | Re-sends archived messages into a Telegram chat as the Telegram account of the session. Each message carries its original sender and time in its text. Media is uploaded again as new files. Each file of a message becomes its own Telegram message: the first carries the text, the others follow without it, downloaded files first, then by media id, so the text is sent once. A file that hits a flood or slow-mode wait is sent again after the wait. | See below. |
| `detect_albums.py` | Groups media sent close together into albums in older archives. | `--dry-run`, `--window SECONDS` (default 2) |
| `deduplicate_media.py` | Moves duplicate media files into `media/_shared` and links them from the chat folders. | `--dry-run`, `-v`/`--verbose` |
| `update_media_sizes.py` | Fills in missing media file sizes from the files on disk. | `--dry-run`, `--force` |
| `normalize_grouped_ids.py` | Stores every album id in the same form. | `--dry-run` |
| `cleanup_legacy_avatars.py` | Deletes old-style avatar files that have a new-style replacement. | `--dry-run`, `--backup-path PATH` (default `/data/backups`) |
| `migrate_media_paths.py` | Renames old media folders of groups and channels to their marked ids and updates the database paths. | `--dry-run`, `--media-path PATH` (default `$MEDIA_PATH` or `/data/backups/media`), `--db-url URL` |
| `healthcheck_backup.py`, `healthcheck_viewer.py` | The container health checks described above. | none |
| `fix_reactions_sequence.sql`, `migrate_to_marked_ids.sql`, `migrate_to_marked_ids_sqlite.sql` | SQL helpers. Run them with `sqlite3` or `psql` outside the image. | none |
| `generate_dummy_db.py` | A development tool that builds a demo archive. | `--data-dir`, `--force` |
| `fix_media_sizes.py` | Broken. It fails with `ImportError`. | none |

[Import and maintenance tasks](<https://geiserx.github.io/Telegram-Archive/operations/maintenance/index.md>) explains when and how to run the maintenance scripts.

### restore\_chat.py

```
python scripts/restore_chat.py (--chat ID | --source-chat ID --dest-chat ID)
    [--after YYYY-MM-DD] [--before YYYY-MM-DD] [--limit N]
    [--delay SECONDS] [--no-media] [--dry-run]
```

| Flag | Argument | Meaning |
| --- | --- | --- |
| `--chat` | `ID` | Restore this chat into itself. |
| `--source-chat` | `ID` | Restore from this chat. Needs `--dest-chat`. |
| `--dest-chat` | `ID` | Send into this chat. |
| `--after` | `YYYY-MM-DD` | Only messages after this date. |
| `--before` | `YYYY-MM-DD` | Only messages before this date. |
| `--limit` | `N` | At most this many messages. |
| `--delay` | `SECONDS` | Pause between messages, and between the files of one message. Default 2.0. |
| `--no-media` |  | Send text only. |
| `--dry-run` |  | Show what would be sent without sending. |

The session is `SESSION_PATH` when set. Otherwise it is `SESSION_DIR` joined with `SESSION_NAME`, and `SESSION_DIR` defaults to `/data/session`. It reads `TELEGRAM_API_ID` and `TELEGRAM_API_HASH`.

> [!CAUTION]
>
> **It sends real messages**
>
> Stop the backup service first. See [One client per session](<https://geiserx.github.io/Telegram-Archive/getting-started/telegram-login/#one-client-per-session>). Always run it with `--dry-run` first and read what it would send.

## Python API

The package exposes four names:

```
from telegram_archive import Config, TelegramBackup, run_backup, __version__
```

`Config`, `TelegramBackup` and `run_backup` load on first use, so `import telegram_archive` works where telethon is not installed, as in the viewer image. Every other module is internal.

Before you call the API, you need:

- the environment variables the command line reads
- a database migrated with [`migrate`](<#migrate>) using those variables
- an authorized session from [`auth`](<#auth>)

### Config

`Config()` reads every setting from the environment when it is built. It creates `BACKUP_PATH`, the session directory, the database directory and, when `DOWNLOAD_MEDIA` is on, the media directory. An invalid value raises at construction. For example, a non-numeric `TELEGRAM_API_ID` raises `ValueError`.

`config.validate_credentials()` raises `ValueError` when `TELEGRAM_API_ID`, `TELEGRAM_API_HASH` or `TELEGRAM_PHONE` is missing in single-account mode.

### run\_backup

```
await run_backup(config, client=None, *, account_id=None)
```

A coroutine that returns `None`.

- Without `client`, it backs up every configured account in turn, each with its own session file. With one account, an error propagates. With several, it logs a failing account by its index and continues with the others. It raises `RuntimeError("all N configured accounts failed to back up")` only when every account failed.
- With `client`, an already connected and authorized Telethon `TelegramClient`, it backs up only that client's account.

Unlike the `backup` command, it does not run the one-time move of `media/_shared`. It runs no gap-fill and starts no listener.

A complete script:

```
import asyncio
import os

os.environ.setdefault("BACKUP_PATH", "./data/backups")

from telegram_archive import Config, run_backup

asyncio.run(run_backup(Config()))
```

`TELEGRAM_API_ID`, `TELEGRAM_API_HASH` and `TELEGRAM_PHONE` come from the environment. With `BACKUP_PATH=./data/backups`, the session directory defaults to `./data/session` and the SQLite database to `./data/backups/telegram_backup.db`.

### TelegramBackup

`TelegramBackup` is the class `run_backup` drives. It is lower level: prefer `run_backup` unless you need to control the steps.

```
backup = await TelegramBackup.create(config, client=None, *, account_id=None, account=None, account_resolver=None)
```

`create()` opens its own database connection. It needs either `account_id` or `account_resolver`. `account_id` is the archive's row id for the account. `account_resolver` is an async callable. After the client connects, `create()` calls it with the client and the database, and it returns that id. Without either it raises `ValueError`.

The lifecycle is:

1. `await backup.connect()`
2. `await backup.backup_all()`
3. `await backup.disconnect()`
4. `await backup.db.close()`

`disconnect()` only disconnects a client the instance created. A client passed in stays connected.

### The viewer as an ASGI app

`telegram_archive.web.main:app` is a standard ASGI application. It builds `Config` when it is imported, so an invalid value stops it from starting. Until viewer access is configured, it answers requests with 503. See [Logins, viewer accounts and share links](<https://geiserx.github.io/Telegram-Archive/viewer/access/index.md>).

The HTTP routes are documented in [HTTP API](<https://geiserx.github.io/Telegram-Archive/reference/api/index.md>). A running viewer also serves the machine-readable route list at `/openapi.json`.
