Docs

On this page

Two small command-line tools that run on your own machine: telegram-tools for your own Telegram account, discord-tools for your own Discord servers. This page covers installing each one, the one-time setup, and what every command does. The two commands that cannot be undone get their own section.

Both need Python 3.11 or newer. There is no account to make and no server to point them at. After the setup below you type the tool’s name and pick from a numbered menu; the same jobs are also available as commands, for scripts and for agents.

telegram-tools

Telegram gives every chat, channel and forum topic a number, and then hides it. This finds those numbers, and does the handful of jobs the app makes tedious: searching an old thread, saving one to a file, emptying a forum topic without losing the topic. It signs in as you, the way the desktop app does.

Install

pip install telegram-tools
# or
pipx install telegram-tools
# or
uv tool install telegram-tools

Set it up

Telegram will not let a program sign in as you without your own API key. Getting one takes about two minutes, once.

  1. Open https://my.telegram.org/apps and log in with your Telegram phone number.
  2. Fill in the short “Create new application” form. Any name and short name will do; for platform, pick Desktop.
  3. Copy the App api_id (a number) and the App api_hash (a long string of letters and digits).
  4. Put them in the file the tool reads:
mkdir -p ~/.telegram-tools
cat > ~/.telegram-tools/.env <<'EOF'
TELEGRAM_API_ID=123456
TELEGRAM_API_HASH=your-api-hash-here
EOF

Treat the api_hash like a password. Shell environment variables and a .env in the folder you are standing in also work, and both win over ~/.telegram-tools/.env.

Then log in:

telegram-tools auth        # your phone number, then the code Telegram sends
telegram-tools auth --qr   # or scan a code from a phone that is already signed in

Either way it happens once, exactly like signing in on a new device — the login is saved and reused afterwards. If your account has two-step verification, the password is asked for at the prompt and stored nowhere. auth --qr needs one extra library, pip install 'telegram-tools[qr]', to draw the code; open Telegram on the signed-in phone, Settings → Devices → Link Desktop Device, and scan it.

telegram-tools auth --logout ends the session after you type the profile’s name back. The login also shows under Telegram’s Settings → Devices, like any other device.

More than one account

Each login is a profile, and you can have several:

telegram-tools auth --profile work        # log a second account in
telegram-tools --profile work discover    # act as it — the flag goes first
telegram-tools profiles                   # what this machine is logged in as

Every profile keeps its own login in ~/.telegram-tools/profiles/<name>/, written so only your user can read it, beside a small record holding a label and the account id — never your phone number, never a password. TELEGRAM_TOOLS_PROFILE sets which one is the default; with nothing set it is default.

If you used telegram-tools before profiles existed, nothing moved: your existing login is the default profile, read from where it has always been. doctor mentions it could move into a profile folder, and auth --migrate does it after asking.

Which account am I about to use?

Every command and every menu screen opens with the answer:

Acting as: Sven (@sven) · account · Target: Agency › 💻 Deploys (-1001234567890)

That is there because sending the right message to the wrong account is the mistake worth catching before it happens, not after. A phone number is never part of that line, and the file your login lives in is never printed anywhere.

Posting as one of your bots

telegram-tools --as-bot alerts send --chat -1001234567890 --topic 141 --text "deploy is green"

--as-bot goes before the command, like --profile, and names a bot whose token you have stored under that nickname in TELEGRAM_BOT_TOKENS. The message then comes from the bot, and the line at the top says so, naming both the bot and the account it belongs to:

Acting as: @alertsbot · bot (via Sven (@sven)) · Target: Agency › 💻 Deploys (-1001234567890:141)

A bot can do less than you, not more. Telegram gives a bot no list of chats, no history and no search, so under --as-bot only send and create topic run; anything else refuses and tells you to run it without the flag. The bot has to be a member of the chat already, --chat has to be an id or an @username (a bot cannot look up a link), and --yes needs the destination in your send allowlist exactly as it does for you. The bot’s token is never written to disk, and no screen ever prints it.

Going through a proxy

