Skip to content
grigoreo-devPublic

About

Agent-first Telegram CLI (Go, MTProto)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

tgc — agent-first Telegram CLI

tgc

An agent-first Telegram CLI, written in Go.

Speaks MTProto through gotgproto and gotd/td — writes compact JSONL to stdout and structured JSON errors to stderr, so an agent can pipe and parse every result.

Release CI Go MTProto Output Agent-first License

Русская версия · Install · Quick start · Commands · Docs

$ tgc send @user "**hi**" --await-reply
{"id":42,"chat_id":123,"date":"2026-07-19T12:00:00Z","text":"hi","sender_id":123}
{"id":43,"chat_id":123,"date":"2026-07-19T12:00:04Z","text":"hey!","sender_id":456}

Pass --pretty when a human is reading.

Install

Recommended (no Go required — downloads a release binary):

curl -fsSL https://github.com/raw/grigoreo-dev/tgc/main/install.sh | sh

Env knobs: TGC_VERSION=vX.Y.Z for a specific version, TGC_INSTALL_DIR for a custom directory (default ~/.local/bin), GITHUB_TOKEN/GH_TOKEN to avoid API rate limits, TGC_NO_SETUP=1 to skip post-install PATH/completion setup.

After install, the script runs tgc self setup so new shell sessions find tgc on PATH and get bash/zsh/fish completion (per-user). Setup failure is a warning only — the binary install still succeeds. Opt out with TGC_NO_SETUP=1. Re-run anytime with tgc self setup; reverse with tgc self setup --remove (removes only the managed rc block and marked files).

tgc completion <shell> generates a completion script to stdout (for manual install). tgc self setup installs the same scripts into standard per-user locations. Prefer setup for day-to-day use.

zsh: the managed ~/.zshrc block updates fpath before completion loads — keep compinit after that block (default layouts usually already do).

Prefer to inspect first? Download install.sh, read it, then run it.

Alternative (requires Go 1.25+):

go install github.com/grigoreo-dev/tgc/cmd/tgc@latest

A go install build reports version dev and does not auto-check for updates or self-update. For automatic update checks and tgc self update, install via the script above. After go install, run tgc self setup once if you want PATH/completion wired the same way.

Updating

tgc self update    # download and install the latest release
tgc self check     # report {"update_available":...} without installing

While a newer release is available, tgc prints a one-line {"warning":"update_available",...} to stderr on each run. Set TGC_NO_UPDATE_CHECK=1 to disable the check entirely. After a successful update, marked completion files installed by tgc self setup are refreshed.

Security: releases are verified by sha256 checksum over HTTPS. Publisher signature verification (cosign) is planned for a future release.

Quick start

Get an api_id and api_hash at my.telegram.org, then log in:

tgc auth login --phone +NNN        # user account
tgc auth login --bot-token $TOKEN  # bot account

Once logged in:

tgc chats
tgc send @user "**hi**"
tgc read @user --limit 10

Commands

Run tgc --help for the full list; tgc <command> --help for each command's flags.

Command Purpose
auth Manage Telegram sessions (login, list, export, import, logout).
chats List dialogs (cached 5m; --fresh to refresh).
info Show a chat or user card (id, type, title; username, phone, about, members_count, bot, premium when set).
members List the members of a group.
search Search chats and messages (both by default); --type chats|messages|user|group|channel to narrow; --chat <peer> to search inside one chat (--from, --since, --until).
read Read chat history, newest first.
await Block until incoming messages arrive, print them, mark them read.
context Show a message with the messages surrounding it.
send Send a message (Markdown by default; --file for media; --await-reply to wait for the reply).
edit Edit a message you sent.
delete Delete messages (for everyone by default; --for-me to keep for others).
forward Forward a message to another chat.
download Download media from a message.

Output contract

  • stdout carries results only, as compact JSONL — one JSON object per line. This is the default so agents can pipe output straight into a parser.
  • stderr carries errors, as a single structured JSON line ({"error":"<code>","message":"..."}), and the process exits 1.
  • --pretty switches stdout to human-readable tables instead of JSONL.

Error codes

Errors use a stable set of machine-readable codes:

Code Meaning
ambiguous A selector matched more than one chat; candidates are in the error body.
bad_args Invalid arguments or flags.
bad_config The config file or an env var could not be parsed.
bot_unsupported The command is not available for bot accounts.
flood_wait Telegram rate-limited the request; retry after the wait.
io_error A local filesystem operation failed.
no_api_credentials api_id/api_hash are not set.
no_media The target message has no downloadable media.
not_authenticated No session for the selected profile.
not_found The chat, user, or message could not be resolved.
rich_unsupported The account rejected a --rich message.
upload_failed An uploaded file did not come back usable.

