diff --git a/AGENT.md b/AGENT.md index dcb5f5c..eb9cd3d 100644 --- a/AGENT.md +++ b/AGENT.md @@ -10,8 +10,22 @@ pending login lives in the daemon and is mirrored to `/login-state.json` at 0600, so the two steps can run minutes apart in different processes. +Every login needs an app registration (`api_id` + `api_hash`) from +. Save it once and no login asks for it again: + +``` +tlgr auth api set 1234567 --api-hash-env TLGR_API_HASH +→ {"configured": true, "api_id": 1234567, "api_hash": "…9f0c", "path": "~/.tlgr/api.json", "updated": true} + +tlgr auth api get # configured: false → the user has to register first (register_url) +``` + +`--api-id` with `--api-hash-env` on any login still wins over the saved +default. `auth api set` refuses the api_ids of Telegram's own apps (they get +accounts banned) and a hash that is not 32 hex characters, with exit 2. + ``` -tlgr auth send-code +989123456789 --alias work --api-id 12345 --api-hash-env TLGR_API_HASH +tlgr auth send-code +989123456789 --alias work → {"account": "work", "phone": "989…89", "type": "app", "code_hash": "5f2a…", "timeout": 60} tlgr auth verify-code 12345 --alias work --password-env TLGR_2FA_PASSWORD diff --git a/CHANGELOG.md b/CHANGELOG.md index 4820443..1dd2712 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,41 @@ All notable changes to tlgr are recorded here. The format follows semantic versioning at the CLI surface, which means the JSON shapes and exit codes documented in `AGENT.md` are the public API. -## [Unreleased] +## [2.1.0] - 2026-09-28 + +Log in without registering your app's credentials again for every account: +`tlgr auth api set` saves them once. Also: `chat poster list` resumes long +walks, and `chat list` no longer drops dialogs at a page boundary. ### Added +- **Save your app credentials once: `tlgr auth api set`.** Every login needs + an `api_id`/`api_hash` from my.telegram.org, and tlgr used to ask for them + again for every account you added. Now you register one app, save it, and + every later `auth send-code`, `auth qr`, `account add` and `account import` + uses it without `--api-id`: + + ```bash + tlgr auth api set # at a terminal: walks you through my.telegram.org, hides the hash + tlgr auth api set 1234567 --api-hash-env TLGR_API_HASH # scripted + tlgr auth api get # what is saved, hash masked; register_url when nothing is + tlgr auth api unset # forget it + ``` + + The pair lives in `~/.tlgr/api.json` at 0600. Flags on a login still win. + Each login keeps copying the pair into the account's own `config.json`, so + changing the default later never moves an existing account to another app. + An account with no credentials of its own now falls back to the default + instead of refusing to start. + +- **tlgr refuses the api_ids of Telegram's own apps.** Telegram Desktop, + Android, iOS, macOS, Web and Telegram X credentials are published in build + files and get copied into third-party tools. Logging in with them breaks + Telegram's API terms and gets accounts banned, so `auth api set` and every + login now stop with exit 2 and say where to register instead. A hash that + is not 32 hex characters is refused the same way, before it can surface as + a confusing `API_ID_INVALID`. + - **`chat poster list` can walk deeper than one call.** A single call stops at `--max-messages 20000` and at the operation deadline, so a longer history could not be harvested at all: a bigger number was refused and @@ -31,6 +62,13 @@ codes documented in `AGENT.md` are the public API. ### Fixed +- **`API_ID_INVALID` and `API_ID_PUBLISHED_FLOOD` name the fix.** Both used + to exit 1 with no hint. They are now `CONFIG_ERROR` (exit 10) and point at + `tlgr auth api get` / `tlgr auth api set`. + +- **Secret flag errors spell the flag the way you typed it.** A missing + variable said `--api_hash-env names X`; it now says `--api-hash-env`. + - **`chat list` no longer loses a block of dialogs at a page boundary.** The dialog walk paired each row with its top message by message id alone, but only private chats share one id space: every channel and supergroup numbers diff --git a/README.md b/README.md index 7c7e644..be257c0 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ pipx install git+https://github.com/tlgrcli/tlgr.git ```bash tlgr config init # create config files +tlgr auth api set # once: your app from my.telegram.org/apps tlgr login +15551234567 # authenticate (shortcut for account add) tlgr daemon start # start background daemon tlgr send @username "Hello from tlgr" # send a message @@ -472,8 +473,22 @@ alive unless you passed `--logout`. ### Logging in +Register an app once at (two minutes: any +title, any short name) and save its `api_id` and `api_hash`. Every account +you log in after that uses it: + +```bash +tlgr auth api set # prompts; the hash is not echoed +tlgr auth api set 12345 --api-hash-env TLGR_API_HASH # the scripted form +tlgr auth api get # what is saved (hash masked) +``` + +tlgr refuses the api_ids of Telegram's own apps, such as the Telegram Desktop +credentials in its public build files. Logging in with them breaks Telegram's +API terms and gets accounts banned. + ```bash -tlgr auth send-code --alias work --api-id 12345 --api-hash-env TLGR_API_HASH +tlgr auth send-code --alias work tlgr auth verify-code --alias work --password-env TLGR_2FA_PASSWORD tlgr auth qr --alias work # streams tg://login tokens until one is approved tlgr auth recover # forgot the cloud password (recovery email) diff --git a/SECURITY.md b/SECURITY.md index 2d1b454..918f6e3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -82,6 +82,10 @@ different machine. - Use your own `api_id`/`api_hash` from . Never borrow an official client's credentials, and never spoof `device_model` to obtain official-app behaviour: it violates the ToS and gets accounts banned. + `tlgr auth api set` and every login refuse the known official api_ids. +- `~/.tlgr/api.json` (written by `tlgr auth api set`, 0600) holds the default + `api_hash`. It is not account access on its own, but it is your app's + identity: a leaked hash lets someone else's traffic count against it. - Use `https://` for webhooks, and verify `X-Tlgr-Signature` over the raw body before trusting a delivery. - `account export --string` prints full account access. It requires `--yes` diff --git a/docs/reference/PARITY.md b/docs/reference/PARITY.md index 306bf1e..1cc64ec 100644 --- a/docs/reference/PARITY.md +++ b/docs/reference/PARITY.md @@ -7,10 +7,10 @@ Coverage against the Telegram feature catalog, computed from the registry: every `covered` is implemented today. `acct%` is covered **plus** waived — an id this build genuinely cannot cover, named in `tlgr/data/parity_waivers.toml` with the reason and the MTProto method that is missing. Ids whose feasibility is `not-applicable` or `prohibited` are excluded from the denominator once and never counted again. ``` -catalog 2026-09-02 — 678 operations, 951 invocable paths +catalog 2026-09-02 — 681 operations, 954 invocable paths domain covered req % acct% ops -auth_sessions_security 89 89 100.0% 100.0% 45 +auth_sessions_security 89 89 100.0% 100.0% 48 bots_inline_payments 167 175 95.4% 100.0% 87 calls_voicechats 133 133 100.0% 100.0% 56 contacts_users 121 121 100.0% 100.0% 56 @@ -38,7 +38,7 @@ uncovered: 9 (9 waived with a reason) | Domain | Covered | Required | % | Accounted % | Ops | |---|---:|---:|---:|---:|---:| -| `auth_sessions_security` | 89 | 89 | 100.0% | 100.0% | 45 | +| `auth_sessions_security` | 89 | 89 | 100.0% | 100.0% | 48 | | `bots_inline_payments` | 167 | 175 | 95.4% | 100.0% | 87 | | `calls_voicechats` | 133 | 133 | 100.0% | 100.0% | 56 | | `contacts_users` | 121 | 121 | 100.0% | 100.0% | 56 | diff --git a/docs/reference/README.md b/docs/reference/README.md index 2c9a880..41cadb7 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -2,13 +2,13 @@ # Command reference -678 operations across 47 groups, generated from the operation registry. Groups still served by v1's hand-written commands are not listed here; they arrive with their own PR. +681 operations across 47 groups, generated from the operation registry. Groups still served by v1's hand-written commands are not listed here; they arrive with their own PR. | Group | Operations | Reference | |---|---:|---| | `account` | 35 | [account.md](account.md) | | `agent` | 7 | [agent.md](agent.md) | -| `auth` | 11 | [auth.md](auth.md) | +| `auth` | 14 | [auth.md](auth.md) | | `boost` | 3 | [boost.md](boost.md) | | `bot` | 57 | [bot.md](bot.md) | | `business` | 14 | [business.md](business.md) | diff --git a/docs/reference/account.md b/docs/reference/account.md index 274ae49..6105050 100644 --- a/docs/reference/account.md +++ b/docs/reference/account.md @@ -62,7 +62,7 @@ tlgr account add [PHONE] [OPTIONS] |---|---|---|---| | `--alias` | text | | Local name for the account. | | `--api-hash` | text | | api_hash for this account. | -| `--api-id` | int | | api_id for this account. | +| `--api-id` | int | | api_id; default: `auth api set`'s. | | `--bot` | flag | | Log in as a bot with a token instead. | | `--test-dc` | flag | | Use the Telegram test DCs. | | `--token` | text | | The bot token. | diff --git a/docs/reference/auth.md b/docs/reference/auth.md index e6ede7b..38e4825 100644 --- a/docs/reference/auth.md +++ b/docs/reference/auth.md @@ -2,10 +2,13 @@ # `tlgr auth` -11 operations. Every one takes the global flags (`--json`, `--plain`, `-a/--account`, `--results-only`, `--select`, `--dry-run`, `--yes`, `--no-input`, `--flood-wait-max`, `-v`) anywhere on the line. +14 operations. Every one takes the global flags (`--json`, `--plain`, `-a/--account`, `--results-only`, `--select`, `--dry-run`, `--yes`, `--no-input`, `--flood-wait-max`, `-v`) anywhere on the line. | Command | Summary | |---|---| +| [`auth api get`](#tlgr-auth-api-get) | Show the default api_id a login uses | +| [`auth api set`](#tlgr-auth-api-set) | Save the api_id/api_hash every login uses by default | +| [`auth api unset`](#tlgr-auth-api-unset) | Forget the default api_id/api_hash | | [`auth autologin-url get`](#tlgr-auth-autologin-url-get) | Append the autologin token to a telegram.org URL | | [`auth code list`](#tlgr-auth-code-list) | Read login codes Telegram delivered to this session, and burn leaked ones | | [`auth login-email set`](#tlgr-auth-login-email-set) | Set, verify or reset the login email the server demands during login | @@ -18,6 +21,86 @@ | [`auth tos`](#tlgr-auth-tos) | Show, accept or decline the Terms of Service | | [`auth verify-code`](#tlgr-auth-verify-code) | Finish a pending login: submit the code and, if asked, the password | +### `auth api get` + +Show the default api_id a login uses. + +Reads the default saved by `auth api set`. The hash is masked to its last four characters. `configured: false` means a login with no `--api-id` has nothing to use, and `register_url` is where to get one. + +``` +tlgr auth api get [OPTIONS] +``` + +**idempotent (reports `already`) · runs without an account · returns `ApiCredentials`** + +```console +$ tlgr auth api get --json +``` + +
Catalog coverage (0 full, 1 partial) + +Partial: `auth.api-credentials` + +Registration itself happens at my.telegram.org; tlgr stores the result. + +
+ +### `auth api set` + +Save the api_id/api_hash every login uses by default. + +Register an app once at my.telegram.org/apps and save it here; `auth send-code`, `auth qr`, `account add --bot` and `account import` then need no `--api-id`. Each login still copies the pair into the account, so changing the default later never moves an existing account to a different app. Written to `api.json` at 0600. At a terminal with no arguments it prompts (the hash without echo). Refuses a malformed hash and the api_ids of Telegram's own apps. + +``` +tlgr auth api set [API_ID] [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · runs without an account · returns `ApiCredentials`** + +| Argument | Type | Required | Meaning | +|---|---|---|---| +| `API_ID` | int | no | The App api_id from my.telegram.org/apps; prompted for at a terminal. | + +| Flag | Type | Default | Meaning | +|---|---|---|---| +| `--api-hash` | text | | The App api_hash; never on argv. | + +```console +$ tlgr auth api set 1234567 --json +``` + +
Catalog coverage (0 full, 1 partial) + +Partial: `auth.api-credentials` + +Registration itself happens at my.telegram.org; tlgr stores the result. + +
+ +### `auth api unset` + +Forget the default api_id/api_hash. + +Deletes `api.json`. Accounts that already logged in keep working: each holds its own copy of the pair it was made with. Later logins need `--api-id` again, or a new `auth api set`. + +``` +tlgr auth api unset [OPTIONS] +``` + +**mutating · idempotent (reports `already`) · runs without an account · returns `ApiCredentials`** + +```console +$ tlgr auth api unset --json +``` + +
Catalog coverage (0 full, 1 partial) + +Partial: `auth.api-credentials` + +Registration itself happens at my.telegram.org; tlgr stores the result. + +
+ ### `auth autologin-url get` Append the autologin token to a telegram.org URL. @@ -117,7 +200,7 @@ tlgr auth qr [OPTIONS] |---|---|---|---| | `--alias` | text | | Account alias to create. | | `--api-hash` | text | | api_hash for this account. | -| `--api-id` | int | | api_id for this account. | +| `--api-id` | int | | api_id; default: `auth api set`'s. | | `--password` | text | | The 2FA cloud password. | | `--test-dc` | flag | | Use the Telegram test DCs. | | `--url-only` | flag | | Print only the tg://login URL (pipe it into qrencode). | @@ -240,7 +323,7 @@ tlgr auth send-code [OPTIONS] | `--allow-flashcall` | flag | | Permit a flash-call code; you type the number. | | `--allow-missed-call` | flag | | Permit a missed-call code (read the caller id). | | `--api-hash` | text | | api_hash — never on the command line. | -| `--api-id` | int | | api_id; else TLGR_API_ID, then the config. | +| `--api-id` | int | | api_id; else TLGR_API_ID, then `auth api set`'s. | | `--current-number` | flag | | codeSettings.current_number: this device owns it. | | `--no-future-tokens` | flag | | Do not offer stored tokens; force a real code. | | `--recaptcha-token` | text | | A reCAPTCHA token you solved. | diff --git a/plugin/skills/tlgr/SKILL.md b/plugin/skills/tlgr/SKILL.md index 73472ed..4e6fff2 100644 --- a/plugin/skills/tlgr/SKILL.md +++ b/plugin/skills/tlgr/SKILL.md @@ -87,10 +87,16 @@ bot-only, admin-only) and what tlgr refuses on purpose, each with a reason. Login is two ordinary commands, so only reading the code needs a person: ```bash -tlgr auth send-code +15551234567 --alias main --api-id 12345 --api-hash-env TLGR_API_HASH --json +tlgr auth send-code +15551234567 --alias main --json tlgr auth verify-code 12345 --alias main --json ``` +`send-code` exits 10 with `CONFIG_ERROR` when no app is saved. Check with +`tlgr auth api get --json`: if `configured` is false, the user registers an +app at https://my.telegram.org/apps and runs `tlgr auth api set` themselves +(it prompts and hides the hash). Do not ask them to paste the hash into the +chat, and never use an official Telegram client's api_id. + Ask the user for the code Telegram sends them. Exit 4 with `AUTH_PASSWORD_REQUIRED` means two-step verification is on: rerun `verify-code` with `--password-env TLGR_2FA_PASSWORD` after the user sets diff --git a/tests/test_errors_map.py b/tests/test_errors_map.py index 0f97a10..ccc0870 100644 --- a/tests/test_errors_map.py +++ b/tests/test_errors_map.py @@ -11,6 +11,7 @@ ERROR_MAP, EXIT_AUTH, EXIT_CODE_MAP, + EXIT_CONFIG, EXIT_GENERIC, EXIT_NOT_FOUND, EXIT_PERMISSION, @@ -147,6 +148,13 @@ def test_frozen_rpc_message_wins_over_the_class(self): exc = type("RPCError", (Exception,), {})("FROZEN_METHOD_INVALID") assert classify(exc).code == "ACCOUNT_FROZEN" + @pytest.mark.parametrize("rpc", ["API_ID_INVALID", "API_ID_PUBLISHED_FLOOD"]) + def test_a_bad_app_is_a_config_error_that_names_the_fix(self, rpc): + """The account is fine; the api_id is what needs changing, and exit 1 said nothing.""" + body = classify(type("RPCError", (Exception,), {})(f"RPCError 400: {rpc}")) + assert (body.code, body.exit_code) == ("CONFIG_ERROR", EXIT_CONFIG) + assert "tlgr auth api" in (body.hint or "") + def test_msgspec_validation_error_is_usage_with_a_field(self): with pytest.raises(msgspec.ValidationError) as excinfo: msgspec.json.decode(b'{"chat":{"kind":1}}', type=_Outer) diff --git a/tests/test_ops_auth.py b/tests/test_ops_auth.py index 2ae85e4..ac52f33 100644 --- a/tests/test_ops_auth.py +++ b/tests/test_ops_auth.py @@ -79,6 +79,153 @@ def run(runner: CliRunner, *args: str): return runner.invoke(cli, list(args)) +# --------------------------------------------------------------------------- +# auth api get / set / unset: one app registration for every login +# --------------------------------------------------------------------------- + +OTHER_HASH = "fedcba9876543210fedcba9876543210" + + +class TestApiDefault: + def test_set_saves_the_pair_privately_and_get_masks_it(self, cli_runner, tlgr_home): + out = run_env(cli_runner, "--json", "auth", "api", "set", str(API_ID), H=API_HASH) + assert out.exit_code == 0, out.output + saved = tlgr_home / "api.json" + assert stat.S_IMODE(saved.stat().st_mode) == 0o600 + assert API_HASH in saved.read_text() + + shown = run(cli_runner, "--json", "auth", "api", "get") + assert f'"api_id": {API_ID}' in shown.output + assert '"api_hash": "…cdef"' in shown.output + assert API_HASH not in shown.output + + def test_setting_the_same_pair_again_is_already(self, cli_runner): + run_env(cli_runner, "auth", "api", "set", str(API_ID), H=API_HASH) + again = run_env(cli_runner, "--json", "auth", "api", "set", str(API_ID), H=API_HASH) + assert '"already": true' in again.output + + def test_get_says_where_to_register_when_nothing_is_saved(self, cli_runner): + out = run(cli_runner, "--json", "auth", "api", "get") + assert out.exit_code == 0, out.output + assert '"configured": false' in out.output + assert "my.telegram.org/apps" in out.output + + def test_unset_forgets_it_and_is_idempotent(self, cli_runner, tlgr_home): + run_env(cli_runner, "auth", "api", "set", str(API_ID), H=API_HASH) + gone = run(cli_runner, "--json", "auth", "api", "unset") + assert '"removed": true' in gone.output + assert not (tlgr_home / "api.json").exists() + again = run(cli_runner, "--json", "auth", "api", "unset") + assert '"already": true' in again.output + + @pytest.mark.parametrize("official", [2040, 611335, 17349, 6]) + def test_an_official_clients_api_id_is_refused(self, cli_runner, tlgr_home, official): + """Copying Telegram Desktop's snap credentials is how accounts get banned.""" + out = run_env(cli_runner, "auth", "api", "set", str(official), H=API_HASH) + assert out.exit_code == EXIT_USAGE, out.output + assert "banned" in out.output + assert not (tlgr_home / "api.json").exists() + + @pytest.mark.parametrize("bad", ["abc", API_HASH.upper(), API_HASH + "0"]) + def test_a_malformed_hash_is_refused(self, cli_runner, bad): + out = run_env(cli_runner, "auth", "api", "set", str(API_ID), H=bad) + assert out.exit_code == EXIT_USAGE, out.output + assert "32 hex" in out.output + + def test_without_a_terminal_it_explains_instead_of_prompting(self, cli_runner): + out = run(cli_runner, "auth", "api", "set") + assert out.exit_code == EXIT_USAGE, out.output + assert "my.telegram.org/apps" in out.output + assert "--api-hash-env" in out.output + + def test_a_missing_hash_variable_names_the_flag_as_typed(self, cli_runner): + out = run(cli_runner, "auth", "api", "set", str(API_ID), "--api-hash-env", "NOPE") + assert "--api-hash-env names NOPE" in out.output + + def test_at_a_terminal_it_prompts_and_retries_a_typo(self, cli_runner, tlgr_home, monkeypatch): + """The hash goes through getpass; a bad one is asked for again, not fatal.""" + from tlgr.ops import auth as auth_ops + + monkeypatch.setattr(auth_ops, "_interactive", lambda: True) + typed = iter(["not a number", str(API_ID)]) + monkeypatch.setattr("builtins.input", lambda prompt="": next(typed)) + hidden = iter(["too-short", API_HASH]) + monkeypatch.setattr("getpass.getpass", lambda prompt="": next(hidden)) + + out = run(cli_runner, "--json", "auth", "api", "set") + assert out.exit_code == 0, out.output + assert API_HASH in (tlgr_home / "api.json").read_text() + + def test_an_account_without_its_own_file_falls_back_to_the_default(self, tlgr_home): + from tlgr.core.accounts import AccountManager + + manager = AccountManager(tlgr_home) + manager.add_account("bare") + assert manager.load_credentials("bare") == (None, None) + manager.save_default_credentials(API_ID, API_HASH) + assert manager.load_credentials("bare") == (API_ID, API_HASH) + + def test_an_accounts_own_pair_wins_and_is_never_mixed(self, tlgr_home): + from tlgr.core.accounts import AccountManager + + manager = AccountManager(tlgr_home) + manager.add_account("own") + manager.save_credentials(999, OTHER_HASH, "own") + manager.save_default_credentials(API_ID, API_HASH) + assert manager.load_credentials("own") == (999, OTHER_HASH) + + # Half a pair (an id with no hash) must not borrow a different app's hash. + (tlgr_home / "accounts" / "own" / "config.json").write_text('{"api_id": 999}') + assert manager.load_credentials("own") == (999, None) + + async def test_a_login_without_flags_uses_the_default( + self, live_daemon, client, in_thread, tlgr_home + ): + from tlgr.core.accounts import AccountManager + + AccountManager(tlgr_home).save_default_credentials(API_ID, API_HASH) + sent = await result( + client, in_thread, "auth.send-code", {"phone": PHONE, "alias": "newbie"}, account="" + ) + assert sent["type"] == "app" + # The account keeps its own copy, so a later `auth api set` of a + # different app never moves it. + own = tlgr_home / "accounts" / "newbie" / "config.json" + assert API_HASH in own.read_text() + + async def test_a_login_with_nothing_names_the_command_that_fixes_it( + self, live_daemon, client, in_thread + ): + with pytest.raises(Exception) as caught: + await result( + client, in_thread, "auth.send-code", {"phone": PHONE, "alias": "newbie"}, account="" + ) + body = classify(caught.value) + assert body.code == "CONFIG_ERROR" + assert "tlgr auth api set" in f"{caught.value} {body.hint}" + + async def test_a_login_refuses_an_official_api_id_from_the_flags( + self, live_daemon, client, in_thread + ): + with pytest.raises(Exception) as caught: + await result( + client, + in_thread, + "auth.send-code", + {"phone": PHONE, "alias": "newbie", "api_id": 611335, "api_hash": API_HASH}, + account="", + ) + assert classify(caught.value).exit_code == EXIT_USAGE + assert "Telegram Desktop" in str(caught.value) + + +def run_env(runner: CliRunner, *args: str, H: str): + """`run`, with the api_hash in `$H` and `--api-hash-env H` appended.""" + from tlgr.cli import cli + + return runner.invoke(cli, [*args, "--api-hash-env", "H"], env={"H": H}) + + # --------------------------------------------------------------------------- # auth send-code / verify-code — the resumable login # --------------------------------------------------------------------------- diff --git a/tlgr/__init__.py b/tlgr/__init__.py index 4ecd4ce..c9f1c7a 100644 --- a/tlgr/__init__.py +++ b/tlgr/__init__.py @@ -1,3 +1,3 @@ """tlgr — Full Telegram account control CLI.""" -__version__ = "2.0.1" +__version__ = "2.1.0" diff --git a/tlgr/core/accounts.py b/tlgr/core/accounts.py index a357705..178646a 100644 --- a/tlgr/core/accounts.py +++ b/tlgr/core/accounts.py @@ -22,13 +22,14 @@ import json import os +import re import shutil from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any -from tlgr.core.errors import AccountNotFoundError, TlgrError +from tlgr.core.errors import AccountNotFoundError, TlgrError, UsageError from tlgr.core.paths import TlgrPaths, validate_alias, write_private ACCOUNTS_FILE = "accounts.json" @@ -46,6 +47,50 @@ ) +#: Where a person registers the app whose `api_id`/`api_hash` tlgr logs in with. +API_REGISTRATION_URL = "https://my.telegram.org/apps" + +#: `api_id`s that belong to Telegram's own apps. They are published (in build +#: scripts, in forum posts), so they get copied; a third-party client logging +#: in with one is a ToS violation Telegram answers by banning the account +#: (see `core/identity.py`). Refusing them here is cheaper than an appeal. +OFFICIAL_API_IDS: dict[int, str] = { + 4: "Telegram for Android (legacy)", + 6: "Telegram for Android", + 8: "Telegram for iOS (legacy)", + 2040: "Telegram Desktop", + 2496: "Telegram Web", + 2834: "Telegram for macOS", + 10840: "Telegram for iOS", + 17349: "Telegram Desktop's published test credentials", + 21724: "Telegram X", + 611335: "Telegram Desktop (snap)", +} + +#: What my.telegram.org issues: 32 lowercase hex digits. +_API_HASH_RE = re.compile(r"^[0-9a-f]{32}$") + + +def check_api_credentials(api_id: int, api_hash: str) -> None: + """Refuse credentials that cannot work, or that would get the account banned.""" + if api_id <= 0: + raise UsageError(f"api_id must be a positive number, not {api_id}", field="api_id") + official = OFFICIAL_API_IDS.get(api_id) + if official is not None: + raise UsageError( + f"api_id {api_id} belongs to {official}. Logging in with an official app's " + "credentials breaks Telegram's API terms and gets accounts banned. Register " + f"your own app at {API_REGISTRATION_URL}", + field="api_id", + ) + if not _API_HASH_RE.match(api_hash): + raise UsageError( + "api_hash must be the 32 hex characters my.telegram.org shows " + f"(got {len(api_hash)} characters)", + field="api_hash", + ) + + def _now() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") @@ -373,6 +418,12 @@ def load_credentials(self, alias: str | None = None) -> tuple[int | None, str | if env_hash: api_hash = env_hash + if not (api_id and api_hash): + # The default is a pair: half of one app's credentials next to + # half of another's would fail at login with a misleading error. + default_id, default_hash = self.load_default_credentials() + if default_id and default_hash and api_id in (None, default_id): + return default_id, default_hash return api_id, api_hash def save_credentials(self, api_id: int, api_hash: str, alias: str | None = None) -> None: @@ -382,3 +433,40 @@ def save_credentials(self, api_id: int, api_hash: str, alias: str | None = None) self.paths.credentials(resolved), json.dumps({"api_id": api_id, "api_hash": api_hash}, indent=2), ) + + # -- the default credentials ------------------------------------------- + # + # One app registration at my.telegram.org serves every account a person + # logs in, so asking for it again per alias was friction with no benefit. + # A login still copies what it used into the account's own file: changing + # the default later must not silently move existing accounts to another + # app. + + def load_default_credentials(self) -> tuple[int | None, str | None]: + path = self.paths.api_credentials + if not path.exists(): + return None, None + try: + data = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return None, None + api_id = data.get("api_id") if isinstance(data, dict) else None + api_hash = data.get("api_hash") if isinstance(data, dict) else None + if not isinstance(api_id, int) or not isinstance(api_hash, str): + return None, None + return api_id, api_hash + + def save_default_credentials(self, api_id: int, api_hash: str) -> None: + self.paths.ensure_base() + write_private( + self.paths.api_credentials, + json.dumps({"api_id": api_id, "api_hash": api_hash}, indent=2), + ) + + def clear_default_credentials(self) -> bool: + """Forget the default. True when there was one to forget.""" + try: + self.paths.api_credentials.unlink() + except FileNotFoundError: + return False + return True diff --git a/tlgr/core/errors.py b/tlgr/core/errors.py index 76f43aa..50d9f5f 100644 --- a/tlgr/core/errors.py +++ b/tlgr/core/errors.py @@ -492,6 +492,31 @@ class ErrorRule: ), _USAGE, ), + # The app the login used, not the account, is what is wrong. Telethon + # names these ApiIdInvalidError/ApiIdPublishedFloodError, and without a + # rule they are exit 1 with no way forward. + ( + re.compile(r"\bAPI_ID_INVALID\b"), + ErrorRule( + "CONFIG_ERROR", + EXIT_CONFIG, + 400, + False, + "Telegram does not recognise this api_id/api_hash pair. " + "Check it with: tlgr auth api get", + ), + ), + ( + re.compile(r"\bAPI_ID_PUBLISHED_FLOOD\b"), + ErrorRule( + "CONFIG_ERROR", + EXIT_CONFIG, + 400, + False, + "This api_id was published and Telegram throttles it. Register your own at " + "https://my.telegram.org/apps, then: tlgr auth api set", + ), + ), # `USERNAME_PURCHASE_AVAILABLE` means the name is free *on Fragment*, # which is neither "taken" nor an error tlgr can retry past. (re.compile(r"\bUSERNAME_PURCHASE_AVAILABLE\b"), _USAGE), diff --git a/tlgr/core/paths.py b/tlgr/core/paths.py index ef9fcc2..edeba4a 100644 --- a/tlgr/core/paths.py +++ b/tlgr/core/paths.py @@ -208,6 +208,11 @@ def cursor_key(self) -> Path: def identity(self) -> Path: return self.base / "identity.json" + @property + def api_credentials(self) -> Path: + """The default `api_id`/`api_hash`, for accounts that have none of their own (0600).""" + return self.base / "api.json" + @property def proxies(self) -> Path: """Saved proxies, including their passwords and MTProxy secrets (0600).""" @@ -294,6 +299,7 @@ def ensure_account_dir(self, alias: str) -> Path: _SECRET_GLOBS = ( "accounts/*/session*", "accounts/*/config.json", + "api.json", "ipc.token", "cursor.key", "webhook.toml", diff --git a/tlgr/daemon/sessions.py b/tlgr/daemon/sessions.py index 4bd3e53..6b3536e 100644 --- a/tlgr/daemon/sessions.py +++ b/tlgr/daemon/sessions.py @@ -95,7 +95,8 @@ def _options(self, alias: str) -> ClientOptions: api_id, api_hash = self.accounts.load_credentials(alias) if not api_id or not api_hash: raise ConfigurationError( - f"account {alias!r} has no API credentials. Run: tlgr account add --alias {alias}" + f"account {alias!r} has no API credentials and no default is saved. " + "Run: tlgr auth api set" ) identity = load_identity( self.paths.base, diff --git a/tlgr/models/auth.py b/tlgr/models/auth.py index 6e7f168..e08b022 100644 --- a/tlgr/models/auth.py +++ b/tlgr/models/auth.py @@ -29,6 +29,7 @@ "AccountRecord", "AccountState", "AccountTtl", + "ApiCredentials", "AutologinUrl", "DeviceLock", "LoginCodes", @@ -64,6 +65,26 @@ # --------------------------------------------------------------------------- +class ApiCredentials(Model): + """The default app a login uses when it is given no credentials of its own. + + Saved once by `auth api set`, used by every `auth send-code`, `auth qr` + and `account import` after it. `api_hash` is masked to its last four + characters: enough to tell two registrations apart, not enough to use one. + """ + + #: No default, so it is always in the output: `false` is the answer. + configured: bool + api_id: int | None = None + api_hash: str | None = None + path: str = "" + #: A person without credentials needs to know where they come from. + register_url: str = "" + updated: bool = False + removed: bool = False + already: bool = False + + class SentCode(Model): """What `auth.sendCode` (and the two phone-change flows) answered. diff --git a/tlgr/ops/_params.py b/tlgr/ops/_params.py index 207e97b..28290c3 100644 --- a/tlgr/ops/_params.py +++ b/tlgr/ops/_params.py @@ -190,16 +190,18 @@ def read_secret( import os import sys + # *name* is the request field (`api_hash`); the flag spells it with a dash. + flag = f"--{name.replace('_', '-')}" if file: try: with open(file, encoding="utf-8") as handle: return handle.read().strip("\n") except OSError as exc: - raise UsageError(f"--{name}-file: {exc.strerror or exc}", field=name) from exc + raise UsageError(f"{flag}-file: {exc.strerror or exc}", field=name) from exc if stdin: if sys.stdin is None or sys.stdin.isatty(): - raise UsageError(f"--{name}-stdin was given but stdin is a terminal", field=name) + raise UsageError(f"{flag}-stdin was given but stdin is a terminal", field=name) return sys.stdin.read().strip("\n") variable = env or default_env @@ -208,5 +210,5 @@ def read_secret( if value is not None: return value if env: - raise UsageError(f"--{name}-env names {variable}, which is not set", field=name) + raise UsageError(f"{flag}-env names {variable}, which is not set", field=name) return None diff --git a/tlgr/ops/account.py b/tlgr/ops/account.py index 3604ddf..b36b9a8 100644 --- a/tlgr/ops/account.py +++ b/tlgr/ops/account.py @@ -268,7 +268,7 @@ class AddReq(Request): ] = None use_qr: Annotated[bool, opt("--qr", help="Use QR login instead of a phone code.")] = False api_id: Annotated[ - int | None, opt("--api-id", metavar="ID", help="api_id for this account.") + int | None, opt("--api-id", metavar="ID", help="api_id; default: `auth api set`'s.") ] = None api_hash: Annotated[ str | None, opt(secret=True, envvar="TLGR_API_HASH", help="api_hash for this account.") diff --git a/tlgr/ops/auth.py b/tlgr/ops/auth.py index ac82322..9c4915c 100644 --- a/tlgr/ops/auth.py +++ b/tlgr/ops/auth.py @@ -27,6 +27,7 @@ from datetime import timedelta from typing import Annotated, Any +from tlgr.core.accounts import API_REGISTRATION_URL, check_api_credentials from tlgr.core.errors import ( AuthenticationError, AuthPasswordRequiredError, @@ -38,6 +39,7 @@ from tlgr.core.timefmt import parse_duration from tlgr.models.auth import ( AccountDeletion, + ApiCredentials, AutologinUrl, LoginCodes, LoginEmail, @@ -50,9 +52,12 @@ from tlgr.models.page import Page from tlgr.ops import _auth from tlgr.ops._params import arg, opt -from tlgr.ops._spec import OpContext, OperationSpec +from tlgr.ops._spec import OpContext, OperationSpec, Surface __all__ = [ + "SPEC_API_GET", + "SPEC_API_SET", + "SPEC_API_UNSET", "SPEC_AUTOLOGIN_URL_GET", "SPEC_CODE_LIST", "SPEC_LOGIN_EMAIL_SET", @@ -133,7 +138,14 @@ async def _caller(ctx: OpContext, service: Any, alias: str) -> Any: def _credentials( ctx: OpContext, alias: str, api_id: int | None, api_hash: str | None ) -> tuple[int, str]: - """`(api_id, api_hash)` from the flags, the account, the env or the config.""" + """`(api_id, api_hash)` for a login. + + The flags, then the account's own file, then the environment, then the + default `auth api set` saved. The default only ever fills in as a pair, + and only when it is the same app as an `api_id` already given: its hash + next to somebody else's id would fail as `API_ID_INVALID`, which reads + like the id is wrong. + """ import os manager = _auth.accounts(ctx) @@ -148,10 +160,18 @@ def _credentials( if not resolved_hash: resolved_hash = os.environ.get("TELEGRAM_API_HASH") or None if not resolved_id or not resolved_hash: - raise ConfigurationError( - "this account has no API credentials. Get them from my.telegram.org and pass " - "--api-id with --api-hash-env (never on the command line)." + default_id, default_hash = manager.load_default_credentials() + if default_id and default_hash and resolved_id in (None, default_id): + resolved_id, resolved_hash = default_id, default_hash + if not resolved_id or not resolved_hash: + error = ConfigurationError( + "no API credentials to log in with. Register an app once at " + f"{API_REGISTRATION_URL}, save it with `tlgr auth api set`, and every " + "login after that uses it." ) + error.hint = "Run: tlgr auth api set" + raise error + check_api_credentials(int(resolved_id), str(resolved_hash)) return int(resolved_id), str(resolved_hash) @@ -187,6 +207,239 @@ async def _authorized(ctx: OpContext, service: Any, alias: str, result: Any) -> ) +# --------------------------------------------------------------------------- +# auth api get / set / unset: the app every login uses +# --------------------------------------------------------------------------- + +#: What a person who has never registered an app needs to read, once. +_REGISTER_STEPS = f"""\ +tlgr logs in as an app you register with Telegram. It takes two minutes, once: + + 1. Open {API_REGISTRATION_URL} and log in with your phone number. + 2. Fill in "App title" and "Short name" (anything, e.g. "tlgr" and + "tlgrcli"), pick any platform, and create the app. + 3. Copy "App api_id" and "App api_hash" from the page. +""" + + +def _api_state(ctx: OpContext, **flags: bool) -> ApiCredentials: + manager = _auth.accounts(ctx) + api_id, api_hash = manager.load_default_credentials() + configured = bool(api_id and api_hash) + return ApiCredentials( + configured=configured, + api_id=api_id if configured else None, + api_hash=f"…{api_hash[-4:]}" if configured and api_hash else None, + path=str(manager.paths.api_credentials), + register_url="" if configured else API_REGISTRATION_URL, + **flags, + ) + + +class ApiGetReq(Request): + pass + + +async def api_get(ctx: OpContext, req: ApiGetReq) -> ApiCredentials: + """Which app a login uses when it is handed no credentials of its own.""" + return _api_state(ctx) + + +SPEC_API_GET = OperationSpec( + id="auth.api.get", + request=ApiGetReq, + response=ApiCredentials, + impl=api_get, + summary="Show the default api_id a login uses", + description=( + "Reads the default saved by `auth api set`. The hash is masked to its " + "last four characters. `configured: false` means a login with no " + "`--api-id` has nothing to use, and `register_url` is where to get one." + ), + idempotent=True, + needs_account=False, + needs_auth=False, + needs_client=False, + surface=Surface.LOCAL, + rate_class="local", + timeout_s=30, + columns=("configured", "api_id", "api_hash", "path"), + headers=("Configured", "api_id", "api_hash", "File"), + example={ + "configured": True, + "api_id": 1234567, + "api_hash": "…9f0c", + "path": "~/.tlgr/api.json", + }, + example_args="auth api get", + covers_partial=("auth.api-credentials",), + coverage_note="Registration itself happens at my.telegram.org; tlgr stores the result.", + tags=frozenset({"agent-safe"}), +) + + +class ApiSetReq(Request): + api_id: Annotated[ + int | None, + arg( + 0, + metavar="API_ID", + required=False, + help="The App api_id from my.telegram.org/apps; prompted for at a terminal.", + ), + ] = None + api_hash: Annotated[ + str | None, + opt(secret=True, envvar="TLGR_API_HASH", help="The App api_hash; never on argv."), + ] = None + + +def _interactive() -> bool: + import sys + + return bool(sys.stdin is not None and sys.stdin.isatty() and sys.stderr and sys.stderr.isatty()) + + +def _prompt_api(api_id: int | None, api_hash: str | None) -> tuple[int, str]: + """Ask a person at a terminal for whichever half is missing. + + The hash is read with `getpass`, so it never lands in the scrollback; the + id is not a secret and is echoed so a typo is visible. + """ + import getpass + import sys + + print(_REGISTER_STEPS, file=sys.stderr) + while True: + while api_id is None: + typed = input("api_id: ").strip() + if typed.isdigit(): + api_id = int(typed) + else: + print(" api_id is a number; copy it from the page.", file=sys.stderr) + while not api_hash: + api_hash = getpass.getpass("api_hash (hidden): ").strip() or None + try: + check_api_credentials(api_id, api_hash) + except UsageError as exc: + # A person at a terminal gets to retype the half that was wrong. + print(f" {exc}", file=sys.stderr) + if exc.field == "api_id": + api_id = None + else: + api_hash = None + continue + return api_id, api_hash + + +async def api_set(ctx: OpContext, req: ApiSetReq) -> ApiCredentials: + """Save the app every later login uses, so nobody types it twice. + + Given nothing at a terminal it walks the person through my.telegram.org + and prompts; anywhere else it needs `API_ID` and `--api-hash-env` (or + `-stdin`/`-file`) and says so. An official client's `api_id` is refused + outright: logging in with one is how accounts get banned. + """ + api_id, api_hash = req.api_id, (req.api_hash or "").strip() or None + if api_id is None or api_hash is None: + if not _interactive(): + missing = "API_ID" if api_id is None else "--api-hash-env (or -stdin/-file)" + raise UsageError( + f"{_REGISTER_STEPS}\nThen: tlgr auth api set API_ID --api-hash-env TLGR_API_HASH " + f"({missing} is missing)", + field="api_id" if api_id is None else "api_hash", + ) + api_id, api_hash = _prompt_api(api_id, api_hash) + check_api_credentials(api_id, api_hash) + + manager = _auth.accounts(ctx) + if manager.load_default_credentials() == (api_id, api_hash): + ctx.mark_already() + return _api_state(ctx, already=True) + manager.save_default_credentials(api_id, api_hash) + return _api_state(ctx, updated=True) + + +SPEC_API_SET = OperationSpec( + id="auth.api.set", + request=ApiSetReq, + response=ApiCredentials, + impl=api_set, + summary="Save the api_id/api_hash every login uses by default", + description=( + "Register an app once at my.telegram.org/apps and save it here; " + "`auth send-code`, `auth qr`, `account add --bot` and `account import` " + "then need no `--api-id`. Each login still copies the pair into the " + "account, so changing the default later never moves an existing " + "account to a different app. Written to `api.json` at 0600. At a " + "terminal with no arguments it prompts (the hash without echo). " + "Refuses a malformed hash and the api_ids of Telegram's own apps." + ), + mutating=True, + idempotent=True, + needs_account=False, + needs_auth=False, + needs_client=False, + surface=Surface.LOCAL, + rate_class="local", + timeout_s=30, + columns=("configured", "api_id", "api_hash", "path"), + headers=("Configured", "api_id", "api_hash", "File"), + example={ + "configured": True, + "api_id": 1234567, + "api_hash": "…9f0c", + "path": "~/.tlgr/api.json", + "updated": True, + }, + example_args="auth api set 1234567", + covers_partial=("auth.api-credentials",), + coverage_note="Registration itself happens at my.telegram.org; tlgr stores the result.", + tags=frozenset({"agent-safe"}), +) + + +class ApiUnsetReq(Request): + pass + + +async def api_unset(ctx: OpContext, req: ApiUnsetReq) -> ApiCredentials: + """Forget the default. Accounts keep the credentials they logged in with.""" + if not _auth.accounts(ctx).clear_default_credentials(): + ctx.mark_already() + return _api_state(ctx, already=True) + return _api_state(ctx, removed=True) + + +SPEC_API_UNSET = OperationSpec( + id="auth.api.unset", + request=ApiUnsetReq, + response=ApiCredentials, + impl=api_unset, + summary="Forget the default api_id/api_hash", + description=( + "Deletes `api.json`. Accounts that already logged in keep working: " + "each holds its own copy of the pair it was made with. Later logins " + "need `--api-id` again, or a new `auth api set`." + ), + mutating=True, + idempotent=True, + needs_account=False, + needs_auth=False, + needs_client=False, + surface=Surface.LOCAL, + rate_class="local", + timeout_s=30, + columns=("configured", "removed", "path"), + headers=("Configured", "Removed", "File"), + example={"configured": False, "removed": True, "path": "~/.tlgr/api.json"}, + example_args="auth api unset", + covers_partial=("auth.api-credentials",), + coverage_note="Registration itself happens at my.telegram.org; tlgr stores the result.", + tags=frozenset({"agent-safe"}), +) + + # --------------------------------------------------------------------------- # auth send-code # --------------------------------------------------------------------------- @@ -199,7 +452,8 @@ class SendCodeReq(Request): opt("--alias", help="Account alias to create or resume (default: the last 6 digits)."), ] = None api_id: Annotated[ - int | None, opt("--api-id", metavar="ID", help="api_id; else TLGR_API_ID, then the config.") + int | None, + opt("--api-id", metavar="ID", help="api_id; else TLGR_API_ID, then `auth api set`'s."), ] = None api_hash: Annotated[ str | None, @@ -590,7 +844,7 @@ class QrReq(Request): str | None, opt(secret=True, envvar="TLGR_2FA_PASSWORD", help="The 2FA cloud password.") ] = None api_id: Annotated[ - int | None, opt("--api-id", metavar="ID", help="api_id for this account.") + int | None, opt("--api-id", metavar="ID", help="api_id; default: `auth api set`'s.") ] = None api_hash: Annotated[ str | None, opt(secret=True, envvar="TLGR_API_HASH", help="api_hash for this account.") diff --git a/tlgr/ops/proxy.py b/tlgr/ops/proxy.py index 7c24c82..0026b85 100644 --- a/tlgr/ops/proxy.py +++ b/tlgr/ops/proxy.py @@ -682,7 +682,7 @@ async def _probe(ctx: OpContext, entry: dict[str, Any], timeout: int) -> ProxyPr row = ProxyProbe(id=str(entry.get("id", "")), name=str(entry.get("name", "") or "")) api_id, api_hash = _credentials(ctx) if not api_id or not api_hash: - row.error = "no API credentials are registered; run `tlgr account add` first" + row.error = "no API credentials are registered; run `tlgr auth api set` first" return row proxy, connection = _telethon_proxy(entry) @@ -715,10 +715,8 @@ def _credentials(ctx: OpContext) -> tuple[int | None, str | None]: manager = AccountManager(default_base()) alias = ctx.account or manager.get_active() or "" - if not alias: - return None, None with contextlib.suppress(Exception): - return manager.load_credentials(alias) + return manager.load_credentials(alias) if alias else manager.load_default_credentials() return None, None