# in ~/.telegram-tools/.env, or a single profile's own .env
TELEGRAM_PROXY=socks5://127.0.0.1:1080

socks5://, socks4:// and http:// work, with an optional user:password@. It needs pip install 'telegram-tools[proxy]' — and without that library the command refuses to run rather than quietly connecting from your own address, which is the one outcome someone asking for a proxy must not get. doctor tells you before you run anything.

The commands

Run telegram-tools with nothing after it and the menu opens. Every command below has a row in it, so you never have to remember a flag.

Every command also has a machine-readable form for scripts and AI agents: put --json before the command name and it prints one object instead of a screen — the result, what it acted on, and a stable error code with a hint when it refuses. --jsonl streams a line per row for long answers. Anything a person would read moves out of the way so the object is the only thing on stdout, and without the flag nothing changes at all. The object also names the account the run acted as, so a script driving two profiles can check it used the right one. auth is the exception a script should never touch: it asks a human for a code.

discord-tools

Discord does not allow a person’s account to be automated, so this one drives a bot instead. You make the bot once — the tool walks you through Discord’s developer site — and after that it does the same set of jobs: IDs, members, history, sending, creating channels, roles and who can do what where, moderating members and invites, reading the server’s audit log, clearing them out. It can also stay running and watch a server, and put something in the calendar or in the queue to post later.

Install

pip install discord-tools-cli
# or
pipx install discord-tools-cli
# or
uv tool install discord-tools-cli

The package is called discord-tools-cli because the plain name was already taken on PyPI by an unrelated, abandoned package. The command you type afterwards is discord-tools.

Set it up

discord-tools auth

That one command is the setup. It walks you through the Discord Developer Portal, checks the token you paste back, checks that the message-content intent is switched on, saves the token, and prints the invite URL you use to add the bot to a server. Run discord-tools doctor afterwards to confirm the token, the intent, the servers the bot joined and its permissions.

The token is stored in ~/.discord-tools/.env, in a file only you can read, as a named profile:

DISCORD_BOT_TOKENS=default:token-a,dobby:token-b

Run auth once per bot to add another. --profile dobby, written before the command, picks one for a single run; DISCORD_TOOLS_PROFILE sets your default, and the menu can switch between them without restarting.

If message text comes back empty on every message, the message-content intent is off in the portal. doctor names it and auth walks you through turning it on.

A bot per agent

discord-tools auth --profile dobby        # set a second bot up
discord-tools --profile dobby send ...    # act as it — the flag goes first
discord-tools profiles                    # what this machine has
discord-tools profiles remove --name dobby

The tokens stay on that one DISCORD_BOT_TOKENS line. What sits beside them is ~/.discord-tools/profiles/<name>/profile.json, holding the bot’s name and the bot id auth saw when it checked the token — no token, nothing secret. It is there so a token pasted into the wrong profile is caught: a Discord bot token carries its own bot id, and if that disagrees with the recorded one the command stops before making a single call, rather than quietly acting as the wrong bot.

Removing a profile asks you to type its name, the same way deleting a channel does, and says first that the token is not recoverable from here — setting that bot up again means resetting its token in the portal.

Which bot am I about to use?

Every command and every menu screen opens with the answer:

Acting as: harrybot (profile harry) · bot · Target: Agency › 🚨alerts (1394827364512)

That is there because posting the right message as the wrong bot is the mistake worth catching before it happens, not after. A token is never part of that line. A token handed over in DISCORD_TOKEN has no profile to name, so the line says (token from environment) instead — a bot nobody named is the one worth naming.

Going through a proxy

# in ~/.discord-tools/.env
DISCORD_PROXY=http://127.0.0.1:3128

http:// and socks5:// work, with an optional user:password@. doctor prints the host it will go through and never the credentials.

The commands

Run discord-tools with nothing after it and the menu opens. Every command below has a row in it.

Reading the output with a script. Put --json before the subcommand and any command answers with one object on standard output, with previews, prompts and progress moved to standard error. The keys are the same whatever ran, so there is one thing to parse rather than one per command, and the exit code says the rest: 0 done, 1 stopped at a gate or only partly finished, 2 refused, 3 an answer was needed and there was no terminal to ask on. --jsonl streams one line per message, member, server or archive hit first, and closes with the same object.