Environment variables

Variable Effect
TGC_PROFILE The active profile (same as --profile).
TGC_API_ID Telegram api_id.
TGC_API_HASH Telegram api_hash.
TGC_SESSION Import a session string directly, without a session file.
TGC_CONFIG_DIR Override the config/profile root. XDG_CONFIG_HOME is honored too.
TGC_DOWNLOAD_DIR Download root; defaults to ~/.tgc/downloads.
NO_COLOR Disable --pretty colors.

Profiles

tgc keeps named profiles, selected with --profile or TGC_PROFILE. Each profile stores its session under the config directory, and a profile is either a user login or a bot login. This lets you keep, say, a personal account and a bot side by side and switch between them per command.

Local project config (./.tgc)

Run tgc init in a project directory to create a local ./.tgc config, so that directory (and its subdirectories) uses its own default account:

tgc init --profile work
tgc auth login

tgc discovers config in this order: TGC_CONFIG_DIR → the nearest ./.tgc walking up from the current directory (not past your home directory) → $XDG_CONFIG_HOME/tgc → ~/.config/tgc. A shared parent workspace/.tgc covers all subprojects; a nearer ./.tgc overrides it. A .tgc directly in your home directory is not used as local config (that is what ~/.config/tgc is for).

tgc init writes .tgc/.gitignore (*) so sessions are never committed, and inherits api_id/api_hash from your global config if set. Inspect the active config with:

tgc config path

Bot-mode limits

A bot account can't list dialogs or run search — both return bot_unsupported, because Telegram doesn't expose dialog listing or contacts search to bots. A bot can send and read in chats it belongs to: address a user by @username, or by numeric user id for someone who has already messaged the bot.

Await incoming (agent conversations)

When an agent needs the other side's reply rather than a snapshot of history, tgc await blocks until unread messages arrive, prints them, marks them read, and exits — the wait-for-reply primitive, no polling loop required.

tgc await @user                       # block until a message arrives
tgc await @user --timeout 30          # give up after 30s
tgc send @bot "ping" --await-reply    # send, then wait for the reply on one connection

await debounces: it waits for a quiet gap before returning, so a burst (or a media group) is coalesced into a single batch. Messages print oldest→newest, one compact JSON object per line — the same shape as read:

{"id":42,"chat_id":123,"date":"2026-07-19T12:00:00Z","text":"hi","sender_id":123, ...}

Telegram rich messages (Bot API 10.1) carry their body in a rich block tree rather than plain text. read/context/await render that body to Markdown in the text field and add "rich": true; if the copy was truncated (or a full fetch was skipped/failed), "rich_truncated": true is also set.

On silence until the timeout, await prints a marker and exits 0 (a normal outcome, not an error):

{"status":"timeout","chat_id":123,"waited":30}
Flag Command Default Effect
--timeout await 300 Max seconds to wait before emitting the timeout marker.
--debounce await 2 Seconds of quiet before returning a batch (0 = off).
--from await — Only messages from this sender (selector).
--await-reply send false After sending, wait for the reply on the same connection.
--await-timeout send 300 --await-reply: max seconds to wait.
--await-debounce send 2 --await-reply: quiet seconds before returning a batch.
--await-from send — --await-reply: only messages from this sender.

Read-receipt side effect: await marks the awaited messages read, which is a visible action to the contact (they see your "read" state advance). This is intentional — the messages are consumed — but it is not a silent peek.

One await per profile: a profile holds a single MTProto session, so you cannot run two awaits (or a send --await-reply alongside another await) concurrently on the same profile. Parallel agents must use separate profiles (--profile / TGC_PROFILE, or a per-project ./.tgc).

Bots are live-only: no unread backfill and no mark-read, so a bot await catches only messages that arrive while it is waiting. --await-reply is not supported together with --file.

A dev-only bot-side harness for exercising these paths lives at scripts/await-e2e.sh.

Rich messages

Sending. By default, Markdown renders through Telegram message entities, which every account supports. A server-side rich-message path also exists, but user accounts reject it with RICH_MESSAGE_UNSUPPORTED, so tgc falls back to entities transparently. See docs/rich-spike.md for the full investigation and the live result.