Every write also reports the permission it needed, the permission it held, and what it read back afterwards — and if it could not read anything back it says so rather than claiming the write worked.

The folder is yours alone. ~/.discord-tools/, its .env and the profile records are written so only your user can read them, because that is where the bot token lives. If that stops being true — a copy restored with the wrong permissions, a folder shared onto a drive that does not keep them — doctor says which file and which mode, and every command that writes to Discord refuses until it is fixed. Reading still works, so you can find out what is wrong. Your exports are not part of that check; they are yours to share.

Before it sends or deletes anything

The two commands that cannot be undone are the two that ask the most.

Clearing messages dry-runs by default. clear-messages with no extra flags counts what it would delete and stops. Deleting for real takes two separate things: the --execute flag, and typing DELETE at the prompt. Nothing is deleted between those two steps. telegram-tools only ever touches messages — never the topic, never its ID — and checks you actually hold the delete-messages permission before it starts. discord-tools clearing a whole server includes threads and forum posts unless you pass --skip-threads, and reports anything it had to skip.

Deleting messages is the same gate on a selection. Both tools’ message delete lists every message it would remove — by id, or every archived match of a query — and stops; --execute then asks you to type DELETE, and a selection above 200 is refused rather than trimmed until you raise the limit yourself. There is no way to skip that prompt.

Nothing is downloaded until you say so. Attachments and links the archive sees wait in a review queue, listed without contacting any host. Fetching one takes a y/N at review approve, which has no --yes and stops with APPROVAL_REQUIRED when there is no terminal; the file then sits in quarantine, checked, until a second y/N at review accept — which shows the verdict first and refuses a file a check blocked or a scanner flagged.

Sending shows you the message first. send prints the whole message and its destination and waits for a yes, and so does every message verb on either tool — a reply, a reaction, a pin, a forward — after showing the message it is about to act on. On Discord a post pings nobody unless you pass --mention, and --mention everyone asks even when you said --yes. The --yes flag skips that prompt, and because a skipped prompt means nobody saw where the message was going, it only works for destinations you named in advance — TELEGRAM_SEND_ALLOWLIST for one tool, DISCORD_SEND_ALLOWLIST for the other. With neither set, every unattended send is refused.

Creating and editing ask too. create, bots and bot show what they are about to do — a diff, for an edit — and wait for a yes.

Applying a blueprint is the delete gate, used to build. structure apply dry-runs by default and lists every topic, role, category or channel it would make and every setting it would change. For real it takes --execute and the chat’s exact title or the server’s exact name typed at a terminal — there is no --yes, and with no terminal it stops with APPROVAL_REQUIRED — and it never deletes anything the chat or server has beyond the blueprint: those are reported as left alone. Each step it makes is written to the audit log, and on Discord every step carries the audit reason too.

Roles explain the refusal before the change. On Discord a role write can be allowed and still impossible: a bot acts only on roles below its own top role, never on a managed one, and can only hand out rights it holds. role and permission check all three before writing and say which one stopped it and what would fix it, instead of relaying a bare 403. Deleting a role is the delete gate — dry-run, then --execute and the role’s exact name, no --yes — and any change that grants or removes Administrator is typed the same way. The tool never edits the bot’s own roles, so it cannot raise its own rights.

A webhook URL is printed once and kept out of everything else. Anyone holding one posts into that channel as anything they like — no account, no bot, no invite — so discord-tools prints a whole one on exactly one screen, when you ask for it with --reveal, and blanks the secret part of it everywhere else: in the listing, in the --json a script reads, in the arguments it echoes back, in its own log, and in the preview it shows before deleting one. Without --reveal a new webhook’s URL is not printed at all, and the command tells you where to find it. There is no flag that hands a script a working URL.

Removing a webhook, an emoji, a sticker or an AutoMod rule is the delete gate. Each dry-runs by default and for real takes --execute and that thing’s exact name typed at a terminal, with no --yes. Deleting a webhook stops everything posting through it, and Discord cannot bring the same URL back; a removed emoji leaves every message that used it showing a hole; and a deleted AutoMod rule means the server has quietly stopped filtering what that rule filtered, with nothing to announce it. Adding an emoji or a sticker, writing or changing a rule, and editing a channel’s settings each show you what they will do and wait for a yes.

Banning someone, or taking their rights away, is the delete gate on a person. telegram-tools’ member ban and admin demote dry-run by default and show who would be affected; for real they take --execute and the person’s exact label typed at a terminal — their @username, or their name — with no --yes and no way to run without a terminal. Every other admin change (a promotion, a mute, an approved join request, a new or revoked invite link, slow mode) shows who acts on whom and waits for a yes, and none of them has a --yes either. Before any of it, the tool asks Telegram what rights your account holds and refuses by name when the one it needs is missing, or when you hold it but cannot use it on that person: an admin gives only the rights it has, and cannot edit an admin who has more.

discord-tools’ member kick and member ban work the same way: dry-run, then --execute and the member’s exact username typed at a terminal, with no --yes and a reason you have to give, because that reason is what the person sees and what the next moderator reads. A timeout, a nickname change, lifting a ban and making an invite each show who is affected and wait for a yes. Revoking an invite is the delete gate again — the exact code typed back — because the link stops working for everyone holding it and Discord cannot bring the same code back. Before any of it, the tool asks Discord what the bot holds and refuses by name when the right it needs is missing, or when it holds the right and still cannot use it on that person: the server’s owner, whom nobody can moderate, the bot itself, or somebody whose top role is not below the bot’s. A ban deletes no messages — that is clear-messages, and it has its own gate.

Downloading asks twice, and nothing downloads on its own. Neither tool fetches a link or an attachment because a sync or a rule saw it: it notes it in the review queue and stops. review approve shows you the link exactly as it was written, or the file’s name, type and size, and waits for a yes before it fetches anything — and only then does it follow a link’s redirects, one careful step at a time. What it fetches goes into a quarantine folder and through eleven checks: the address must be a public one, the file must be what it claims to be, an archive must not be a bomb, and a local ClamAV scans it if you have one. Then review accept shows you the verdict and waits for a second yes before the file is kept. A file the checks refused cannot be accepted at all; a file no scanner looked at is marked UNSCANNED, and it is your call. There is no --yes for either step, nothing is ever uploaded anywhere to be checked, and both steps refuse to run when nobody is at the terminal.

Nothing a rule does can be undone, because nothing a rule does changes anything. The list of things a rule may do is fixed in the code and closed: alert, tag, bookmark, record what arrived, sync a channel, queue something for review. There is no rule that deletes a message, edits one, sends one of its own or downloads a file — a rule asking for any of those is refused when it loads, not when it runs. An alert to a channel goes out through the same send path as everything else, so it can only reach a channel already on your allowlist; an alert that runs a command of yours is checked against your PATH the moment the rule is saved, rather than failing quietly at three in the morning. Every alert carries a marker, and an alert arriving back from another bot is ignored, so two watchers cannot talk each other into a loop.

A scheduled post is checked when you write it, not when it fires. The watcher posts with nobody looking, so a schedule post aimed at a channel that is not on your allowlist is refused there and then. A time already in the past is refused before you are asked to confirm it. And if the machine’s clock jumps — a laptop waking up, a timezone change — a missed post goes out once, marked late, rather than once for every hour it slept through.

The menu is never a shorter path past any of this. It has no equivalent of --yes at all, its clear screen still dry-runs first and still asks you to type DELETE, and backing out of a half-written message asks before throwing it away.

Where your files live

Everything either tool keeps is in one folder per tool, in your home directory.

There is no server, no account, and no third party. Nothing leaves your machine except the calls to Telegram and Discord that you asked for, and neither tool ever prints a token or a key — doctor will tell you a token is present and working without showing you what it is.

Both tools are MIT licensed, and the code is on GitHub: telegram-tools and discord-tools.