Reading. Telegram Rich Messages (Bot API 10.1) put their body in a rich block tree (tg.Message.RichMessage) with an empty plain text field. read, context, and await render that block tree — headings, bold/italic/spoiler, code, lists, blockquotes, tables, math, and inline media references — into Markdown in the text field and add "rich": true. If the inline copy was truncated (or a full fetch was skipped/failed), "rich_truncated": true is also set. Untrusted text is Markdown-escaped so a sender cannot forge formatting.

Contributing

Contributions are welcome. The workflow mirrors CI, so what passes locally passes in the pipeline.

Setup

git clone https://github.com/grigoreo-dev/tgc
cd tgc
go build ./...

Before you open a PR, run the same checks CI does:

go build ./...
go vet ./...
go test ./...
shellcheck install.sh   # if you touched the install script

Keep changes focused, follow the existing package layout under internal/, and preserve the output contract — results as JSONL on stdout, structured errors on stderr. User-facing changes should update the README (and README.ru.md).

Live e2e suite. scripts/e2e/ holds a bidirectional live test suite that drives tgc against real test accounts (a user and a bot). It is not run by CI — it needs credentials and dedicated throwaway accounts. See scripts/e2e/README.md for preconditions and how to run scripts/e2e/run-all.sh.

Releases

Release versions and CHANGELOG.md are generated from Conventional Commits. Use squash merge and give ordinary PRs a title such as fix: handle empty chat, feat: add search, or feat(cli)!: change the output contract. CI rejects other titles (conventional-commit-title). Release Please opens a Release PR whose generated title (for example chore(main): release 0.2.0) is Conventional-Commit-compatible, so that check must pass on the Release PR as well.

After releasable commits reach main, Release Please opens or updates that Release PR with the next version and changelog. Review it, then merge when the release is ready — do not create release tags by hand.

On merge of the Release PR, the release workflow:

  1. Runs build, vet, test, and install.sh shellcheck (verify) on the merge commit on main.
  2. Lets Release Please create the vX.Y.Z git tag and a draft GitHub Release (notes/body from the generated changelog).
  3. Runs GoReleaser against that tag to attach archives and checksums.txt to the existing draft (retry-safe replace).
  4. Publishes the release with gh release edit … --draft=false only after assets succeed.

To rebuild assets for an existing draft that is not yet published, without re-running Release Please, use Actions → Release → Run workflow and pass the existing tag_name (for example v0.2.0). That path validates the tag, checks out that tag for verify and GoReleaser, then republishes the same draft after assets succeed. Do not use it for already-published releases.

While tgc is below v1.0.0, breaking changes release the next 0.x.0 version (bump-minor-pre-major). v1.0.0 is an explicit maintainer decision, not an automatic consequence of a breaking-change commit.

Repository settings (maintainer / admin, not enforced by workflow YAML):

  1. Squash merge — Settings → General → Pull Requests: enable squash merging; prefer defaulting the squash commit message to the PR title so validated titles become the commits Release Please reads.
  2. Required checks on main — branch protection or ruleset: require status checks before merge; include at least test, lint, and conventional-commit-title; prefer requiring branches to be up to date. With the built-in GITHUB_TOKEN, Release Please PRs are opened by github-actions[bot], so conventional-commit-title runs and must pass (the generated title is Conventional-Commit-compatible). Treat Release PRs like any other PR for required checks; do not rely on a Skipped title job.

Local release-config contract (no credentials, no publish):

sh scripts/check-release-config.sh
docker run --rm -v "$PWD":/work -w /work goreleaser/goreleaser:v2.12.7 check --config .goreleaser.yaml

Use a concrete GoReleaser image tag such as v2.12.7 for check; the floating tag goreleaser/goreleaser:v2 is not a published image.

Issue tracking with beads (bd)

This repo tracks work with beads (bd), a git-native, dependency-aware issue tracker. You don't need it to send a PR, but if you want to pick up existing work or coordinate a larger change:

bd ready                     # list unblocked, claimable work
bd show <id>                 # read an issue and its dependencies
bd update <id> --claim       # atomically take it (sets you as assignee)
bd close <id> --reason "..." # mark it done

As an outside contributor, initialise beads in contributor mode so your planning issues route to a separate local repo and stay out of your PR:

bd init --contributor

Reference the bead id in your commit messages (e.g. tgc-a1b2: fix flood-wait backoff) so the change is traceable back to its issue.

Documentation

About

Agent-first Telegram CLI (Go, MTProto)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages